Ship Novya PWA with backend for deployable production use.
Fix production TypeScript builds and add DEPLOY.md so the app can be hosted from this repo. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Assessment Service Abstraction
|
||||
*
|
||||
* This module provides a clean interface for assessment operations.
|
||||
* Currently implemented with localStorage for offline/guest mode.
|
||||
*
|
||||
* To migrate to Node.js + Prisma backend:
|
||||
* 1. Create a new implementation (e.g., assessmentServiceApi.ts)
|
||||
* 2. Replace the import below to point to the API implementation
|
||||
* 3. No changes needed in any UI component or hook
|
||||
*/
|
||||
|
||||
import type { AttemptPhase } from '@/hooks/useAssessmentAttempts';
|
||||
import { type RawAnswer, type DimensionName } from '@/lib/assessment/scoring';
|
||||
import { api, hasSessionToken } from '@/services/backend';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type { AttemptPhase };
|
||||
|
||||
export interface AssessmentAnswer {
|
||||
questionId: string;
|
||||
selectedValue: number;
|
||||
dimension: DimensionName;
|
||||
isReverseScored: boolean;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface AssessmentScores {
|
||||
total: number;
|
||||
efficacy: number;
|
||||
hope: number;
|
||||
resilience: number;
|
||||
optimism: number;
|
||||
}
|
||||
|
||||
export interface AssessmentSubmission {
|
||||
id: string;
|
||||
userId: string;
|
||||
phase: AttemptPhase;
|
||||
scores: AssessmentScores;
|
||||
answers: AssessmentAnswer[];
|
||||
completedAt: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Input accepted from AssessmentPage (raw answers before scoring)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RawAssessmentInput {
|
||||
userId: string;
|
||||
phase: AttemptPhase;
|
||||
answers: RawAnswer[];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Storage Key
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ASSESSMENT_STORAGE_KEY = 'assessments';
|
||||
|
||||
const getKey = (userId: string) => `${ASSESSMENT_STORAGE_KEY}:${userId}`;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Local Storage Implementation
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const getAll = (userId: string): AssessmentSubmission[] => {
|
||||
try {
|
||||
const raw = localStorage.getItem(getKey(userId));
|
||||
return raw ? (JSON.parse(raw) as AssessmentSubmission[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const saveAll = (userId: string, submissions: AssessmentSubmission[]) => {
|
||||
localStorage.setItem(getKey(userId), JSON.stringify(submissions));
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Backend Mapping
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
type BackendAttempt = {
|
||||
id: string;
|
||||
phase: AttemptPhase;
|
||||
submittedAt: string;
|
||||
scores: AssessmentScores;
|
||||
answers: AssessmentAnswer[];
|
||||
};
|
||||
|
||||
const isOfflineGuest = (userId: string): boolean => userId.startsWith('guest-');
|
||||
|
||||
const shouldUseBackend = (userId: string): boolean => hasSessionToken() && !isOfflineGuest(userId);
|
||||
|
||||
function mapBackendAttempt(userId: string, attempt: BackendAttempt): AssessmentSubmission {
|
||||
return {
|
||||
id: attempt.id,
|
||||
userId,
|
||||
phase: attempt.phase,
|
||||
scores: attempt.scores,
|
||||
answers: attempt.answers,
|
||||
completedAt: attempt.submittedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Score and save a completed assessment.
|
||||
*
|
||||
* Receives raw answers → runs PCQ-24 scoring engine → saves result.
|
||||
*
|
||||
* @throws ValidationError — if answers are missing, duplicated, or out of range
|
||||
*/
|
||||
export async function saveAssessment(input: RawAssessmentInput): Promise<AssessmentSubmission> {
|
||||
if (shouldUseBackend(input.userId)) {
|
||||
const attempt = await api.post<BackendAttempt>('/assessments/attempts', {
|
||||
phase: input.phase,
|
||||
answers: input.answers,
|
||||
});
|
||||
const mapped = mapBackendAttempt(input.userId, attempt);
|
||||
const existing = getAll(input.userId).filter((entry) => entry.phase !== input.phase);
|
||||
saveAll(input.userId, [mapped, ...existing]);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
throw new Error('Assessment submission requires backend session');
|
||||
}
|
||||
|
||||
async function getLatestByPhaseFromBackend(
|
||||
userId: string,
|
||||
phase: AttemptPhase
|
||||
): Promise<AssessmentSubmission | null> {
|
||||
if (!shouldUseBackend(userId)) {
|
||||
return getAll(userId).find((s) => s.phase === phase) ?? null;
|
||||
}
|
||||
|
||||
const attempt = await api.get<BackendAttempt | null>(`/assessments/attempts/latest?phase=${phase}`);
|
||||
if (!attempt) return null;
|
||||
const mapped = mapBackendAttempt(userId, attempt);
|
||||
const existing = getAll(userId);
|
||||
const next = [mapped, ...existing.filter((entry) => entry.phase !== phase)];
|
||||
saveAll(userId, next);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all assessment submissions for a user.
|
||||
*/
|
||||
export async function getAssessmentHistory(userId: string): Promise<AssessmentSubmission[]> {
|
||||
if (!shouldUseBackend(userId)) return getAll(userId);
|
||||
|
||||
const attempts = await api.get<BackendAttempt[]>('/assessments/attempts');
|
||||
const mapped = attempts.map((attempt) => mapBackendAttempt(userId, attempt));
|
||||
saveAll(userId, mapped);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest submission for a specific phase (pre or post).
|
||||
*/
|
||||
export async function getLatestByPhase(
|
||||
userId: string,
|
||||
phase: AttemptPhase
|
||||
): Promise<AssessmentSubmission | null> {
|
||||
return getLatestByPhaseFromBackend(userId, phase);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a phase has already been completed.
|
||||
*/
|
||||
export async function isPhaseCompleted(userId: string, phase: AttemptPhase): Promise<boolean> {
|
||||
return (await getLatestByPhaseFromBackend(userId, phase)) !== null;
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Backend API client for the Novya PWA.
|
||||
*
|
||||
* Handles:
|
||||
* - Base URL resolution (VITE_BACKEND_URL or /api proxy)
|
||||
* - Bearer token injection from localStorage
|
||||
* - 401 handling (clear session, redirect to login)
|
||||
* - Typed JSON responses matching backend ApiSuccess / ApiError
|
||||
*/
|
||||
|
||||
const SESSION_TOKEN_KEY = 'novya-session-token';
|
||||
|
||||
/** Prefer Vite dev proxy (`/api/backend`) to avoid cross-origin issues on localhost. */
|
||||
const baseUrl = (import.meta.env.VITE_BACKEND_URL || '/api/backend').replace(/\/$/, '');
|
||||
|
||||
export type ApiSuccess<T = unknown> = {
|
||||
success: true;
|
||||
data: T;
|
||||
meta: { requestId: string; timestamp: string };
|
||||
};
|
||||
|
||||
export type ApiErrorBody = {
|
||||
success: false;
|
||||
error: { code: string; message: string; details?: unknown };
|
||||
meta: { requestId: string; timestamp: string };
|
||||
};
|
||||
|
||||
export type ApiResponse<T = unknown> = ApiSuccess<T> | ApiErrorBody;
|
||||
|
||||
export class BackendError extends Error {
|
||||
code: string;
|
||||
status: number;
|
||||
details?: unknown;
|
||||
|
||||
constructor(status: number, code: string, message: string, details?: unknown) {
|
||||
super(message);
|
||||
this.name = 'BackendError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionToken(): string | null {
|
||||
return localStorage.getItem(SESSION_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setSessionToken(token: string): void {
|
||||
localStorage.setItem(SESSION_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
export function clearSessionToken(): void {
|
||||
localStorage.removeItem(SESSION_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function hasSessionToken(): boolean {
|
||||
return Boolean(localStorage.getItem(SESSION_TOKEN_KEY));
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const url = `${baseUrl}${path}`;
|
||||
const token = getSessionToken();
|
||||
|
||||
const headers = new Headers(options.headers);
|
||||
if (!headers.has('Content-Type') && options.body) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { ...options, headers });
|
||||
} catch {
|
||||
throw new BackendError(
|
||||
0,
|
||||
'NETWORK_ERROR',
|
||||
'سرور در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
let body: ApiResponse<T>;
|
||||
try {
|
||||
body = (await response.json()) as ApiResponse<T>;
|
||||
} catch {
|
||||
throw new BackendError(
|
||||
response.status || 0,
|
||||
'NETWORK_ERROR',
|
||||
'سرور در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok || !body.success) {
|
||||
const err = body as ApiErrorBody;
|
||||
const isCredentialAttempt = path === '/auth/login' || path === '/auth/register';
|
||||
if (response.status === 401 && !isCredentialAttempt) {
|
||||
clearSessionToken();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('novya:unauthorized'));
|
||||
}
|
||||
}
|
||||
throw new BackendError(
|
||||
response.status,
|
||||
err.error?.code ?? 'UNKNOWN',
|
||||
err.error?.message ?? `Request failed with status ${response.status}`,
|
||||
err.error?.details,
|
||||
);
|
||||
}
|
||||
|
||||
return (body as ApiSuccess<T>).data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, data?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'POST',
|
||||
body: data !== undefined ? JSON.stringify(data) : undefined,
|
||||
}),
|
||||
patch: <T>(path: string, data?: unknown) =>
|
||||
request<T>(path, {
|
||||
method: 'PATCH',
|
||||
body: data !== undefined ? JSON.stringify(data) : undefined,
|
||||
}),
|
||||
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
@@ -1,297 +0,0 @@
|
||||
// URL پیشفرض n8n chat flow
|
||||
const DEFAULT_N8N_CHAT_URL = 'https://nn.feep.ir/webhook/ad03737e-f857-4b5c-87d0-601327b2081c/chat';
|
||||
const n8nWebhookUrl = import.meta.env.VITE_N8N_WEBHOOK_URL || DEFAULT_N8N_CHAT_URL;
|
||||
const n8nApiKey = import.meta.env.VITE_N8N_API_KEY || '';
|
||||
|
||||
export type ChatMessage = {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
export const isN8nConfigured = Boolean(n8nWebhookUrl);
|
||||
|
||||
// ذخیره sessionId برای هر کاربر (برای حفظ context در Simple Memory)
|
||||
const sessionIdMap = new Map<string, string>();
|
||||
|
||||
// تولید یا دریافت sessionId برای یک کاربر
|
||||
const getOrCreateSessionId = (userId: string): string => {
|
||||
if (!sessionIdMap.has(userId)) {
|
||||
// تولید sessionId جدید (UUID-like)
|
||||
const sessionId = `${userId}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
||||
sessionIdMap.set(userId, sessionId);
|
||||
console.log('🔑 ایجاد sessionId جدید:', sessionId, 'برای کاربر:', userId);
|
||||
}
|
||||
return sessionIdMap.get(userId)!;
|
||||
};
|
||||
|
||||
// در development از proxy استفاده میکنیم، در production از URL مستقیم
|
||||
const getN8nUrl = () => {
|
||||
// اگر در development هستیم و URL شامل nn.feep.ir است، از proxy استفاده کن
|
||||
const useProxy = import.meta.env.DEV && n8nWebhookUrl.includes('nn.feep.ir');
|
||||
|
||||
if (useProxy) {
|
||||
console.log('🔀 استفاده از proxy برای دور زدن CORS');
|
||||
// استخراج مسیر webhook از URL کامل
|
||||
try {
|
||||
const url = new URL(n8nWebhookUrl);
|
||||
const urlPath = url.pathname;
|
||||
const proxyUrl = `/api/n8n${urlPath}`;
|
||||
console.log('📍 مسیر proxy:', proxyUrl, 'از URL اصلی:', n8nWebhookUrl);
|
||||
return proxyUrl;
|
||||
} catch (error) {
|
||||
console.error('❌ خطا در parse کردن URL:', error);
|
||||
// اگر URL معتبر نیست، از URL مستقیم استفاده کن
|
||||
return n8nWebhookUrl;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🌐 استفاده از URL مستقیم:', n8nWebhookUrl);
|
||||
return n8nWebhookUrl;
|
||||
};
|
||||
|
||||
/**
|
||||
* ارسال پیام به n8n و دریافت پاسخ
|
||||
* @param message - پیام کاربر
|
||||
* @param userId - شناسه کاربر
|
||||
* @param conversationHistory - تاریخچه مکالمه (اختیاری)
|
||||
* @returns پاسخ از n8n
|
||||
*/
|
||||
export async function sendMessageToN8n(
|
||||
message: string,
|
||||
userId: string,
|
||||
_conversationHistory?: ChatMessage[] // n8n Chat با Simple Memory خودش session را مدیریت میکند، این پارامتر برای backward compatibility است
|
||||
): Promise<string> {
|
||||
if (!isN8nConfigured) {
|
||||
throw new Error(
|
||||
'n8n تنظیم نشده است. مقدار VITE_N8N_WEBHOOK_URL را در فایل .env تنظیم کنید.'
|
||||
);
|
||||
}
|
||||
|
||||
// فرمت n8n Chat Trigger - این workflow از "When chat message received" استفاده میکند
|
||||
// فرمت مورد نیاز: { sessionId, action: "sendMessage", chatInput }
|
||||
const sessionId = getOrCreateSessionId(userId);
|
||||
|
||||
const payload = {
|
||||
sessionId: sessionId,
|
||||
action: 'sendMessage',
|
||||
chatInput: message
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// اگر API Key تنظیم شده باشد، اضافه میکنیم
|
||||
if (n8nApiKey) {
|
||||
headers['Authorization'] = `Bearer ${n8nApiKey}`;
|
||||
// یا میتوانید از header دیگری استفاده کنید
|
||||
// headers['X-API-Key'] = n8nApiKey;
|
||||
}
|
||||
|
||||
try {
|
||||
const actualUrl = getN8nUrl();
|
||||
console.log('📤 ارسال به n8n:', { url: actualUrl, originalUrl: n8nWebhookUrl, payload });
|
||||
|
||||
const response = await fetch(actualUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
// اضافه کردن mode برای جلوگیری از CORS issues
|
||||
mode: 'cors',
|
||||
credentials: 'omit'
|
||||
});
|
||||
|
||||
console.log('📥 پاسخ از n8n:', { status: response.status, ok: response.ok });
|
||||
|
||||
if (!response.ok) {
|
||||
let errorText = '';
|
||||
try {
|
||||
errorText = await response.text();
|
||||
// اگر پاسخ HTML است (مثل خطای 500)، پیام بهتری نمایش بده
|
||||
if (errorText.trim().startsWith('<!DOCTYPE') || errorText.includes('<html')) {
|
||||
console.error('❌ خطا از n8n (HTML Error Page):', { status: response.status });
|
||||
throw new Error(
|
||||
`خطا در n8n workflow (${response.status}): خطای داخلی سرور. لطفا workflow را در n8n بررسی کنید:\n` +
|
||||
`1. آیا workflow فعال است؟\n` +
|
||||
`2. آیا Message a model node درست تنظیم شده؟\n` +
|
||||
`3. آیا API Key OpenAI درست است؟\n` +
|
||||
`4. آیا Respond to Webhook node وجود دارد؟`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message.includes('خطا در n8n workflow')) {
|
||||
throw e;
|
||||
}
|
||||
errorText = response.statusText;
|
||||
}
|
||||
console.error('❌ خطا از n8n:', { status: response.status, errorText });
|
||||
throw new Error(
|
||||
`خطا در ارتباط با n8n (${response.status}): ${errorText.substring(0, 200) || response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
// بررسی Content-Type
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
console.log('📋 Content-Type:', contentType);
|
||||
|
||||
const responseText = await response.text();
|
||||
console.log('📄 پاسخ خام از n8n (اولین 500 کاراکتر):', responseText.substring(0, 500));
|
||||
|
||||
if (!responseText || responseText.trim() === '') {
|
||||
throw new Error('پاسخ خالی از n8n دریافت شد.');
|
||||
}
|
||||
|
||||
// اگر پاسخ HTML است (خطای 500)، خطا بده
|
||||
if (responseText.trim().startsWith('<!DOCTYPE') || responseText.includes('<html')) {
|
||||
console.error('❌ پاسخ HTML دریافت شد (خطای سرور)');
|
||||
throw new Error(
|
||||
'n8n خطای سرور برمیگرداند. لطفا بررسی کنید:\n' +
|
||||
'1. آیا workflow فعال است؟\n' +
|
||||
'2. آیا همه node ها درست تنظیم شدهاند؟\n' +
|
||||
'3. آیا Respond to Webhook node وجود دارد و درست تنظیم شده؟'
|
||||
);
|
||||
}
|
||||
|
||||
// تلاش برای parse کردن JSON
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch (jsonError) {
|
||||
// اگر JSON نیست، ممکنه string ساده باشه
|
||||
console.warn('⚠️ پاسخ JSON نیست، به عنوان string استفاده میشود');
|
||||
return responseText.trim();
|
||||
}
|
||||
} catch (parseError) {
|
||||
if (parseError instanceof Error && parseError.message.includes('n8n خطای سرور')) {
|
||||
throw parseError;
|
||||
}
|
||||
console.error('❌ خطا در parse کردن پاسخ:', parseError);
|
||||
throw new Error('فرمت پاسخ n8n نامعتبر است (JSON parse failed): ' + String(parseError));
|
||||
}
|
||||
|
||||
console.log('📦 داده parse شده از n8n:', data);
|
||||
|
||||
// n8n Chat Trigger + AI Agent معمولاً پاسخ را در فرمتهای مختلف برمیگرداند
|
||||
// این فرمتهای رایج را پشتیبانی میکنیم:
|
||||
|
||||
// 0. فرمت AI Agent output (اولویت اول برای n8n chat)
|
||||
if (data.output && typeof data.output === 'string' && data.output.trim()) {
|
||||
console.log('✅ پاسخ از AI Agent (output):', data.output);
|
||||
return data.output.trim();
|
||||
}
|
||||
|
||||
// 0.1. فرمت AI Agent با object output
|
||||
if (data.output && typeof data.output === 'object') {
|
||||
// بررسی فیلدهای مختلف در output
|
||||
if (data.output.output && typeof data.output.output === 'string' && data.output.output.trim()) {
|
||||
console.log('✅ پاسخ از AI Agent (output.output):', data.output.output);
|
||||
return data.output.output.trim();
|
||||
}
|
||||
if (data.output.text && typeof data.output.text === 'string' && data.output.text.trim()) {
|
||||
console.log('✅ پاسخ از AI Agent (output.text):', data.output.text);
|
||||
return data.output.text.trim();
|
||||
}
|
||||
if (data.output.response && typeof data.output.response === 'string' && data.output.response.trim()) {
|
||||
console.log('✅ پاسخ از AI Agent (output.response):', data.output.response);
|
||||
return data.output.response.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// 1. اگر پاسخ از Message a model (GPT) با Simplify Output خاموش است
|
||||
if (data.choices && Array.isArray(data.choices) && data.choices.length > 0) {
|
||||
const content = data.choices[0]?.message?.content;
|
||||
if (content && content.trim()) {
|
||||
console.log('✅ پاسخ از GPT (choices):', content);
|
||||
return content.trim();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. اگر Simplify Output فعال است - پاسخ مستقیم از GPT
|
||||
if (data.response && typeof data.response === 'string' && data.response.trim()) {
|
||||
console.log('✅ پاسخ از n8n (response):', data.response);
|
||||
return data.response.trim();
|
||||
}
|
||||
|
||||
// 3. اگر response خالی است، ببین آیا در جای دیگری هست
|
||||
if (data.response === '' || data.response === null) {
|
||||
// شاید پاسخ در جای دیگری باشد
|
||||
if (data.choices?.[0]?.message?.content) {
|
||||
const content = data.choices[0].message.content;
|
||||
if (content && content.trim()) {
|
||||
console.log('✅ پاسخ از GPT (در choices):', content);
|
||||
return content.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. فرمتهای دیگر
|
||||
if (typeof data === 'string' && data.trim()) {
|
||||
return data.trim();
|
||||
}
|
||||
|
||||
if (data.message && typeof data.message === 'string' && data.message.trim()) {
|
||||
return data.message.trim();
|
||||
}
|
||||
|
||||
if (data.text && typeof data.text === 'string' && data.text.trim()) {
|
||||
return data.text.trim();
|
||||
}
|
||||
|
||||
if (data.data?.response && typeof data.data.response === 'string' && data.data.response.trim()) {
|
||||
return data.data.response.trim();
|
||||
}
|
||||
|
||||
if (data.data?.message && typeof data.data.message === 'string' && data.data.message.trim()) {
|
||||
return data.data.message.trim();
|
||||
}
|
||||
|
||||
// 5. اگر پاسخ یک object است و هیچ فیلد متنی نداره، خطا بده
|
||||
if (typeof data === 'object') {
|
||||
// بررسی عمیقتر برای پیدا کردن متن
|
||||
const findTextInObject = (obj: any): string | null => {
|
||||
if (typeof obj === 'string' && obj.trim()) {
|
||||
return obj.trim();
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
for (const item of obj) {
|
||||
const text = findTextInObject(item);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
if (obj && typeof obj === 'object') {
|
||||
for (const key in obj) {
|
||||
if (key === 'content' || key === 'text' || key === 'message' || key === 'response') {
|
||||
const text = findTextInObject(obj[key]);
|
||||
if (text) return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const foundText = findTextInObject(data);
|
||||
if (foundText) {
|
||||
console.log('✅ متن پیدا شد در object:', foundText);
|
||||
return foundText;
|
||||
}
|
||||
|
||||
// اگر هیچ متنی پیدا نشد، خطا بده
|
||||
console.error('❌ هیچ متن معتبری در پاسخ پیدا نشد:', JSON.stringify(data, null, 2));
|
||||
throw new Error('پاسخ از n8n خالی است یا فرمت نامعتبر دارد. لطفا Respond to Webhook را بررسی کنید.');
|
||||
}
|
||||
|
||||
throw new Error('فرمت پاسخ n8n نامعتبر است. ساختار: ' + JSON.stringify(data));
|
||||
} catch (error) {
|
||||
console.error('❌ خطا در sendMessageToN8n:', error);
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
throw new Error('خطا در اتصال به n8n. لطفا بررسی کنید:\n1. URL صحیح است؟\n2. n8n در دسترس است؟\n3. مشکل CORS وجود دارد؟');
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('خطا در ارتباط با n8n: ' + String(error));
|
||||
}
|
||||
}
|
||||
|
||||
+260
-31
@@ -1,8 +1,21 @@
|
||||
const openAiApiKey = import.meta.env.VITE_OPENAI_API_KEY || '';
|
||||
const openAiBaseUrl = import.meta.env.VITE_OPENAI_BASE_URL || 'https://api.openai.com/v1';
|
||||
const openAiModel = import.meta.env.VITE_OPENAI_MODEL || 'gpt-4o-mini';
|
||||
const openAiOrg = import.meta.env.VITE_OPENAI_ORG_ID || '';
|
||||
const openAiProject = import.meta.env.VITE_OPENAI_PROJECT_ID || '';
|
||||
/**
|
||||
* @deprecated Direct provider calls are no longer used by the PWA chat path.
|
||||
* Chat and AI runtime now go through the backend (`/chat/*` + backend AI client).
|
||||
* This file is retained only as a historical adapter and must not be imported
|
||||
* by runtime UI code.
|
||||
*/
|
||||
import { getFetchErrorMessage, isAbortError } from '@/lib/fetchError';
|
||||
|
||||
|
||||
const openAiApiKey = (import.meta.env.VITE_OPENAI_API_KEY || '').replace(/^\uFEFF/, '').trim();
|
||||
const openAiBaseUrl = (import.meta.env.VITE_OPENAI_BASE_URL || 'https://api.gapgpt.app/v1')
|
||||
.trim()
|
||||
.replace(/\/$/, '');
|
||||
const openAiModel = (import.meta.env.VITE_OPENAI_MODEL || 'gpt-4o-mini').trim();
|
||||
const openAiOrg = (import.meta.env.VITE_OPENAI_ORG_ID || '').trim();
|
||||
const openAiProject = (import.meta.env.VITE_OPENAI_PROJECT_ID || '').trim();
|
||||
|
||||
const OPENAI_TIMEOUT_MS = 120_000;
|
||||
|
||||
export type ChatCompletionRole = 'system' | 'user' | 'assistant';
|
||||
|
||||
@@ -14,31 +27,106 @@ export type ChatCompletionMessage = {
|
||||
export const isOpenAIConfigured = Boolean(openAiApiKey);
|
||||
|
||||
const defaultSystemPrompt =
|
||||
import.meta.env.VITE_OPENAI_SYSTEM_PROMPT ||
|
||||
'You are an AI assistant that responds in Persian (Farsi) with concise, helpful answers for users of a progressive web app.';
|
||||
(import.meta.env.VITE_OPENAI_SYSTEM_PROMPT || '').trim() ||
|
||||
'You are Novya (نویا), a Persian-speaking educational psychology coach. Reply in concise, helpful Farsi.';
|
||||
|
||||
export const systemMessage: ChatCompletionMessage = {
|
||||
role: 'system',
|
||||
content: defaultSystemPrompt,
|
||||
};
|
||||
|
||||
export async function generateAIResponse(messages: ChatCompletionMessage[]): Promise<string> {
|
||||
if (!isOpenAIConfigured) {
|
||||
throw new Error('کلید OpenAI تعریف نشده است. مقدار VITE_OPENAI_API_KEY را تنظیم کنید.');
|
||||
const isOpenRouter = /openrouter\.ai/i.test(openAiBaseUrl);
|
||||
const isGapGpt = /gapgpt\.app/i.test(openAiBaseUrl);
|
||||
|
||||
const OPENROUTER_BLOCKED_FA =
|
||||
'ارتباط با OpenRouter برقرار نشد. VPN را روشن نگه دارید، صفحه را رفرش کنید و دوباره پیام بفرستید.';
|
||||
|
||||
const asTrimmedString = (value: unknown): string | undefined => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const joinContentParts = (content: unknown): string | undefined => {
|
||||
if (typeof content === 'string') return asTrimmedString(content);
|
||||
if (!Array.isArray(content)) return undefined;
|
||||
|
||||
const joined = content
|
||||
.map((part) => {
|
||||
if (typeof part === 'string') return part;
|
||||
if (part && typeof part === 'object') {
|
||||
const record = part as Record<string, unknown>;
|
||||
if (typeof record.text === 'string') return record.text;
|
||||
if (typeof record.content === 'string') return record.content;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('')
|
||||
.trim();
|
||||
|
||||
return joined || undefined;
|
||||
};
|
||||
|
||||
const extractAssistantText = (data: unknown): string | undefined => {
|
||||
if (!data || typeof data !== 'object') return undefined;
|
||||
const root = data as Record<string, unknown>;
|
||||
const choices = root.choices;
|
||||
if (!Array.isArray(choices) || choices.length === 0) return undefined;
|
||||
|
||||
const first = choices[0] as Record<string, unknown>;
|
||||
const message = (first?.message ?? first?.delta) as Record<string, unknown> | undefined;
|
||||
if (!message) return undefined;
|
||||
|
||||
const fromContent = joinContentParts(message.content);
|
||||
if (fromContent) return fromContent;
|
||||
|
||||
const fromOutputText = asTrimmedString(message.output_text);
|
||||
if (fromOutputText) return fromOutputText;
|
||||
|
||||
const fromAnswer = asTrimmedString(message.answer);
|
||||
if (fromAnswer) return fromAnswer;
|
||||
|
||||
const fromReasoning = asTrimmedString(message.reasoning);
|
||||
if (fromReasoning) return fromReasoning;
|
||||
|
||||
const details = message.reasoning_details;
|
||||
if (Array.isArray(details)) {
|
||||
const joined = details
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') return item;
|
||||
if (item && typeof item === 'object') {
|
||||
const record = item as Record<string, unknown>;
|
||||
return String(record.text ?? record.content ?? '');
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('\n')
|
||||
.trim();
|
||||
if (joined) return joined;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
model: openAiModel,
|
||||
messages: messages.length ? messages : [systemMessage],
|
||||
temperature: 0.3,
|
||||
max_tokens: 800,
|
||||
};
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('[AI] Unrecognized message shape:', {
|
||||
finishReason: first.finish_reason,
|
||||
messageKeys: Object.keys(message),
|
||||
contentType: typeof message.content,
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const buildHeaders = (viaProxy: boolean): Record<string, string> => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${openAiApiKey}`,
|
||||
};
|
||||
|
||||
if (isOpenRouter && viaProxy) {
|
||||
headers['HTTP-Referer'] = 'https://novya.app';
|
||||
headers['X-Title'] = 'Novya';
|
||||
}
|
||||
|
||||
if (openAiOrg) {
|
||||
headers['OpenAI-Organization'] = openAiOrg;
|
||||
}
|
||||
@@ -47,24 +135,165 @@ export async function generateAIResponse(messages: ChatCompletionMessage[]): Pro
|
||||
headers['OpenAI-Project'] = openAiProject;
|
||||
}
|
||||
|
||||
const response = await fetch(`${openAiBaseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return headers;
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(`خطا در دریافت پاسخ از GPT: ${errorBody}`);
|
||||
/**
|
||||
* DEV endpoints:
|
||||
* GapGPT — same-origin proxy first (avoids browser CORS surprises).
|
||||
* OpenRouter — browser first (Cloudflare often blocks Node's TLS fingerprint).
|
||||
*/
|
||||
const getCompletionEndpoints = (): { url: string; viaProxy: boolean }[] => {
|
||||
const direct = { url: `${openAiBaseUrl}/chat/completions`, viaProxy: false };
|
||||
const proxy = { url: '/api/ai/chat/completions', viaProxy: true };
|
||||
if (!import.meta.env.DEV) {
|
||||
return [direct];
|
||||
}
|
||||
if (isGapGpt) {
|
||||
return [proxy, direct];
|
||||
}
|
||||
return [direct, proxy];
|
||||
};
|
||||
|
||||
const isCloudflareSecurityBlock = (status: number, body: string): boolean =>
|
||||
status === 403 && /access denied by security policy/i.test(body);
|
||||
|
||||
const isRetryableFailure = (error: unknown, status?: number): boolean => {
|
||||
if (status === 502 || status === 503 || status === 504) return true;
|
||||
if (isAbortError(error)) return false;
|
||||
const message = error instanceof Error ? error.message : String(error ?? '');
|
||||
return /failed to fetch|networkerror|load failed|socket hang up|ECONNRESET|proxy/i.test(message);
|
||||
};
|
||||
|
||||
const toUserError = (error: unknown): Error => {
|
||||
const msg = error instanceof Error ? error.message : String(error ?? '');
|
||||
if (
|
||||
msg.includes('ارتباط با OpenRouter') ||
|
||||
msg.includes('ارتباط با GapGPT') ||
|
||||
msg.includes('کلید API') ||
|
||||
msg.includes('مدل')
|
||||
) {
|
||||
return error instanceof Error ? error : new Error(msg);
|
||||
}
|
||||
if (isOpenRouter && /failed to fetch|networkerror|load failed|403|security policy/i.test(msg)) {
|
||||
return new Error(OPENROUTER_BLOCKED_FA);
|
||||
}
|
||||
if (isGapGpt && /failed to fetch|networkerror|load failed|fetch failed/i.test(msg)) {
|
||||
return new Error('ارتباط با GapGPT برقرار نشد. صفحه را یکبار کامل رفرش کنید و دوباره پیام بفرستید.');
|
||||
}
|
||||
return new Error(getFetchErrorMessage(error, 'خطا در ارتباط با سرویس هوش مصنوعی.'));
|
||||
};
|
||||
|
||||
export async function generateAIResponse(messages: ChatCompletionMessage[]): Promise<string> {
|
||||
if (!isOpenAIConfigured) {
|
||||
throw new Error(
|
||||
'کلید هوش مصنوعی تعریف نشده است. مقدار VITE_OPENAI_API_KEY را در فایل .env تنظیم کنید و سرور را ریاستارت کنید.'
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const text: string | undefined = data?.choices?.[0]?.message?.content?.trim();
|
||||
const payload: Record<string, unknown> = {
|
||||
model: openAiModel,
|
||||
messages: messages.length ? messages : [systemMessage],
|
||||
temperature: 0.3,
|
||||
max_tokens: 2000,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
if (!text) {
|
||||
throw new Error('پاسخی از هوش مصنوعی دریافت نشد.');
|
||||
const endpoints = getCompletionEndpoints();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < endpoints.length; i += 1) {
|
||||
const { url: endpoint, viaProxy } = endpoints[i];
|
||||
const isLast = i === endpoints.length - 1;
|
||||
const headers = buildHeaders(viaProxy);
|
||||
|
||||
try {
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug('[AI] Request →', endpoint, {
|
||||
model: openAiModel,
|
||||
provider: isOpenRouter ? 'openrouter' : isGapGpt ? 'gapgpt' : 'openai-compatible',
|
||||
viaProxy,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
if (!isLast && isRetryableFailure(null, response.status)) {
|
||||
lastError = new Error(
|
||||
`خطا در دریافت پاسخ از هوش مصنوعی (${response.status}): ${errorBody.slice(0, 200)}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (isCloudflareSecurityBlock(response.status, errorBody) || (isOpenRouter && response.status === 403)) {
|
||||
throw new Error(OPENROUTER_BLOCKED_FA);
|
||||
}
|
||||
if (response.status === 400) {
|
||||
throw new Error(
|
||||
`درخواست نامعتبر است (400). مدل «${openAiModel}» را در پنل GapGPT/OpenRouter بررسی کنید. ${errorBody.slice(0, 180)}`
|
||||
);
|
||||
}
|
||||
if (response.status === 401) {
|
||||
throw new Error(
|
||||
'کلید API نامعتبر است (401). مقدار VITE_OPENAI_API_KEY را در .env با کلید همان سرویس (OpenRouter یا GapGPT) همخوان کنید.'
|
||||
);
|
||||
}
|
||||
if (response.status === 403) {
|
||||
throw new Error(
|
||||
`دسترسی به سرویس هوش مصنوعی رد شد (403). کلید، مدل، و محدودیت حساب را بررسی کنید.`
|
||||
);
|
||||
}
|
||||
if (response.status === 502 || response.status === 503 || response.status === 504) {
|
||||
throw new Error(
|
||||
`سرویس هوش مصنوعی موقتاً در دسترس نیست (${response.status}). چند لحظه بعد دوباره تلاش کنید.`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`خطا در دریافت پاسخ از هوش مصنوعی (${response.status}): ${errorBody.slice(0, 280)}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const text = extractAssistantText(data);
|
||||
|
||||
if (!text) {
|
||||
throw new Error(
|
||||
'پاسخی از هوش مصنوعی دریافت نشد. مدل ممکن است فقط thinking برگرداند؛ مدل را در .env بررسی کنید.'
|
||||
);
|
||||
}
|
||||
|
||||
return text;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (isAbortError(error)) {
|
||||
throw new Error('پاسخ هوش مصنوعی بیش از حد طول کشید. لطفاً دوباره تلاش کنید.');
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : '';
|
||||
if (msg.includes('پاسخی از هوش مصنوعی دریافت نشد') || msg.includes('کلید API') || msg.includes('درخواست نامعتبر')) {
|
||||
throw error instanceof Error ? error : new Error(msg);
|
||||
}
|
||||
if (!isLast && isRetryableFailure(error)) {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn('[AI] Endpoint failed, trying fallback…', endpoint, error);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw toUserError(error);
|
||||
}
|
||||
}
|
||||
|
||||
throw toUserError(lastError);
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
import {
|
||||
PROGRAM_SESSION_COUNT,
|
||||
getSessionByOrder,
|
||||
getLastSession,
|
||||
type ProgramSession,
|
||||
} from '@/data/program/sessions';
|
||||
import { api, hasSessionToken } from '@/services/backend';
|
||||
import {
|
||||
getNextLockedSessionUnlockTime,
|
||||
isSessionUnlockedForProgress,
|
||||
type SessionCompletionTimes,
|
||||
} from '@/lib/sessionUnlock';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProgramProgress {
|
||||
userId: string;
|
||||
currentSessionOrder: number; // 0 = not started; 1-based order
|
||||
completedSessions: number[]; // sorted array of completed session orders
|
||||
sessionCompletedAt: SessionCompletionTimes;
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
nextSessionUnlocksAt: string | null;
|
||||
waitingForSessionOrder: number | null;
|
||||
}
|
||||
|
||||
export interface SessionCompletionSignal {
|
||||
sessionComplete: boolean;
|
||||
sessionId: string;
|
||||
sessionOrder: number;
|
||||
reason: string;
|
||||
coveredTopics: string[];
|
||||
completedExercises: string[];
|
||||
completedReflections?: string[];
|
||||
userConfirmed: boolean;
|
||||
readyForNextSession: boolean;
|
||||
chatSessionId?: string;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Storage
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'program-progress';
|
||||
const SESSIONS_CACHE_KEY_PREFIX = 'program-sessions';
|
||||
|
||||
const getKey = (userId: string) => `${STORAGE_KEY_PREFIX}:${userId}`;
|
||||
const getSessionsKey = (userId: string) => `${SESSIONS_CACHE_KEY_PREFIX}:${userId}`;
|
||||
|
||||
const getProgress = (userId: string): ProgramProgress | null => {
|
||||
try {
|
||||
const raw = localStorage.getItem(getKey(userId));
|
||||
return raw ? (JSON.parse(raw) as ProgramProgress) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const saveProgress = (userId: string, progress: ProgramProgress) => {
|
||||
localStorage.setItem(getKey(userId), JSON.stringify(progress));
|
||||
};
|
||||
|
||||
const saveSessions = (userId: string, sessions: ProgramSession[]) => {
|
||||
localStorage.setItem(getSessionsKey(userId), JSON.stringify(sessions));
|
||||
};
|
||||
|
||||
const getSessions = (userId: string): ProgramSession[] => {
|
||||
try {
|
||||
const raw = localStorage.getItem(getSessionsKey(userId));
|
||||
return raw ? (JSON.parse(raw) as ProgramSession[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Derived State
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const defaultProgress = (userId: string): ProgramProgress => ({
|
||||
userId,
|
||||
currentSessionOrder: 0,
|
||||
completedSessions: [],
|
||||
sessionCompletedAt: {},
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
nextSessionUnlocksAt: null,
|
||||
waitingForSessionOrder: null,
|
||||
});
|
||||
|
||||
type BackendSessionProgress = {
|
||||
currentSessionOrder: number;
|
||||
completedSessions: number[];
|
||||
startedAt: string | null;
|
||||
completedAt: string | null;
|
||||
nextSessionUnlocksAt?: string | null;
|
||||
waitingForSessionOrder?: number | null;
|
||||
sessions?: {
|
||||
sessionOrder: number;
|
||||
completedAt: string | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
type BackendSession = ProgramSession & {
|
||||
unlocked?: boolean;
|
||||
completed?: boolean;
|
||||
unlocksAt?: string | null;
|
||||
};
|
||||
|
||||
const buildSessionCompletedAt = (
|
||||
completedSessions: number[],
|
||||
sessions?: BackendSessionProgress['sessions'],
|
||||
existing: SessionCompletionTimes = {},
|
||||
): SessionCompletionTimes => {
|
||||
const next: SessionCompletionTimes = { ...existing };
|
||||
for (const session of sessions ?? []) {
|
||||
if (session.completedAt) {
|
||||
next[session.sessionOrder] = session.completedAt;
|
||||
}
|
||||
}
|
||||
for (const order of completedSessions) {
|
||||
if (!next[order]) {
|
||||
next[order] = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const toProgramProgress = (userId: string, data: BackendSessionProgress): ProgramProgress => {
|
||||
const sessionCompletedAt = buildSessionCompletedAt(data.completedSessions, data.sessions);
|
||||
const waiting = getNextLockedSessionUnlockTime(data.completedSessions, sessionCompletedAt);
|
||||
|
||||
return {
|
||||
userId,
|
||||
currentSessionOrder: data.currentSessionOrder,
|
||||
completedSessions: data.completedSessions,
|
||||
sessionCompletedAt,
|
||||
startedAt: data.startedAt,
|
||||
completedAt: data.completedAt,
|
||||
nextSessionUnlocksAt: data.nextSessionUnlocksAt ?? waiting?.unlocksAt.toISOString() ?? null,
|
||||
waitingForSessionOrder: data.waitingForSessionOrder ?? waiting?.sessionOrder ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const isOfflineGuest = (userId: string): boolean => userId.startsWith('guest-');
|
||||
const shouldUseBackend = (userId: string): boolean => hasSessionToken() && !isOfflineGuest(userId);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the program progress for a user.
|
||||
* Returns a default progress if none exists.
|
||||
*/
|
||||
export function getProgramProgress(userId: string): ProgramProgress {
|
||||
const raw = getProgress(userId);
|
||||
if (!raw) {
|
||||
return defaultProgress(userId);
|
||||
}
|
||||
|
||||
const merged: ProgramProgress = {
|
||||
...defaultProgress(userId),
|
||||
...raw,
|
||||
sessionCompletedAt: raw.sessionCompletedAt ?? {},
|
||||
nextSessionUnlocksAt: raw.nextSessionUnlocksAt ?? null,
|
||||
waitingForSessionOrder: raw.waitingForSessionOrder ?? null,
|
||||
};
|
||||
|
||||
const waiting = getNextLockedSessionUnlockTime(merged.completedSessions, merged.sessionCompletedAt);
|
||||
return {
|
||||
...merged,
|
||||
nextSessionUnlocksAt: merged.nextSessionUnlocksAt ?? waiting?.unlocksAt.toISOString() ?? null,
|
||||
waitingForSessionOrder: merged.waitingForSessionOrder ?? waiting?.sessionOrder ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncProgramProgress(userId: string): Promise<ProgramProgress> {
|
||||
if (!shouldUseBackend(userId)) {
|
||||
return getProgramProgress(userId);
|
||||
}
|
||||
|
||||
const data = await api.get<BackendSessionProgress>('/sessions/progress');
|
||||
const mapped = toProgramProgress(userId, data);
|
||||
saveProgress(userId, mapped);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export async function syncProgramSessions(userId: string): Promise<ProgramSession[]> {
|
||||
if (!shouldUseBackend(userId)) {
|
||||
return getSessions(userId);
|
||||
}
|
||||
|
||||
const sessions = await api.get<BackendSession[]>('/sessions');
|
||||
saveSessions(userId, sessions);
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a session as completed.
|
||||
* Updates currentSessionOrder to the next incomplete session.
|
||||
* If all sessions are completed, marks the program as completed.
|
||||
*/
|
||||
export function completeSession(userId: string, sessionOrder: number): ProgramProgress {
|
||||
if (shouldUseBackend(userId)) {
|
||||
return getProgramProgress(userId);
|
||||
}
|
||||
const current = getProgramProgress(userId);
|
||||
|
||||
if (current.completedAt !== null) {
|
||||
// Program already completed — return as-is
|
||||
return current;
|
||||
}
|
||||
|
||||
const alreadyCompleted = current.completedSessions.includes(sessionOrder);
|
||||
const completedSessions = alreadyCompleted
|
||||
? current.completedSessions
|
||||
: [...current.completedSessions, sessionOrder].sort((a, b) => a - b);
|
||||
|
||||
const startedAt = current.startedAt ?? new Date().toISOString();
|
||||
|
||||
const completedAtIso = new Date().toISOString();
|
||||
const sessionCompletedAt = {
|
||||
...current.sessionCompletedAt,
|
||||
...(alreadyCompleted ? {} : { [sessionOrder]: completedAtIso }),
|
||||
};
|
||||
|
||||
// Determine next available session (sequential unlock)
|
||||
const nextOrder = sessionOrder + 1;
|
||||
const allDone = completedSessions.length >= PROGRAM_SESSION_COUNT;
|
||||
const nextSessionOrder = allDone ? sessionOrder : nextOrder;
|
||||
|
||||
const completedAt = allDone ? completedAtIso : null;
|
||||
const waiting = getNextLockedSessionUnlockTime(completedSessions, sessionCompletedAt);
|
||||
|
||||
const updated: ProgramProgress = {
|
||||
userId,
|
||||
currentSessionOrder: nextSessionOrder,
|
||||
completedSessions,
|
||||
sessionCompletedAt,
|
||||
startedAt,
|
||||
completedAt,
|
||||
nextSessionUnlocksAt: waiting?.unlocksAt.toISOString() ?? null,
|
||||
waitingForSessionOrder: waiting?.sessionOrder ?? null,
|
||||
};
|
||||
|
||||
saveProgress(userId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function completeSessionWithBackend(
|
||||
userId: string,
|
||||
signal: SessionCompletionSignal,
|
||||
): Promise<ProgramProgress> {
|
||||
if (!shouldUseBackend(userId)) {
|
||||
return completeSession(userId, signal.sessionOrder);
|
||||
}
|
||||
|
||||
const data = await api.post<BackendSessionProgress>(`/sessions/${signal.sessionOrder}/complete`, signal);
|
||||
const mapped = toProgramProgress(userId, data);
|
||||
saveProgress(userId, mapped);
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific session is unlocked for a user.
|
||||
* Session N unlocks when sessions 1…N-1 are complete and at least 1 hour has passed
|
||||
* since session N-1 was completed.
|
||||
*/
|
||||
export function isSessionUnlocked(userId: string, sessionOrder: number): boolean {
|
||||
const cachedSessions = getSessions(userId);
|
||||
if (cachedSessions.length > 0) {
|
||||
const session = cachedSessions.find((item) => item.order === sessionOrder);
|
||||
if (session && typeof (session as BackendSession).unlocked === 'boolean') {
|
||||
return Boolean((session as BackendSession).unlocked);
|
||||
}
|
||||
}
|
||||
|
||||
const progress = getProgramProgress(userId);
|
||||
|
||||
if (progress.completedAt !== null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isSessionUnlockedForProgress(
|
||||
sessionOrder,
|
||||
progress.completedSessions,
|
||||
true,
|
||||
false,
|
||||
progress.sessionCompletedAt ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
export function isWaitingForNextSession(userId: string): boolean {
|
||||
const progress = getProgramProgress(userId);
|
||||
if (!progress.nextSessionUnlocksAt || !progress.waitingForSessionOrder) {
|
||||
return false;
|
||||
}
|
||||
return new Date(progress.nextSessionUnlocksAt).getTime() > Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session is completed.
|
||||
*/
|
||||
export function isSessionCompleted(userId: string, sessionOrder: number): boolean {
|
||||
return getProgramProgress(userId).completedSessions.includes(sessionOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the entire program is completed.
|
||||
*/
|
||||
export function isProgramCompleted(userId: string): boolean {
|
||||
return getProgramProgress(userId).completedAt !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the program has been started (at least one session completed).
|
||||
*/
|
||||
export function isProgramStarted(userId: string): boolean {
|
||||
const progress = getProgramProgress(userId);
|
||||
return progress.completedSessions.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current session order (next session to complete).
|
||||
* Returns 0 if the program has not started.
|
||||
*/
|
||||
export function getCurrentSessionOrder(userId: string): number {
|
||||
return getProgramProgress(userId).currentSessionOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next available session for a user.
|
||||
* Returns null if the program is completed.
|
||||
*/
|
||||
export function getNextSession(userId: string): { id: string; order: number } | null {
|
||||
const cachedSessions = getSessions(userId);
|
||||
if (cachedSessions.length > 0) {
|
||||
const session = cachedSessions.find((item) => (item as BackendSession).unlocked && !(item as BackendSession).completed);
|
||||
if (session) {
|
||||
return { id: session.id, order: session.order };
|
||||
}
|
||||
}
|
||||
|
||||
const progress = getProgramProgress(userId);
|
||||
|
||||
if (progress.completedAt !== null) {
|
||||
return null; // All done
|
||||
}
|
||||
|
||||
if (progress.completedSessions.length === 0) {
|
||||
// Not started
|
||||
return { id: 'session-01', order: 1 };
|
||||
}
|
||||
|
||||
const nextOrder = progress.currentSessionOrder + 1;
|
||||
const lastSession = getLastSession();
|
||||
|
||||
if (nextOrder > (lastSession?.order ?? PROGRAM_SESSION_COUNT)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = getSessionByOrder(nextOrder);
|
||||
return session ? { id: session.id, order: session.order } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all program progress for a user.
|
||||
*/
|
||||
export function resetProgramProgress(userId: string): void {
|
||||
localStorage.removeItem(getKey(userId));
|
||||
localStorage.removeItem(getSessionsKey(userId));
|
||||
}
|
||||
+49
-43
@@ -1,13 +1,34 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || '';
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || '';
|
||||
const supabaseUrl = (import.meta.env.VITE_SUPABASE_URL || '').trim();
|
||||
const supabaseAnonKey = (import.meta.env.VITE_SUPABASE_ANON_KEY || '').trim();
|
||||
|
||||
// Check if Supabase is configured
|
||||
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey);
|
||||
export const supabaseHost = (() => {
|
||||
try {
|
||||
return new URL(supabaseUrl).host;
|
||||
} catch {
|
||||
return 'سرور';
|
||||
}
|
||||
})();
|
||||
|
||||
const isValidUrl = (url: string): boolean => {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.protocol === 'https:' || urlObj.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey && isValidUrl(supabaseUrl));
|
||||
|
||||
if (supabaseUrl && !isValidUrl(supabaseUrl)) {
|
||||
console.error('❌ فرمت URL Supabase نامعتبر است:', supabaseUrl);
|
||||
console.error('💡 URL باید با https:// یا http:// شروع شود');
|
||||
}
|
||||
|
||||
// Create a mock Supabase client for offline mode
|
||||
const createMockClient = (): SupabaseClient => {
|
||||
const mockClient = {
|
||||
auth: {
|
||||
@@ -39,7 +60,6 @@ const createMockClient = (): SupabaseClient => {
|
||||
return mockClient;
|
||||
};
|
||||
|
||||
// Create real or mock client based on configuration
|
||||
export const supabase = isSupabaseConfigured
|
||||
? createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: {
|
||||
@@ -49,7 +69,7 @@ export const supabase = isSupabaseConfigured
|
||||
},
|
||||
global: {
|
||||
headers: {
|
||||
'apikey': supabaseAnonKey
|
||||
apikey: supabaseAnonKey
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -57,47 +77,34 @@ export const supabase = isSupabaseConfigured
|
||||
|
||||
if (!isSupabaseConfigured) {
|
||||
console.warn('⚠️ Supabase environment variables are not set. Running in offline/mock mode.');
|
||||
} else {
|
||||
// Test connection to Supabase (runs once on module load, but doesn't block)
|
||||
// Use setTimeout to avoid blocking module initialization
|
||||
setTimeout(() => {
|
||||
testSupabaseConnection();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Test Supabase connection without needing tables
|
||||
async function testSupabaseConnection() {
|
||||
if (!isSupabaseConfigured) return;
|
||||
|
||||
/** آیا REST API سرور Supabase پاسخ میدهد؟ (401/404 = سالم؛ 5xx = قطع) */
|
||||
export async function checkSupabaseReachable(): Promise<boolean> {
|
||||
if (!isSupabaseConfigured) return false;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 8000);
|
||||
|
||||
try {
|
||||
// Simple health check: try to get current session
|
||||
// This doesn't require any tables, just checks if we can communicate with Supabase
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
console.error('❌ خطا در اتصال به Supabase:', error.message);
|
||||
console.log('📋 اطلاعات اتصال:');
|
||||
console.log(' URL:', supabaseUrl);
|
||||
console.log(' Key:', supabaseAnonKey ? `${supabaseAnonKey.substring(0, 20)}...` : 'not set');
|
||||
console.log('💡 نکته: اگر URL شما custom domain است، مطمئن شوید که به درستی پیکربندی شده است.');
|
||||
} else {
|
||||
console.log('✅ اتصال به Supabase با موفقیت برقرار شد!');
|
||||
console.log('📋 اطلاعات اتصال:');
|
||||
console.log(' URL:', supabaseUrl);
|
||||
console.log(' Session:', data.session ? 'فعال' : 'غیرفعال (کاربر لاگین نشده)');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
console.error('❌ خطا در تست اتصال Supabase:', errorMessage);
|
||||
console.log('📋 لطفاً بررسی کنید:');
|
||||
console.log(' 1. فایل .env درست تنظیم شده است؟');
|
||||
console.log(' 2. URL و Key صحیح هستند؟');
|
||||
console.log(' 3. اینترنت متصل است؟');
|
||||
console.log(' 4. اگر از custom domain استفاده میکنید، مطمئن شوید که CORS و تنظیمات درست است.');
|
||||
const response = await fetch(`${supabaseUrl}/rest/v1/`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
apikey: supabaseAnonKey,
|
||||
Accept: 'application/json'
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.status >= 500) return false;
|
||||
return response.ok || response.status === 401 || response.status === 404;
|
||||
} catch {
|
||||
clearTimeout(timeoutId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Database types (extend as needed)
|
||||
export type Profile = {
|
||||
id: string;
|
||||
email?: string;
|
||||
@@ -129,4 +136,3 @@ export type Message = {
|
||||
avatar_url?: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user