Livrare Lot 3 (Frontend): aplicație web, aplicație mobilă Android, extensie browser

- Surse complete web (React/Vite) + mobil (React Native/Expo) + extensie (MV3)
- Documentație de livrare: ghid utilizare, matrice trasabilitate cerințe, raport testare furnizor
- Artefacte binare: imagine Docker didi-frontend:lot3-1.0, APK, extensie v3.2.6 + SHA256SUMS
- Configurare adresă platformă externalizată (build args / .env / config.js)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Top Clossers 2026-07-17 12:33:59 +03:00
commit cec967f953
321 changed files with 80506 additions and 0 deletions

View file

@ -0,0 +1,136 @@
import { skillsApi } from './axios';
import { TIMEOUTS } from './config';
import type {
UploadResponse,
AnalysisSession,
V3Response,
V3QueueStatus,
V3AsyncSubmitData,
} from '../types/analysis';
export async function uploadMedia(
fileUri: string,
fileName: string,
fileType: string,
userId: string
): Promise<string> {
const formData = new FormData();
formData.append('file', {
uri: fileUri,
name: fileName,
type: fileType,
} as any);
formData.append('user_id', userId);
const response = await skillsApi.post<V3Response<UploadResponse['data']>>('media/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: TIMEOUTS.UPLOAD,
});
const data = response.data?.data;
return data?.public_url || '';
}
const ALLOWED_SKILLS = ['techniques', 'claims', 'ai-tampered', 'domain', 'context', 'source-assessment'];
export async function analyzeSkill(
skill: string,
body: { text?: string; media_url?: string; user_id?: string; [key: string]: any }
): Promise<AnalysisSession> {
if (!ALLOWED_SKILLS.includes(skill)) {
throw new Error(`Invalid skill: ${skill}`);
}
const endpoint = body.media_url
? `${skill}/analyze-media`
: `${skill}/analyze`;
const response = await skillsApi.post<V3Response>(
endpoint,
body,
{ timeout: TIMEOUTS.SKILL }
);
return response.data?.data || response.data as any;
}
export async function analyzePipeline(
body: { text?: string; media_url?: string; media_type?: string; user_id?: string; plan_type?: number; [key: string]: any }
): Promise<V3Response> {
const endpoint = body.media_url
? 'pipeline/analyze-media'
: 'pipeline/analyze';
const response = await skillsApi.post<V3Response>(
endpoint,
body,
{ timeout: TIMEOUTS.SKILL }
);
return response.data;
}
export async function submitPipelineAsync(
body: Record<string, any>
): Promise<{ session_id: string; async: boolean; data: V3AsyncSubmitData | AnalysisSession }> {
const response = await skillsApi.post<V3Response<V3AsyncSubmitData>>(
'pipeline/analyze-async',
body,
{ timeout: TIMEOUTS.SKILL }
);
const res = response.data;
// Async path: 202 — session_id is inside data
if (res.async && res.data?.session_id) {
return { session_id: res.data.session_id, async: true, data: res.data };
}
// Sync fallback: RabbitMQ down — full result returned directly
return { session_id: (res.data as any)?.session_id || '', async: false, data: res.data };
}
export async function submitSkillAsync(
skill: string,
body: Record<string, any>
): Promise<{ session_id: string; async: boolean; data: V3AsyncSubmitData | AnalysisSession }> {
if (!ALLOWED_SKILLS.includes(skill)) {
throw new Error(`Invalid skill: ${skill}`);
}
const response = await skillsApi.post<V3Response<V3AsyncSubmitData>>(
`${skill}/analyze-async`,
body,
{ timeout: TIMEOUTS.SKILL }
);
const res = response.data;
// Async path: session_id is inside data
if (res.async && res.data?.session_id) {
return { session_id: res.data.session_id, async: true, data: res.data };
}
// Sync fallback: full result returned directly
return { session_id: (res.data as any)?.session_id || '', async: false, data: res.data };
}
export async function pollQueueStatus(sessionId: string, signal?: AbortSignal): Promise<V3QueueStatus> {
const response = await skillsApi.get<V3Response<AnalysisSession>>(
`pipeline/${encodeURIComponent(sessionId)}/queue-status`,
{ signal }
);
const session = response.data?.data;
const queue = session?._queue;
return {
session,
progress: queue?.progress || 0,
total_components: queue?.total_components || 5,
completed_components: queue?.completed_components || [],
status: session?.status || 'pending',
};
}
export async function getPipelineResult(sessionId: string, signal?: AbortSignal): Promise<AnalysisSession> {
const response = await skillsApi.get<V3Response>(
`pipeline/${encodeURIComponent(sessionId)}/result`,
{ signal }
);
return response.data?.data || response.data as any;
}

View file

@ -0,0 +1,41 @@
import axios from 'axios';
import { AUTH_CONFIG, getRedirectUri } from './config';
import { mainApi } from './axios';
import { TokenResponse, UserProfile } from '../types/auth';
export async function exchangeCodeForTokens(
code: string,
codeVerifier: string
): Promise<TokenResponse> {
const params = new URLSearchParams({
grant_type: 'authorization_code',
client_id: AUTH_CONFIG.CLIENT_ID,
code,
redirect_uri: getRedirectUri(),
code_verifier: codeVerifier,
});
const response = await axios.post(AUTH_CONFIG.TOKEN_URL, params.toString(), {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 30000,
});
return response.data;
}
export async function fetchProfile(): Promise<UserProfile> {
const response = await mainApi.get('auth/me');
// API returns { success: true, data: { ... } }
return response.data?.data || response.data;
}
export async function fetchSubscriptionUsage() {
const response = await mainApi.get('subscriptions/usage');
// API returns { success: true, data: { credits, plan, subscription } }
return response.data?.data || response.data;
}
export async function fetchCredits(): Promise<{ credits_remained: number; credits_spent: number }> {
const response = await mainApi.get('auth/credits');
return response.data?.data || response.data;
}

View file

@ -0,0 +1,62 @@
import axios from 'axios';
import { Platform } from 'react-native';
import { API_CONFIG } from './config';
import { useAuthStore } from '../store/authStore';
const USER_AGENT =
'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 DidiApp/1.0';
// Browser forbids setting User-Agent — only set on native
const nativeHeaders = Platform.OS !== 'web' ? { 'User-Agent': USER_AGENT } : {};
if (Platform.OS !== 'web') {
axios.defaults.headers.common['User-Agent'] = USER_AGENT;
}
export const skillsApi = axios.create({
baseURL: API_CONFIG.SKILLS_API,
timeout: 300000,
headers: nativeHeaders,
});
export const mainApi = axios.create({
baseURL: API_CONFIG.MAIN_API,
timeout: 30000,
headers: nativeHeaders,
});
// Request interceptor - attach token & auto-refresh
const attachToken = async (config: any) => {
const { accessToken, expiresAt, refreshAccessToken } = useAuthStore.getState();
if (expiresAt && Date.now() > expiresAt - 30000) {
const newToken = await refreshAccessToken();
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`;
}
} else if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
};
// Response interceptor - retry on 401 (max once to prevent infinite loops)
const handle401 = async (error: any) => {
const config = error.config;
if (error.response?.status === 401 && !config?._retried) {
config._retried = true;
const newToken = await useAuthStore.getState().refreshAccessToken();
if (newToken) {
config.headers.Authorization = `Bearer ${newToken}`;
return axios.request(config);
}
}
return Promise.reject(error);
};
skillsApi.interceptors.request.use(attachToken);
skillsApi.interceptors.response.use((r) => r, handle401);
mainApi.interceptors.request.use(attachToken);
mainApi.interceptors.response.use((r) => r, handle401);

View file

@ -0,0 +1,58 @@
// Adresa platformei DiDi se setează EXCLUSIV la build, prin variabila de mediu
// EXPO_PUBLIC_DIDI_BASE_URL (fișierul .env din rădăcina proiectului — vezi .env.example).
// Toate endpoint-urile (auth prin prefixul /auth al Keycloak, API-uri prin gateway) derivă
// din această singură valoare; nu hardcoda adrese în cod.
const DIDI_BASE_URL = (process.env.EXPO_PUBLIC_DIDI_BASE_URL ?? '').replace(/\/+$/, '');
if (!DIDI_BASE_URL) {
console.error('[CONFIG] EXPO_PUBLIC_DIDI_BASE_URL nu este setat — vezi .env.example');
}
const AUTH_BASE_URL = `${DIDI_BASE_URL}/auth`;
const KEYCLOAK_REALM = 'didi-clients';
const REALM_BASE = `${AUTH_BASE_URL}/realms/${KEYCLOAK_REALM}`;
const OIDC_BASE = `${REALM_BASE}/protocol/openid-connect`;
export const AUTH_CONFIG = {
AUTH_BASE_URL,
KEYCLOAK_REALM,
CLIENT_ID: 'didi-mobile-app',
REDIRECT_URI: 'didi://callback',
REDIRECT_URI_WEB: typeof window !== 'undefined' && typeof window.location !== 'undefined'
? `${window.location.origin}/auth/callback`
: 'http://localhost:8081/auth/callback',
SCOPES: 'openid profile email roles',
AUTH_URL: `${OIDC_BASE}/auth`,
TOKEN_URL: `${OIDC_BASE}/token`,
REGISTER_URL: `${OIDC_BASE}/registrations`,
USERINFO_URL: `${OIDC_BASE}/userinfo`,
LOGOUT_URL: `${OIDC_BASE}/logout`,
PASSWORD_URL: `${REALM_BASE}/account/credentials/password`,
};
import { Platform } from 'react-native';
export function getRedirectUri(): string {
return Platform.OS === 'web' ? AUTH_CONFIG.REDIRECT_URI_WEB : AUTH_CONFIG.REDIRECT_URI;
}
export const API_CONFIG = {
MAIN_API: `${DIDI_BASE_URL}/api/`,
MAIN_API_V1: `${DIDI_BASE_URL}/api/v1/`,
SKILLS_API: `${DIDI_BASE_URL}/agent-v3/api/v3/`,
SCRAPER_API: `${DIDI_BASE_URL}/scraper/`,
};
export const TIMEOUTS = {
AUTH: 30000,
PIPELINE_TEXT: 120000,
PIPELINE_URL: 120000,
PIPELINE_MEDIA: 300000,
SKILL: 300000,
UPLOAD: 60000,
SCRAPER: 120000,
};
export const POLL_CONFIG = {
INTERVAL: 2500,
MAX_DURATION: 600000,
};

View file

@ -0,0 +1,42 @@
import { skillsApi } from './axios';
import type {
AnalysisSession,
V3HistoryResponse,
V3Response,
} from '../types/analysis';
export async function getHistory(
userId: string,
page: number = 1,
limit: number = 20
): Promise<{ items: AnalysisSession[]; pagination: V3HistoryResponse['data']['pagination'] }> {
const params = new URLSearchParams({
user_id: userId,
page: String(page),
limit: String(limit),
});
const response = await skillsApi.get<V3HistoryResponse>(
`pipeline/history?${params.toString()}`
);
const data = response.data?.data || response.data;
return {
items: Array.isArray((data as any)?.items) ? (data as any).items : [],
pagination: (data as any)?.pagination || { page, limit, total: 0, pages: 0 },
};
}
export async function getHistoryDetail(
sessionId: string,
userId: string
): Promise<AnalysisSession> {
const response = await skillsApi.get<V3Response>(
`pipeline/history/${encodeURIComponent(sessionId)}?user_id=${encodeURIComponent(userId)}`
);
return response.data?.data || response.data as any;
}
export async function deleteHistoryItem(sessionId: string, userId: string): Promise<void> {
await skillsApi.delete(
`pipeline/history/${encodeURIComponent(sessionId)}?user_id=${encodeURIComponent(userId)}`
);
}

View file

@ -0,0 +1,48 @@
import { skillsApi } from './axios';
import type { TechniqueDefinition } from '../types/analysis';
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let cache: TechniqueDefinition[] | null = null;
let cacheTimestamp = 0;
let pendingRequest: Promise<TechniqueDefinition[]> | null = null;
export async function getTechniqueDefinitions(): Promise<TechniqueDefinition[]> {
// Return cache if fresh
if (cache && Date.now() - cacheTimestamp < CACHE_TTL) {
return cache;
}
// Deduplicate concurrent requests
if (pendingRequest) return pendingRequest;
pendingRequest = (async () => {
try {
const response = await skillsApi.get<{ success: boolean; data: TechniqueDefinition[] }>(
'techniques/definitions',
{ timeout: 15000 }
);
const defs = response.data?.data || [];
cache = defs;
cacheTimestamp = Date.now();
return defs;
} catch {
// If endpoint is down, return cached data or empty — zero errors
return cache || [];
} finally {
pendingRequest = null;
}
})();
return pendingRequest;
}
/** Build a lookup map: technique_name → definition */
export async function getTechniqueDefinitionsMap(): Promise<Map<string, TechniqueDefinition>> {
const defs = await getTechniqueDefinitions();
const map = new Map<string, TechniqueDefinition>();
for (const d of defs) {
map.set(d.technique_name, d);
}
return map;
}