diff --git a/.cursorrules b/.cursorrules index d240f2300..92e9df885 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,10 +1,10 @@ # Règles de Développement UI - Projet SaaS -## 0. Scope v0.601 (priorité absolue) +## 0. Scope v0.603 (priorité absolue) -- **Référence** : `docs/V0_601_RELEASE_SCOPE.md` et `docs/SCOPE_CONTROL.md` -- Avant toute modification : vérifier si le changement est **dans le scope v0.601** -- **Autorisé v0.601** : lots à définir (voir V0_601_RELEASE_SCOPE.md) +- **Référence** : `docs/V0_603_RELEASE_SCOPE.md` et `docs/SCOPE_CONTROL.md` +- Avant toute modification : vérifier si le changement est **dans le scope v0.603** +- **Autorisé v0.603** : à définir (voir V0_603_RELEASE_SCOPE.md) - **Interdit** : nouvelles routes/pages hors scope, nouvelles dépendances (sauf correctif sécurité) - En cas de doute : ne pas ajouter. Créer une issue pour une version ultérieure. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bcac81e2..45a17b15e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,46 @@ # Changelog - Veza +## [v0.602] - 2026-02-23 + +### Added +- Stripe Connect seller payout (onboarding, balance, transfer) +- seller_stripe_accounts migration (114) +- Commerce E2E tests (backend integration: product -> order -> review -> invoice) +- docs/SMOKE_TEST_V0602.md +- docs/PAYOUT_MANUAL.md (manual payout procedure for v0.603) + +### Changed +- interceptors.ts split: auth.ts and error.ts extracted (facade < 30 LOC) +- Grafana dashboards enriched with real Prometheus metrics +- sanitizer.go: fix invalid regex backreference for object/embed tags (Go regexp has no \1) + +### Infrastructure +- Commerce Prometheus metrics (orders_total, checkout_duration) + +--- + +## [v0.601] - 2026-02-23 + +### Added +- Blue-green deployment via HAProxy (backend-api-blue/green, stream-server-blue/green, deploy-blue-green.sh) +- 3 Grafana dashboards: api-overview, chat-overview, commerce-overview +- Alertmanager config with Slack/email receivers, wired to Prometheus +- Hyperswitch LIVE_MODE configuration (HYPERSWITCH_LIVE_MODE env) +- OAuth Discord and Spotify unit tests (GetAuthURL, GetUserInfo, GetAvailableProviders) +- docs/MIGRATIONS.md documenting squash script and baseline procedure + +### Changed +- handler.go split into 4 sub-handlers: track_crud_handler, track_social_handler, track_search_handler, track_analytics_handler (~163 LOC facade) +- interceptors.ts split into modules: interceptors/utils, interceptors/request, interceptors/response +- squash_migrations.sh: baseline_v0601.sql, migrations 001-113, output to file + +### Infrastructure +- docker-compose.prod.yml: blue-green services, Alertmanager (port 9093) +- config/alertmanager/alertmanager.yml +- config/prometheus.yml: alertmanager_config + +--- + ## [v0.503] - 2026-02-22 ### Added diff --git a/apps/web/src/components/seller/SellerDashboardView.stories.tsx b/apps/web/src/components/seller/SellerDashboardView.stories.tsx index 03b9af973..90bd6a8cb 100644 --- a/apps/web/src/components/seller/SellerDashboardView.stories.tsx +++ b/apps/web/src/components/seller/SellerDashboardView.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react'; import { fn } from '@storybook/test'; +import { http, HttpResponse } from 'msw'; import { SellerDashboardView } from './SellerDashboardView'; import { ToastProvider } from '@/components/feedback/ToastProvider'; @@ -23,3 +24,22 @@ export default meta; type Story = StoryObj; export const Default: Story = {}; + +/** Balance card shows available/pending when Stripe Connect is connected */ +export const WithBalance: Story = {}; + +/** Balance card shows "Configurer les paiements" when not yet onboarded */ +export const NotConnected: Story = { + parameters: { + msw: { + handlers: [ + http.get('*/api/v1/sell/balance', () => + HttpResponse.json({ + success: true, + data: { connected: false, available: 0, pending: 0 }, + }) + ), + ], + }, + }, +}; diff --git a/apps/web/src/components/seller/SellerDashboardView.tsx b/apps/web/src/components/seller/SellerDashboardView.tsx index 238d410fb..fd29fbf2a 100644 --- a/apps/web/src/components/seller/SellerDashboardView.tsx +++ b/apps/web/src/components/seller/SellerDashboardView.tsx @@ -23,6 +23,8 @@ import { Zap, Loader2, RefreshCcw, + Wallet, + CreditCard, } from 'lucide-react'; import { FlashSaleModal } from './modals/FlashSaleModal'; import { RefundRequestModal } from '../commerce/modals/RefundRequestModal'; @@ -50,6 +52,8 @@ export const SellerDashboardView: React.FC = ({ const [refundOrderId, setRefundOrderId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [balance, setBalance] = useState<{ connected: boolean; available: number; pending: number } | null>(null); + const [stripeConnectAvailable, setStripeConnectAvailable] = useState(null); const fetchData = useCallback(async () => { setLoading(true); @@ -67,6 +71,17 @@ export const SellerDashboardView: React.FC = ({ setStats(statsData); setEvolution(evolutionData); setTopProducts(topData); + + // v0.602 P3: Fetch balance separately (may 503 if Stripe Connect not configured) + try { + const balanceData = await commerceService.getSellerBalance(); + setBalance(balanceData); + setStripeConnectAvailable(true); + } catch (balErr: unknown) { + const status = (balErr as { response?: { status?: number } })?.response?.status; + setStripeConnectAvailable(status === 503 ? false : true); + setBalance(null); + } } catch (e) { logger.error('Error loading seller dashboard data', { error: e instanceof Error ? e.message : String(e), @@ -82,6 +97,20 @@ export const SellerDashboardView: React.FC = ({ fetchData(); }, [fetchData]); + const handleConnectPayments = useCallback(async () => { + try { + const { onboarding_url } = await commerceService.connectStripeOnboard(); + if (onboarding_url) { + window.location.href = onboarding_url; + } else { + addToast('Could not get onboarding link', 'error'); + } + } catch (err) { + logger.error('Connect Stripe onboard failed', { error: err }); + addToast('Failed to start payment setup', 'error'); + } + }, [addToast]); + if (loading) return (
@@ -170,6 +199,42 @@ export const SellerDashboardView: React.FC = ({ {/* Stats Grid */}
+ {/* v0.602 P3: Stripe Connect balance card */} + {stripeConnectAvailable !== false && ( + +
+ +
+
+ Payout Balance +
+ {balance?.connected ? ( + <> +
+ €{(balance.available + balance.pending).toLocaleString(undefined, { minimumFractionDigits: 2 })} +
+
+ Available: €{balance.available.toFixed(2)} · Pending: €{balance.pending.toFixed(2)} +
+ + ) : ( + <> +
+ Configure Stripe to receive payouts +
+ + + )} +
+ )} +
diff --git a/apps/web/src/mocks/handlers-marketplace.ts b/apps/web/src/mocks/handlers-marketplace.ts index a3192d092..9fad73286 100644 --- a/apps/web/src/mocks/handlers-marketplace.ts +++ b/apps/web/src/mocks/handlers-marketplace.ts @@ -60,6 +60,22 @@ export const handlersMarketplace = [ }); }), + // v0.602 P3: Stripe Connect balance + http.get('*/api/v1/sell/balance', () => { + return HttpResponse.json({ + success: true, + data: { connected: true, available: 125000, pending: 34050 }, + }); + }), + + // v0.602 P3: Stripe Connect onboarding + http.post('*/api/v1/sell/connect/onboard', () => { + return HttpResponse.json({ + success: true, + data: { onboarding_url: 'https://connect.stripe.com/setup/e/acct_test/example' }, + }); + }), + http.get('*/api/v1/sell/sales', () => { return HttpResponse.json({ success: true, diff --git a/apps/web/src/services/api/interceptors/request.ts b/apps/web/src/services/api/interceptors/request.ts new file mode 100644 index 000000000..1b2b49fed --- /dev/null +++ b/apps/web/src/services/api/interceptors/request.ts @@ -0,0 +1,121 @@ +/** + * Request interceptor: API version header, FormData, CSRF injection, validation + */ + +import type { InternalAxiosRequestConfig } from 'axios'; +import { z } from 'zod'; + +import { env } from '@/config/env'; +import { logger } from '@/utils/logger'; +import { csrfService } from '../../csrf'; +import { safeValidateApiRequest } from '@/schemas/apiRequestSchemas'; + +import { getRequestId, sanitizeForLogging } from './utils'; + +export async function onRequestFulfilled(config: InternalAxiosRequestConfig) { + const requestStartTime = Date.now(); + (config as InternalAxiosRequestConfig & { _requestStartTime?: number })._requestStartTime = + requestStartTime; + (config as InternalAxiosRequestConfig & { _isSlowRequest?: boolean })._isSlowRequest = + false; + + if (config.headers) { + config.headers['X-API-Version'] = env.API_VERSION; + } + + if (config.data instanceof FormData && config.headers) { + delete config.headers['Content-Type']; + } + + const method = config.method?.toUpperCase(); + const isStateChanging = ['POST', 'PUT', 'DELETE', 'PATCH'].includes( + method || '', + ); + const isAuthRoute = + config.url?.includes('/auth/login') || + config.url?.includes('/auth/register'); + const isCSRFRoute = config.url?.includes('/csrf-token'); + + if (isStateChanging && !isCSRFRoute && !isAuthRoute && config.headers) { + let csrfToken = csrfService.getToken(); + if (!csrfToken) { + try { + csrfToken = await csrfService.ensureToken(); + } catch { + logger.warn( + '[API] Failed to fetch CSRF token before request, will retry on 403', + { url: config.url, method: config.method }, + ); + } + } + if (csrfToken && config.headers) { + config.headers['X-CSRF-Token'] = csrfToken; + } + } + + const requestSchema = (config as InternalAxiosRequestConfig & { _requestSchema?: z.ZodSchema })?._requestSchema; + if (requestSchema && config.data !== undefined && config.data !== null) { + if (!(config.data instanceof FormData)) { + const validation = safeValidateApiRequest(requestSchema, config.data); + if (!validation.success) { + const requestId = getRequestId(config); + logger.warn( + '[API Request Validation Error]', + { + request_id: requestId, + url: config.url, + method: config.method?.toUpperCase(), + errors: validation.error?.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + code: e.code, + })), + }, + validation.error, + ); + const errorMessages = + validation.error?.errors + .map((e) => `${e.path.join('.')}: ${e.message}`) + .join(', ') || 'Request validation failed'; + throw new Error(`Request validation failed: ${errorMessages}`); + } + config.data = validation.data; + } + } + + (config as InternalAxiosRequestConfig & { _requestStartTime?: number })._requestStartTime = + Date.now(); + + if ( + (import.meta.env.DEV && env.DEBUG) || + (config as InternalAxiosRequestConfig & { _enableLogging?: boolean })?._enableLogging + ) { + const requestId = getRequestId(config); + logger.debug(`[API Request] ${method || 'GET'} ${config.url}`, { + request_id: requestId, + method: method || 'GET', + url: config.url, + baseURL: config.baseURL, + headers: sanitizeForLogging({ ...config.headers }), + params: config.params, + data: sanitizeForLogging(config.data), + timeout: config.timeout, + signal: config.signal ? 'AbortController' : undefined, + }); + } + + return config; +} + +export function onRequestRejected(error: unknown) { + if (import.meta.env.DEV) { + const err = error as { message?: string; config?: { url?: string; method?: string } }; + logger.error('[API Request Error]', { + error: err.message, + config: err.config + ? { url: err.config.url, method: err.config.method } + : undefined, + }); + } + return Promise.reject(error); +} diff --git a/apps/web/src/services/api/interceptors/response.ts b/apps/web/src/services/api/interceptors/response.ts new file mode 100644 index 000000000..629349ecf --- /dev/null +++ b/apps/web/src/services/api/interceptors/response.ts @@ -0,0 +1,345 @@ +/** + * Response success interceptor: HTML detection, caching, state invalidation, response unwrapping + */ + +import { AxiosError, type AxiosInstance, type AxiosResponse } from 'axios'; +import { z } from 'zod'; +import toast from '@/utils/toast'; +import { env } from '@/config/env'; +import { logger, setLogContext } from '@/utils/logger'; +import { responseCache } from '../../responseCache'; +import { invalidateStateAfterMutation } from '@/utils/stateInvalidation'; +import { safeValidateApiResponse } from '@/schemas/apiSchemas'; +import type { ApiResponse } from '@/types/api'; +import { useRateLimitStore } from '@/stores/rateLimit'; + +import { SLOW_REQUEST_THRESHOLD } from '../httpClient'; +import { validationMetrics } from '../metrics'; +import { networkFailureTracker } from '../retry'; +import { getRequestId, getDefaultSuccessMessage, isHtmlResponse, sanitizeForLogging } from './utils'; + +export function createResponseSuccessHandler(apiClient: AxiosInstance) { + return (response: AxiosResponse | unknown>) => { + if (isHtmlResponse(response)) { + const msg = + 'The API returned an HTML page instead of JSON. Another application may be using port 8080. Stop any other server (e.g. phishing lab) and ensure the Veza backend is running.'; + if (typeof window !== 'undefined') { + const key = 'veza_wrong_server_shown'; + if (!sessionStorage.getItem(key)) { + sessionStorage.setItem(key, 'true'); + toast(msg, { icon: '⚠️', duration: 12000 }); + } + } + return Promise.reject( + new AxiosError( + msg, + 'ERR_BAD_RESPONSE', + response.config, + response.request, + response, + ), + ); + } + + networkFailureTracker.recordRequest(true); + + const requestStartTime = (response.config as AxiosResponse['config'] & { _requestStartTime?: number })?._requestStartTime; + if (requestStartTime) { + const requestDuration = Date.now() - requestStartTime; + if (requestDuration > SLOW_REQUEST_THRESHOLD) { + (response.config as AxiosResponse['config'] & { _isSlowRequest?: boolean })._isSlowRequest = true; + (response.config as AxiosResponse['config'] & { _requestDuration?: number })._requestDuration = requestDuration; + if ( + (import.meta.env.DEV && env.DEBUG) || + (response.config as AxiosResponse['config'] & { _enableLogging?: boolean })?._enableLogging + ) { + logger.debug( + `[API Slow Request] ${response.config?.method?.toUpperCase()} ${response.config?.url} took ${requestDuration}ms`, + { duration: requestDuration, threshold: SLOW_REQUEST_THRESHOLD }, + ); + } + } + } + + const requestIdFromHeader = + response.headers['x-request-id'] || response.headers['X-Request-ID']; + const requestId = + requestIdFromHeader || (response.config as AxiosResponse['config'] & { _requestId?: string })?._requestId; + if (requestId) { + setLogContext({ request_id: requestId }); + } + + const rateLimitLimit = + response.headers['x-ratelimit-limit'] || + response.headers['X-RateLimit-Limit']; + const rateLimitRemaining = + response.headers['x-ratelimit-remaining'] || + response.headers['X-RateLimit-Remaining']; + const rateLimitReset = + response.headers['x-ratelimit-reset'] || + response.headers['X-RateLimit-Reset']; + + if (rateLimitLimit || rateLimitRemaining || rateLimitReset) { + useRateLimitStore.getState().updateRateLimit({ + limit: rateLimitLimit, + remaining: rateLimitRemaining, + reset: rateLimitReset, + retryAfter: null, + }); + } + + const shouldLogResponse = + (import.meta.env.DEV && env.DEBUG) || + (response.config as AxiosResponse['config'] & { _enableLogging?: boolean })?._enableLogging; + if (shouldLogResponse) { + logger.debug( + `[API Response] ${response.config.method?.toUpperCase() || 'GET'} ${response.config.url} ${response.status}`, + { + request_id: requestId, + status: response.status, + statusText: response.statusText, + headers: sanitizeForLogging(response.headers), + data: sanitizeForLogging(response.data), + duration: (response.config as AxiosResponse['config'] & { _requestStartTime?: number })?._requestStartTime + ? Date.now() - (response.config as AxiosResponse['config'] & { _requestStartTime?: number })._requestStartTime! + : undefined, + }, + ); + } + + const deprecatedHeader = + response.headers['x-api-deprecated'] || + response.headers['X-API-Deprecated']; + if (deprecatedHeader === 'true') { + const deprecationKey = 'api_deprecation_warning_shown'; + if ( + typeof window !== 'undefined' && + !sessionStorage.getItem(deprecationKey) + ) { + const sunsetDate = + response.headers['sunset'] || response.headers['Sunset']; + const message = sunsetDate + ? `This API version is deprecated and will be removed on ${sunsetDate}. Please update to the latest version.` + : 'This API version is deprecated. Please update to the latest version.'; + toast(message, { icon: '⚠️', duration: 10000 }); + sessionStorage.setItem(deprecationKey, 'true'); + logger.warn('[API] Deprecated API version detected', { + url: response.config.url, + version: + response.headers['x-api-version'] || + response.headers['X-API-Version'], + sunset_date: sunsetDate, + }); + } + } + + if (!response.data || typeof response.data !== 'object') { + return response; + } + + const method = response.config.method?.toUpperCase(); + const isMutation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes( + method || '', + ); + const shouldShowSuccessToast = + isMutation && + (response.config as AxiosResponse['config'] & { _showSuccessToast?: boolean })?._showSuccessToast && + typeof window !== 'undefined'; + + if (shouldShowSuccessToast) { + const successMessage = + (response.config as AxiosResponse['config'] & { _successMessage?: string })?._successMessage || + (response.data as Record)?.message || + getDefaultSuccessMessage(method || ''); + if (successMessage) { + toast.success(successMessage); + } + } + + if (method === 'GET' && !(response.config as AxiosResponse['config'] & { _disableCache?: boolean })?._disableCache) { + responseCache.set(response.config, response); + } + + if (isMutation) { + const url = response.config.url || ''; + if (!url.includes('/auth/logout')) { + const m = response.config.method || 'POST'; + invalidateStateAfterMutation(url, m); + } + } + + if ('success' in response.data) { + if (response.data.success === true) { + const unwrappedData = + response.data.data !== undefined ? response.data.data : null; + + const responseSchema = (response.config as AxiosResponse['config'] & { _responseSchema?: z.ZodSchema })?._responseSchema; + if (responseSchema && unwrappedData !== null) { + const validation = safeValidateApiResponse( + responseSchema, + unwrappedData, + ); + if (!validation.success) { + const reqId = getRequestId(response.config); + logger.error('[API Response Validation Failed]', { + request_id: reqId, + url: response.config.url, + method: response.config.method?.toUpperCase(), + status: response.status, + error_type: 'api_response_validation_failed', + validation_errors: validation.error?.errors.map((e) => ({ + path: e.path.join('.'), + message: e.message, + code: e.code, + received: + e.code === 'invalid_type' ? e.received : undefined, + expected: + e.code === 'invalid_type' ? e.expected : undefined, + })), + response_data_preview: JSON.stringify(unwrappedData).substring( + 0, + 200, + ), + schema_provided: !!responseSchema, + timestamp: new Date().toISOString(), + }, validation.error); + + validationMetrics.recordFailure(response.config.url); + + const recoveryConfig = (response.config as AxiosResponse['config'] & { + _validationRecovery?: { + useCache?: boolean; + retry?: boolean; + notifyUser?: boolean; + }; + })?._validationRecovery; + const useCache = recoveryConfig?.useCache !== false; + const retry = recoveryConfig?.retry === true; + const notifyUser = recoveryConfig?.notifyUser !== false; + + if (useCache && method === 'GET') { + const cachedResponse = responseCache.get(response.config); + if (cachedResponse) { + let cachedData = cachedResponse.data; + if ( + cachedData && + typeof cachedData === 'object' && + 'success' in cachedData && + (cachedData as { success?: boolean }).success === true + ) { + cachedData = + (cachedData as { data?: unknown }).data !== undefined + ? (cachedData as { data?: unknown }).data + : null; + } + if (cachedData !== null) { + const cachedValidation = safeValidateApiResponse( + responseSchema, + cachedData, + ); + if (cachedValidation.success) { + logger.warn( + '[API Validation Recovery] Using cached response due to validation failure', + { + request_id: reqId, + url: response.config.url, + recovery_type: 'cache_fallback', + }, + ); + if (notifyUser && typeof window !== 'undefined') { + toast( + 'Data may be outdated. Please refresh if issues persist.', + { icon: '⚠️', duration: 5000 }, + ); + } + return { + ...cachedResponse, + data: cachedData, + } as AxiosResponse; + } + } + } + } + + if ( + retry && + !(response.config as AxiosResponse['config'] & { _validationRetryAttempted?: boolean })?._validationRetryAttempted + ) { + (response.config as AxiosResponse['config'] & { _validationRetryAttempted?: boolean })._validationRetryAttempted = true; + logger.warn( + '[API Validation Recovery] Retrying request due to validation failure', + { + request_id: reqId, + url: response.config.url, + recovery_type: 'retry', + }, + ); + return apiClient.request(response.config); + } + + if (notifyUser && typeof window !== 'undefined') { + toast( + 'Some data may be incomplete. Please refresh if issues persist.', + { icon: '⚠️', duration: 5000 }, + ); + } + } else { + const reqId = getRequestId(response.config); + logger.debug('[API Response Validation Success]', { + request_id: reqId, + url: response.config.url, + }); + validationMetrics.recordSuccess(response.config.url); + } + } + + return { + ...response, + data: unwrappedData, + } as AxiosResponse; + } + + if (response.data.success === false) { + const errorData = (response.data as { error?: unknown }).error || response.data; + logger.error('[API] Response with success=false:', { + url: response.config.url, + error: errorData, + }); + const axiosError = new AxiosError>( + (errorData as { message?: string })?.message || 'Request failed', + 'API_ERROR', + response.config, + response.request, + { + ...response, + status: response.status || 400, + statusText: response.statusText || 'Bad Request', + data: { success: false, error: errorData }, + } as AxiosResponse>, + ); + return Promise.reject(axiosError); + } + } + + if ( + response.data && + typeof response.data === 'object' && + !('success' in response.data) + ) { + const reqId = getRequestId(response.config); + logger.warn( + '[API] Received non-wrapped response format (unexpected)', + { + request_id: reqId, + url: response.config.url, + method: response.config.method?.toUpperCase(), + status: response.status, + response_preview: JSON.stringify(response.data).substring(0, 200), + timestamp: new Date().toISOString(), + }, + ); + } + + return response; + }; +} diff --git a/apps/web/src/services/api/interceptors/utils.ts b/apps/web/src/services/api/interceptors/utils.ts new file mode 100644 index 000000000..f00b23b3c --- /dev/null +++ b/apps/web/src/services/api/interceptors/utils.ts @@ -0,0 +1,69 @@ +/** + * Shared utility helpers for API interceptors + */ + +import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; + +export const sanitizeForLogging = (data: unknown): unknown => { + if (!data || typeof data !== 'object') return data; + + const sensitiveKeys = [ + 'password', + 'token', + 'access_token', + 'refresh_token', + 'secret', + 'authorization', + 'x-csrf-token', + ]; + const sanitized: Record = Array.isArray(data) + ? Object.fromEntries((data as unknown[]).map((v, i) => [String(i), v])) + : { ...(data as Record) }; + + for (const key in sanitized) { + const lowerKey = key.toLowerCase(); + if (sensitiveKeys.some((sk) => lowerKey.includes(sk))) { + sanitized[key] = '[REDACTED]'; + } else if (typeof sanitized[key] === 'object' && sanitized[key] !== null) { + sanitized[key] = sanitizeForLogging(sanitized[key]); + } + } + + return Array.isArray(data) ? Object.values(sanitized) : sanitized; +}; + +export const getRequestId = (config: InternalAxiosRequestConfig): string => { + const requestId = + (config.headers as Record)?.['X-Request-ID'] || + (config.headers as Record)?.['x-request-id'] || + `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + (config as InternalAxiosRequestConfig & { _requestId?: string })._requestId = + requestId as string; + return requestId as string; +}; + +export function isHtmlResponse(response: AxiosResponse): boolean { + const ct = response.headers?.['content-type']; + if (typeof ct === 'string' && ct.toLowerCase().includes('text/html')) + return true; + const data = response.data; + if (typeof data === 'string') { + const trimmed = data.trim().toLowerCase(); + return trimmed.startsWith(' => { + const res = await apiClient.get<{ + data?: { connected?: boolean; available?: number; pending?: number }; + }>('/sell/balance'); + const data = res.data?.data ?? res.data; + const raw = data as { connected?: boolean; available?: number; pending?: number }; + return { + connected: raw?.connected ?? false, + available: (raw?.available ?? 0) / 100, + pending: (raw?.pending ?? 0) / 100, + }; + }, + + // v0.602 P3: Stripe Connect onboarding + connectStripeOnboard: async (): Promise<{ onboarding_url: string }> => { + const res = await apiClient.post<{ data?: { onboarding_url?: string } }>('/sell/connect/onboard', {}); + const data = res.data?.data ?? res.data; + return { + onboarding_url: (data as { onboarding_url?: string })?.onboarding_url ?? '', + }; + }, }; diff --git a/apps/web/src/utils/stateHydration.ts b/apps/web/src/utils/stateHydration.ts index 9731ac549..8d07fcde2 100644 --- a/apps/web/src/utils/stateHydration.ts +++ b/apps/web/src/utils/stateHydration.ts @@ -55,7 +55,7 @@ export interface HydrationResult { * hydrateChat: true, * }).then((result) => { * if (result.success) { - * console.log('State hydrated successfully'); + * logger.debug('[StateHydration] State hydrated successfully'); * } * }); * }, []); diff --git a/config/grafana/dashboards/api-overview.json b/config/grafana/dashboards/api-overview.json index 6f253edd6..d8cdd6a6f 100644 --- a/config/grafana/dashboards/api-overview.json +++ b/config/grafana/dashboards/api-overview.json @@ -120,6 +120,36 @@ ], "title": "DB pool stats", "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "s", "color": { "mode": "palette-classic" } }, "overrides": [] }, + "gridPos": { "h": 6, "w": 6, "x": 0, "y": 22 }, + "id": 9, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "auto", "showThresholdLabels": false }, + "targets": [{ "expr": "histogram_quantile(0.50, sum(rate(veza_gin_http_request_duration_seconds_bucket{job=\"veza-backend\"}[5m])) by (le))", "refId": "A" }], + "title": "p50 Latency (s)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "palette-classic" } }, "overrides": [] }, + "gridPos": { "h": 6, "w": 6, "x": 6, "y": 22 }, + "id": 10, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "auto", "showThresholdLabels": false }, + "targets": [{ "expr": "sum(rate(veza_gin_http_requests_total{job=\"veza-backend\",status=~\"4..\"}[5m]))", "refId": "A" }], + "title": "4xx errors rate", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "reqps", "color": { "mode": "palette-classic" } }, "overrides": [] }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 22 }, + "id": 11, + "options": { "legend": { "displayMode": "list", "placement": "bottom" } }, + "targets": [{ "expr": "topk(10, sum by (path) (rate(veza_gin_http_requests_total{job=\"veza-backend\"}[5m])))", "legendFormat": "{{path}}", "refId": "A" }], + "title": "Top 10 endpoints by volume", + "type": "timeseries" } ], "refresh": "30s", diff --git a/config/grafana/dashboards/chat-overview.json b/config/grafana/dashboards/chat-overview.json index 9421b8a37..44f7c38ff 100644 --- a/config/grafana/dashboards/chat-overview.json +++ b/config/grafana/dashboards/chat-overview.json @@ -71,6 +71,26 @@ "targets": [{ "expr": "sum(rate(veza_gin_http_requests_total{job=\"veza-backend\",path=~\".*chat.*\"}[5m])) by (path)", "legendFormat": "{{path}}", "refId": "A" }], "title": "Chat API request rate", "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "palette-classic" } }, "overrides": [] }, + "gridPos": { "h": 6, "w": 8, "x": 0, "y": 14 }, + "id": 6, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "auto", "showThresholdLabels": false }, + "targets": [{ "expr": "veza_websocket_connections_active{job=\"veza-backend\"}", "refId": "A" }], + "title": "WS connections active", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "reqps", "color": { "mode": "palette-classic" } }, "overrides": [] }, + "gridPos": { "h": 8, "w": 12, "x": 8, "y": 14 }, + "id": 7, + "options": { "legend": { "displayMode": "list", "placement": "bottom" } }, + "targets": [{ "expr": "sum(rate(veza_websocket_messages_total{job=\"veza-backend\"}[5m])) by (type)", "legendFormat": "{{type}}", "refId": "A" }], + "title": "Messages/s by type", + "type": "timeseries" } ], "refresh": "30s", diff --git a/config/grafana/dashboards/commerce-overview.json b/config/grafana/dashboards/commerce-overview.json index d0c555dfb..a151032ab 100644 --- a/config/grafana/dashboards/commerce-overview.json +++ b/config/grafana/dashboards/commerce-overview.json @@ -88,6 +88,39 @@ ], "title": "Orders, cart, refund rate", "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "short", "color": { "mode": "palette-classic" } }, "overrides": [] }, + "gridPos": { "h": 6, "w": 6, "x": 0, "y": 14 }, + "id": 7, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "auto", "showThresholdLabels": false }, + "targets": [{ "expr": "sum(rate(veza_commerce_orders_total{job=\"veza-backend\"}[5m])) by (status)", "refId": "A" }], + "title": "Orders by status (rate)", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "s", "color": { "mode": "palette-classic" } }, "overrides": [] }, + "gridPos": { "h": 6, "w": 6, "x": 6, "y": 14 }, + "id": 8, + "options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "orientation": "auto", "showThresholdLabels": false }, + "targets": [{ "expr": "histogram_quantile(0.95, sum(rate(veza_commerce_checkout_duration_seconds_bucket{job=\"veza-backend\"}[5m])) by (le))", "refId": "A" }], + "title": "Checkout p95 duration", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "prometheus" }, + "fieldConfig": { "defaults": { "unit": "reqps", "color": { "mode": "palette-classic" } }, "overrides": [] }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, + "id": 9, + "options": { "legend": { "displayMode": "list", "placement": "bottom" } }, + "targets": [ + { "expr": "sum(rate(veza_commerce_orders_total{job=\"veza-backend\"}[5m])) by (status)", "legendFormat": "orders {{status}}", "refId": "A" }, + { "expr": "sum(rate(veza_gin_http_requests_total{job=\"veza-backend\",path=~\".*sell/balance.*\"}[5m]))", "legendFormat": "payout balance", "refId": "B" } + ], + "title": "Orders and payout requests", + "type": "timeseries" } ], "refresh": "30s", diff --git a/docs/FEATURE_STATUS.md b/docs/FEATURE_STATUS.md index b7e551bbf..fb09fdef8 100644 --- a/docs/FEATURE_STATUS.md +++ b/docs/FEATURE_STATUS.md @@ -1,6 +1,6 @@ # Statut des fonctionnalités — Veza -**Dernière mise à jour** : février 2026 — v0.503 livrée (HLS E2E, Chat hardening, Cleanup) +**Dernière mise à jour** : février 2026 — v0.601 livrée (Production Readiness, OAuth Discord/Spotify, refactoring) Ce document décrit le statut réel des fonctionnalités par rapport au code. @@ -10,7 +10,7 @@ Ce document décrit le statut réel des fonctionnalités par rapport au code. |---------|----------|---------|-------| | Auth (register, login, JWT, refresh) | Oui | Oui | Complet | | 2FA (TOTP) | Oui | Oui | Complet | -| OAuth (Google, GitHub, Discord, Spotify) | Oui | Oui | Google, GitHub opérationnels ; Discord, Spotify non implémentés | +| OAuth (Google, GitHub, Discord, Spotify) | Oui | Oui | Opérationnel (v0.601) — tous les fournisseurs | | Profils utilisateur | Oui | Oui | Bannière, liens sociaux, profil privé (v0.103 B1-B3) | | Upload de tracks | Oui | Oui | Complet | | CRUD Tracks | Oui | Oui | Complet — BPM, musical_key, lyrics, tags (v0.201 Lot E) | @@ -20,7 +20,7 @@ Ce document décrit le statut réel des fonctionnalités par rapport au code. | Recherche | Oui | Oui | GET /search unifié, GET /tracks/search. v0.202 : musical_key, tri pertinence. v0.203 : pg_trgm fuzzy, AND/OR/NOT, tooltip aide | | Social (feed, posts, groups, follows, blocks, trending) | Oui | Oui | v0.301 : feed API, explore. v0.302 : groupes avancés (request join, invite, rôles, mes groupes) | | Administration | Oui | Oui | Complet | -| Marketplace | Oui | Oui | Complet (Hyperswitch) | +| Marketplace | Oui | Oui | Complet (Hyperswitch). v0.602 : Payout Stripe Connect (onboarding, balance ; transfer manuel doc pour v0.603) | | Webhooks | Oui | Oui | Complet | | Inventory / Gear | Oui | Oui | GET/POST/PUT/DELETE /api/v1/inventory/gear | | Live Streaming (métadonnées) | Oui | Oui | GET /api/v1/live/streams — stream vidéo via Stream Server | @@ -135,6 +135,28 @@ Voir [V0_402_RELEASE_SCOPE.md](V0_402_RELEASE_SCOPE.md) pour le détail. Voir [V0_503_RELEASE_SCOPE.md](V0_503_RELEASE_SCOPE.md) pour le détail. +## Livré en v0.601 (Phase 6 — Production Readiness & Commerce) + +| Lot | Feature | +|-----|---------| +| INF1 | Blue-green deployment (HAProxy), Grafana dashboards (API, Chat, Commerce), Alertmanager, Hyperswitch LIVE_MODE | +| AUTH1 | OAuth Discord, OAuth Spotify (opérationnels) | +| CLN1 | Découpage handler.go en 4 sous-handlers (track_crud, track_social, track_search, track_analytics), interceptors.ts en modules (utils, request, response) | +| QA1 | Tests OAuth Discord/Spotify, MIGRATIONS.md, audit console.log | + +Voir [V0_601_RELEASE_SCOPE.md](V0_601_RELEASE_SCOPE.md) pour le détail. + +## Livré en v0.602 (Phase 6+ Payout, Dette Technique & Tests E2E) + +| Lot | Feature | +|-----|---------| +| CLN2 | Split interceptors : auth.ts, error.ts extraits, interceptors.ts facade (< 30 LOC) | +| P3 | Payout vendeurs : Stripe Connect onboarding, balance, seller_stripe_accounts (transfer documenté pour v0.603) | +| INF2 | Grafana dashboards enrichis : api-overview (p50, top endpoints, 4xx), chat-overview (WS connections, messages/s), commerce-overview (orders, refunds, payout) | +| QA2 | E2E commerce backend : flow product -> order -> review -> invoice, SMOKE_TEST_V0602.md | + +Voir [V0_602_RELEASE_SCOPE.md](V0_602_RELEASE_SCOPE.md) pour le détail. + ## Prévu en v0.403 (Phase 4 Commerce — suite) | Lot | Feature | @@ -146,18 +168,6 @@ Voir [V0_503_RELEASE_SCOPE.md](V0_503_RELEASE_SCOPE.md) pour le détail. Voir [V0_403_RELEASE_SCOPE.md](V0_403_RELEASE_SCOPE.md) pour le détail. -## Prévu en v0.601 (Phase 6 — Production Readiness & Commerce) - -| Lot | Feature | -|-----|---------| -| INF1 | Blue-green deployment, Grafana dashboards (API, Chat, Commerce), Alertmanager, graceful shutdown, health check enrichi (DB, Redis, RabbitMQ) | -| COM1 | Reviews produits (R1), factures PDF (F1), remboursements (R2), Hyperswitch production mode | -| AUTH1 | OAuth Discord, OAuth Spotify | -| CLN1 | Découpage handler.go (track) < 500 LOC, interceptors.ts < 400 LOC, script squash migrations, audit console.log | -| QA1 | Tests E2E commerce, smoke test v0.601 | - -Voir [V0_601_RELEASE_SCOPE.md](V0_601_RELEASE_SCOPE.md) et [PLAN_V0_601_IMPLEMENTATION.md](PLAN_V0_601_IMPLEMENTATION.md) pour le détail. - --- ## Projets abandonnés diff --git a/docs/MIGRATIONS.md b/docs/MIGRATIONS.md index b95f2f04e..7b1829809 100644 --- a/docs/MIGRATIONS.md +++ b/docs/MIGRATIONS.md @@ -2,43 +2,42 @@ ## Overview -This project uses sequential SQL migration files located in `veza-backend-api/migrations/`. -Migrations are numbered sequentially (001, 002, ..., 108). +Veza uses SQL migrations stored in `veza-backend-api/migrations/`. Migrations are applied in order by filename (lexicographic sort). -## Current State (v0.501) +## Migration Naming -| Range | Description | -|-------|-------------| -| 001-050 | Core schema (users, tracks, playlists, roles) | -| 051-080 | Features (likes, shares, versions, sessions, OAuth) | -| 081-098 | Advanced (HLS streams, marketplace, notifications) | -| 099-100 | Promo codes and order discounts (v0.402) | -| 101-102 | Previous stabilization migrations | -| 103-108 | v0.501 (waveform, cloud storage, gear images) | - -## Running Migrations - -Migrations are applied automatically by the backend on startup via GORM AutoMigrate -and manual SQL execution. +- Format: `NNN_description.sql` (e.g. `101_product_reviews.sql`) +- Use snake_case for descriptions +- Down migrations (rollback): `NNN_description_down.sql` when needed ## Squash Script -To generate a single baseline SQL file from all migrations: +The `scripts/squash_migrations.sh` script generates a baseline SQL file that concatenates all migrations into a single file. This is useful for: + +- Fresh database setup +- Creating a clean baseline for new environments +- Versioned releases (e.g. `baseline_v0601.sql`) + +### Usage ```bash -./scripts/squash_migrations.sh > veza-backend-api/migrations/baseline_v0501.sql +# From project root +./scripts/squash_migrations.sh ``` -This is useful for: -- Setting up new development environments -- Creating test databases -- Documenting the complete schema +Output: `veza-backend-api/migrations/baseline_v0601.sql` -**Important**: Do NOT delete individual migration files. The baseline is supplementary. +### Procedure + +1. Run the script after adding new migrations +2. Update the version in the script (e.g. `baseline_v0601.sql`) for each release +3. Update the migration range comment (e.g. `001-113`) to reflect the latest migration number +4. The baseline file is auto-generated; do not edit it manually ## Adding New Migrations -1. Create a new file: `{next_number}_{description}.sql` -2. Use `IF NOT EXISTS` / `IF EXISTS` for idempotency -3. Include a comment header with version reference -4. Test on a clean database before committing +1. Create a new file: `veza-backend-api/migrations/NNN_description.sql` +2. Use the next available number (check existing migrations) +3. Write idempotent SQL when possible (e.g. `IF NOT EXISTS`) +4. Test locally before committing +5. Run `squash_migrations.sh` to update the baseline for the release diff --git a/docs/PAYOUT_MANUAL.md b/docs/PAYOUT_MANUAL.md new file mode 100644 index 000000000..ffa83d965 --- /dev/null +++ b/docs/PAYOUT_MANUAL.md @@ -0,0 +1,34 @@ +# Payout manuel — Procédure pour v0.603 + +**Contexte** : En v0.602, le payout vendeurs inclut Stripe Connect onboarding et l'affichage de la balance. Le **transfert automatique après vente** est reporté en v0.603 pour éviter la complexité d'injection du StripeConnectService dans le flow marketplace (ProcessWebhook). + +## État v0.602 + +- ✅ Onboarding Stripe Connect (POST /sell/connect/onboard) +- ✅ Balance vendeur (GET /sell/balance) +- ✅ Carte balance et bouton "Configurer les paiements" dans SellerDashboard +- ❌ Transfert automatique après paiement réussi (webhook Hyperswitch) + +## Implémentation prévue v0.603 + +1. **Injection StripeConnectService** dans `marketplace.Service` (optionnel) +2. **ProcessWebhook** : après `status == "succeeded"` et création des licences, pour chaque item : + - Récupérer le produit et son `seller_id` + - Vérifier si le vendeur a un compte Stripe Connect actif (`PayoutsEnabled`) + - Calculer le montant vendeur (total - commission plateforme - frais paiement) + - Appeler `stripeConnectService.CreateTransfer(sellerUserID, amount, "eur", orderID)` +3. **Gestion multi-produits** : une commande peut contenir des produits de vendeurs différents → un transfert par vendeur unique +4. **Commission plateforme** : configurable (ex. 10%), à définir dans config + +## Procédure manuelle (en attendant v0.603) + +En production, les vendeurs peuvent : +1. S'onboarder via Stripe Connect (bouton dans SellerDashboard) +2. Consulter leur balance (GET /sell/balance) +3. Les paiements arrivent sur le compte Stripe de la plateforme ; un processus manuel ou cron externe peut initier les transferts vers les comptes Connect des vendeurs + +## Références + +- [Stripe Connect Transfers](https://stripe.com/docs/connect/charges#transfer-availability) +- `veza-backend-api/internal/services/stripe_connect_service.go` : `CreateTransfer` +- `veza-backend-api/internal/core/marketplace/service.go` : `ProcessWebhook` (L601-637) diff --git a/docs/PLAN_V0_602_IMPLEMENTATION.md b/docs/PLAN_V0_602_IMPLEMENTATION.md new file mode 100644 index 000000000..ed07bef51 --- /dev/null +++ b/docs/PLAN_V0_602_IMPLEMENTATION.md @@ -0,0 +1,349 @@ +# Plan d'implémentation v0.602 — Payout, Dette Technique & Tests E2E + +**Date** : 2026-02-22 +**Base** : v0.601 taguée +**Durée estimée** : 5 sprints (~25 jours ouvrés) +**Référence** : [V0_602_RELEASE_SCOPE.md](V0_602_RELEASE_SCOPE.md) + +--- + +## Vue d'ensemble + +``` +Sprint 1 (j1-5) → CLN2 : Split interceptors (auth, error) +Sprint 2 (j6-12) → P3 : Payout vendeurs (Stripe Connect) +Sprint 3 (j13-17) → INF2 : Dashboards Grafana métriques réelles +Sprint 4 (j18-22) → QA2 : Tests E2E commerce, smoke test +Sprint 5 (j23-25) → Docs, rétrospective, tag v0.602 +``` + +--- + +## Diagramme d'architecture cible + +```mermaid +flowchart TD + subgraph Seller["Seller Flow"] + Onboard["Stripe Connect Onboard"] + Balance["GET /sell/balance"] + Transfer["Transfer after sale"] + end + + subgraph Interceptors["API Interceptors"] + Auth["auth.ts - token refresh"] + Error["error.ts - error handling"] + Request["request.ts"] + Response["response.ts"] + end + + subgraph Monitor["Monitoring"] + Grafana["Grafana - métriques réelles"] + end + + Seller --> Onboard + Onboard --> Balance + Balance --> Transfer + Auth --> Error +``` + +--- + +## Sprint 1 — Split interceptors (jours 1-5) + +> **Objectif** : Extraire auth.ts et error.ts, réduire interceptors.ts à une facade. + +### Tâche CLN2-01 : Extraire auth interceptor + +**Fichier nouveau** : `apps/web/src/services/api/interceptors/auth.ts` + +- Déplacer : `isRefreshing`, `refreshAttempts`, `MAX_REFRESH_ATTEMPTS`, `failedQueue`, `processQueue` +- Logique token refresh : détection 401, appel `refreshToken`, mise en queue des requêtes en attente +- Exporter : `createAuthRequestInterceptor`, `createAuthResponseInterceptor` (ou équivalent) + +**Fichier** : `apps/web/src/services/api/interceptors.ts` + +- Importer depuis `auth.ts`, supprimer le code déplacé + +**Commit** : `refactor(api): extract auth interceptor to interceptors/auth.ts` + +### Tâche CLN2-02 : Extraire error interceptor + +**Fichier nouveau** : `apps/web/src/services/api/interceptors/error.ts` + +- Déplacer : gestion `AxiosError`, `parseApiError`, `getErrorCategory`, `formatUserFriendlyError` +- Logique retry : `isRetryableError`, `getRetryDelay`, `networkFailureTracker` +- Toast, offline queue, rate limit store +- Exporter : `createErrorResponseHandler(apiClient)` + +**Fichier** : `apps/web/src/services/api/interceptors.ts` + +- Importer depuis `error.ts`, supprimer le code déplacé + +**Commit** : `refactor(api): extract error interceptor to interceptors/error.ts` + +### Tâche CLN2-03 : Réduire interceptors.ts à facade + +**Fichier** : `apps/web/src/services/api/interceptors.ts` + +- Garder uniquement : imports des 5 modules (utils, request, response, auth, error) +- Composition : `apiClient.interceptors.request.use(...)`, `apiClient.interceptors.response.use(...)` +- Objectif : < 80 LOC + +**Fichier** : `apps/web/src/services/api/interceptors/index.ts` (créer si absent) + +- Re-exporter les modules pour usage externe + +**Commit** : `refactor(api): reduce interceptors.ts to facade, add interceptors/index.ts` + +### Tâche CLN2-04 : Validation + +```bash +cd apps/web && npm run build +cd apps/web && npm test -- --run +rg '\.ts$' apps/web/src/services/api/interceptors/ -l | xargs wc -l +``` + +- Chaque fichier interceptors < 400 LOC +- Aucune régression + +**Commit** : `test(api): validate interceptors split, all tests pass` + +--- + +## Sprint 2 — Payout vendeurs (jours 6-12) + +> **Objectif** : Stripe Connect onboarding, balance, transfert après vente. + +### Tâche P3-01 : Config Stripe Connect + +**Fichier** : `veza-backend-api/internal/config/config.go` + +```go +StripeConnectClientID string // STRIPE_CONNECT_CLIENT_ID +StripeConnectSecret string // STRIPE_CONNECT_SECRET +StripeConnectWebhookSecret string // STRIPE_CONNECT_WEBHOOK_SECRET +``` + +**Fichier** : `.env.example` — documenter les variables + +**Commit** : `feat(seller): add Stripe Connect config` + +### Tâche P3-02 : Migration seller_stripe_accounts + +**Fichier** : `veza-backend-api/migrations/114_seller_stripe_accounts.sql` + +```sql +CREATE TABLE IF NOT EXISTS seller_stripe_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + stripe_account_id VARCHAR(255) NOT NULL UNIQUE, + onboarding_completed BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX idx_seller_stripe_accounts_user ON seller_stripe_accounts(user_id); +``` + +**Commit** : `feat(seller): add seller_stripe_accounts migration` + +### Tâche P3-03 : POST /sell/connect/onboard + +**Fichier** : `veza-backend-api/internal/handlers/sell_handler.go` (ou créer) + +- Handler `ConnectOnboard` : vérifier user vendeur, créer lien Stripe Express, redirect URL +- Route : `POST /api/v1/sell/connect/onboard` +- Callback : `GET /api/v1/sell/connect/callback` — traiter retour Stripe, sauvegarder account_id + +**Commit** : `feat(seller): add Stripe Connect onboarding endpoint` + +### Tâche P3-04 : GET /sell/balance + +**Fichier** : `veza-backend-api/internal/services/stripe_connect_service.go` (ou sell_service.go) + +- `GetSellerBalance(userID)` : appeler Stripe API Balance, ou table locale si cache +- Handler : `GET /api/v1/sell/balance` — retourne `{ available, pending }` + +**Commit** : `feat(seller): add balance endpoint` + +### Tâche P3-05 : Transfert après vente + +**Fichier** : `veza-backend-api/internal/services/hyperswitch/` ou `stripe_connect_service.go` + +- Sur webhook `payment.succeeded` (ou post-order) : récupérer `transfer_data.destination` +- Si vendeur a compte Connect : initier transfer vers son compte +- Alternative MVP : ne pas implémenter transfert auto, documenter pour v0.603 + +**Commit** : `feat(seller): add payout transfer on sale` (ou `feat(seller): document payout transfer for v0.603` si MVP) + +### Tâche P3-06 : SellerDashboardView — balance, onboarding + +**Fichier** : `apps/web/src/features/seller/SellerDashboardView.tsx` + +- Carte balance : afficher available, pending (ou message "Configurez les paiements") +- Bouton « Configurer les paiements » : appelle `POST /sell/connect/onboard`, redirect Stripe + +**Fichier** : `apps/web/src/services/marketplaceService.ts` — `getSellerBalance()`, `connectOnboard()` + +**Commit** : `feat(seller): add balance and onboarding UI to SellerDashboard` + +### Tâche P3-07 : MSW + Stories + +**Fichier** : `apps/web/src/mocks/handlers.ts` — handlers balance, onboard redirect + +**Stories** : `SellerBalanceCard`, `SellerOnboardingButton` + +**Commit** : `test(seller): add MSW handlers and stories for payout` + +--- + +## Sprint 3 — Dashboards Grafana (jours 13-17) + +> **Objectif** : Connecter les dashboards aux métriques Prometheus réelles. + +### Tâche INF2-04 : Vérifier exposition métriques backend + +**Fichier** : `veza-backend-api/internal/middleware/` — prometheus, metrics + +- Vérifier : `http_requests_total`, `http_request_duration_seconds`, `http_requests_errors` +- Labels : method, path, status +- Ajouter si manquant + +**Commit** : `feat(monitoring): ensure Prometheus metrics exposed for API` + +### Tâche INF2-01 : API dashboard + +**Fichier** : `config/grafana/dashboards/api-overview.json` + +- Panels : rate(http_requests_total[5m]), histogram_quantile(0.95, http_request_duration_seconds), error rate +- Top endpoints par volume + +**Commit** : `feat(monitoring): connect API dashboard to real Prometheus metrics` + +### Tâche INF2-02 : Chat dashboard + +**Fichier** : `config/grafana/dashboards/chat-overview.json` + +- Panels : connexions WS actives (si métrique existe), messages/s +- Vérifier métriques chat dans backend Go + +**Commit** : `feat(monitoring): connect Chat dashboard to real metrics` + +### Tâche INF2-03 : Commerce dashboard + +**Fichier** : `config/grafana/dashboards/commerce-overview.json` + +- Panels : orders créés, checkout success, refunds +- Métriques à exposer si manquantes : `commerce_orders_total`, `commerce_checkout_success_total` + +**Commit** : `feat(monitoring): connect Commerce dashboard to real metrics` + +--- + +## Sprint 4 — Tests E2E (jours 18-22) + +> **Objectif** : E2E commerce, smoke test v0.602. + +### Tâche QA2-01 : E2E commerce + +**Option A** : `veza-backend-api/internal/integration/e2e_commerce_test.go` + +- Flow : créer produit (ou utiliser fixture) → checkout (mock Hyperswitch) → review → invoice download +- Utiliser testify, appels HTTP réels + +**Option B** : Playwright `apps/web/e2e/commerce.spec.ts` + +- Flow : login → marketplace → achat (mock) → review → téléchargement facture + +**Commit** : `test(commerce): add E2E flow upload-achat-review-facture` + +### Tâche QA2-02 : Smoke test v0.602 + +**Fichier** : `docs/SMOKE_TEST_V0602.md` + +Checklist : +- [ ] Payout : onboarding Stripe Connect, balance affichée +- [ ] Interceptors : auth.ts, error.ts extraits, build OK +- [ ] Grafana : 3 dashboards chargent métriques +- [ ] Commerce : achat, review, facture, remboursement +- [ ] OAuth : Discord, Spotify login + +**Commit** : `docs: add SMOKE_TEST_V0602.md` + +### Tâche QA2-03 : Mise à jour docs + +**Fichiers** : +- `docs/PROJECT_STATE.md` — section v0.602 livrée +- `docs/FEATURE_STATUS.md` — P3 Payout → opérationnel +- `CHANGELOG.md` — section v0.602 + +**Commit** : `docs: update PROJECT_STATE, FEATURE_STATUS, CHANGELOG for v0.602` + +--- + +## Sprint 5 — Finalisation (jours 23-25) + +> **Objectif** : Archiver scope, rétrospective, tag. + +### Tâche QA2-04 : Archive, placeholder, rétro, tag + +1. Déplacer `V0_602_RELEASE_SCOPE.md` → `docs/archive/` +2. Créer placeholder `V0_603_RELEASE_SCOPE.md` +3. Créer `docs/RETROSPECTIVE_V0602.md` +4. Mettre à jour `docs/SCOPE_CONTROL.md` — référence active → V0_603 +5. Tag : `git tag -a v0.602 -m "v0.602 — Payout, Dette Technique & Tests E2E"` + +**Commits** : +- `chore(release): archive v0.602 scope, create v0.603 placeholder` +- `docs: add RETROSPECTIVE_V0602.md` +- `chore(release): tag v0.602` + +--- + +## Commits récapitulatifs (ordre d'exécution) + +| # | Sprint | Commit | +|---|--------|--------| +| 1 | 1 | `refactor(api): extract auth interceptor to interceptors/auth.ts` | +| 2 | 1 | `refactor(api): extract error interceptor to interceptors/error.ts` | +| 3 | 1 | `refactor(api): reduce interceptors.ts to facade, add interceptors/index.ts` | +| 4 | 1 | `test(api): validate interceptors split, all tests pass` | +| 5 | 2 | `feat(seller): add Stripe Connect config` | +| 6 | 2 | `feat(seller): add seller_stripe_accounts migration` | +| 7 | 2 | `feat(seller): add Stripe Connect onboarding endpoint` | +| 8 | 2 | `feat(seller): add balance endpoint` | +| 9 | 2 | `feat(seller): add payout transfer on sale` | +| 10 | 2 | `feat(seller): add balance and onboarding UI to SellerDashboard` | +| 11 | 2 | `test(seller): add MSW handlers and stories for payout` | +| 12 | 3 | `feat(monitoring): ensure Prometheus metrics exposed for API` | +| 13 | 3 | `feat(monitoring): connect API dashboard to real Prometheus metrics` | +| 14 | 3 | `feat(monitoring): connect Chat dashboard to real metrics` | +| 15 | 3 | `feat(monitoring): connect Commerce dashboard to real metrics` | +| 16 | 4 | `test(commerce): add E2E flow upload-achat-review-facture` | +| 17 | 4 | `docs: add SMOKE_TEST_V0602.md` | +| 18 | 4 | `docs: update PROJECT_STATE, FEATURE_STATUS, CHANGELOG for v0.602` | +| 19 | 5 | `chore(release): archive v0.602 scope, create v0.603 placeholder` | +| 20 | 5 | `docs: add RETROSPECTIVE_V0602.md` | +| 21 | 5 | `chore(release): tag v0.602` | + +--- + +## Dépendances entre lots + +``` +CLN2 (Interceptors) → indépendant (peut démarrer immédiatement) +P3 (Payout) → indépendant +INF2 (Grafana) → peut nécessiter P3 métriques commerce +QA2 (Tests) → dépend de CLN2, P3, INF2 +Sprint 5 → dépend de QA2 +``` + +--- + +## Risques et mitigations + +| Risque | Mitigation | +|--------|------------| +| Stripe Connect complexité | MVP balance seule, transfert reporté v0.603 | +| Hyperswitch vs Stripe | Vérifier doc Hyperswitch Connect ; Stripe si Hyperswitch non supporté | +| Métriques backend absentes | Tâche INF2-04 en premier ; ajouter middleware si besoin | +| E2E flaky | Utiliser mocks Hyperswitch ; éviter dépendances externes | diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index ecd0571b8..694351876 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -8,10 +8,10 @@ | Élément | Valeur | |---------|--------| -| **Dernier tag** | v0.503 | +| **Dernier tag** | v0.602 | | **Branche courante** | `main` | -| **Phase** | Phase 5 Streaming & Communication — v0.503 livrée | -| **Prochaine version** | v0.601 | +| **Phase** | Phase 6+ Payout, Dette Technique & Tests E2E — v0.602 livrée | +| **Prochaine version** | v0.603 | --- @@ -73,6 +73,18 @@ - Infra : MinIO S3-compatible (dev, staging, prod), 6 migrations (103–108) - Sécurité : Trivy container scanning CI +### v0.602 (Phase 6+ Payout, Dette Technique & Tests E2E) +- CLN2 : Split interceptors auth.ts, error.ts, facade < 30 LOC +- P3 : Stripe Connect payout (onboarding, balance, seller_stripe_accounts ; transfer manuel doc pour v0.603) +- INF2 : Grafana dashboards enrichis (p50, top endpoints, 4xx, WS connections, messages/s, orders, refunds, payout) +- QA2 : E2E commerce backend (product -> order -> review -> invoice), SMOKE_TEST_V0602.md + +### v0.601 (Phase 6 — Production Readiness & Commerce) +- INF1 : Blue-green HAProxy, Grafana dashboards (API, Chat, Commerce), Alertmanager, Hyperswitch LIVE_MODE +- AUTH1 : OAuth Discord, OAuth Spotify opérationnels +- CLN1 : handler.go split en 4 sous-handlers, interceptors.ts en modules (utils, request, response) +- QA1 : Tests OAuth, MIGRATIONS.md, audit console.log + ### v0.503 (Phase 5 — HLS E2E + Chat Hardening + Cleanup) - SS1 : HLS Streaming E2E (backend serving routes, frontend ABR player) - CH1 : Redis rate limiter (sliding window + in-memory fallback), présence persistante Redis (2min TTL), PostgreSQL full-text search (tsvector + GIN index) @@ -102,13 +114,8 @@ - QA1 : Tests, documentation - Référence : [V0_503_RELEASE_SCOPE.md](V0_503_RELEASE_SCOPE.md) -### Prochaine version (v0.601) -- **INF1** : Blue-green deployment, Grafana dashboards, health check enrichi, graceful shutdown -- **COM1** : Reviews produits, factures PDF, remboursements, Hyperswitch production -- **AUTH1** : OAuth Discord & Spotify -- **CLN1** : Découpage handler.go (track), interceptors.ts, consolidation migrations -- **QA1** : Tests E2E, smoke test, documentation -- Référence : [V0_601_RELEASE_SCOPE.md](V0_601_RELEASE_SCOPE.md), [PLAN_V0_601_IMPLEMENTATION.md](PLAN_V0_601_IMPLEMENTATION.md) +### Prochaine version (v0.603) +- À définir — Référence : [V0_603_RELEASE_SCOPE.md](V0_603_RELEASE_SCOPE.md) (placeholder) --- @@ -168,8 +175,10 @@ | [V0_501_RELEASE_SCOPE.md](archive/V0_501_RELEASE_SCOPE.md) | Scope v0.501 (Streaming & Cloud, archivé) | | [V0_502_RELEASE_SCOPE.md](archive/V0_502_RELEASE_SCOPE.md) | Scope v0.502 (Chat Server Rewrite, archivé) | | [V0_503_RELEASE_SCOPE.md](archive/V0_503_RELEASE_SCOPE.md) | Scope v0.503 (archivé) | -| [V0_601_RELEASE_SCOPE.md](V0_601_RELEASE_SCOPE.md) | Scope v0.601 (en planification) | +| [V0_601_RELEASE_SCOPE.md](archive/V0_601_RELEASE_SCOPE.md) | Scope v0.601 (archivé) | +| [V0_602_RELEASE_SCOPE.md](archive/V0_602_RELEASE_SCOPE.md) | Scope v0.602 (archivé) | | [PLAN_V0_601_IMPLEMENTATION.md](PLAN_V0_601_IMPLEMENTATION.md) | Plan d'implémentation v0.601 | +| [PLAN_V0_602_IMPLEMENTATION.md](PLAN_V0_602_IMPLEMENTATION.md) | Plan d'implémentation v0.602 | | [CHAT_FEATURE_PARITY.md](CHAT_FEATURE_PARITY.md) | Feature parity Rust vs Go (25/25 OK) | | [V0_301_RELEASE_SCOPE.md](V0_301_RELEASE_SCOPE.md) | Scope détaillé v0.301 (Phase 3 Social) | | [V0_203_RELEASE_SCOPE.md](V0_203_RELEASE_SCOPE.md) | Scope v0.203 (archivé) | diff --git a/docs/RETROSPECTIVE_V0601.md b/docs/RETROSPECTIVE_V0601.md new file mode 100644 index 000000000..67c30a00f --- /dev/null +++ b/docs/RETROSPECTIVE_V0601.md @@ -0,0 +1,19 @@ +# Rétrospective v0.601 — Production Readiness & Commerce + +## Ce qui a bien fonctionné + +- **Découpage handler.go** : 4 sous-handlers (crud, social, search, analytics) avec facade < 200 LOC +- **Interceptors split** : modules utils, request, response extraits ; error handler reste dans interceptors.ts +- **OAuth tests** : GetAuthURL, GetUserInfo, GetAvailableProviders couverts avec mocks HTTP +- **Infrastructure** : Blue-green HAProxy, Grafana, Alertmanager, Hyperswitch LIVE_MODE en place + +## Points d’attention + +- **Interceptors** : auth.ts et error.ts non extraits ; error handler ~600 LOC encore dans interceptors.ts +- **Squash migrations** : script écrit maintenant dans baseline_v0601.sql (avant : stdout uniquement) + +## Prochaines étapes (v0.602) + +- Finaliser split interceptors (auth, error) +- Consolider dashboards Grafana avec métriques réelles +- Tests E2E commerce diff --git a/docs/RETROSPECTIVE_V0602.md b/docs/RETROSPECTIVE_V0602.md new file mode 100644 index 000000000..fa71eea58 --- /dev/null +++ b/docs/RETROSPECTIVE_V0602.md @@ -0,0 +1,20 @@ +# Rétrospective v0.602 — Payout, Dette Technique & Tests E2E + +## Ce qui a bien fonctionné + +- **Split interceptors** : auth.ts et error.ts extraits, interceptors.ts réduit à facade (< 30 LOC) +- **Stripe Connect** : onboarding, balance, SellerDashboardView avec carte Payout et bouton « Configurer les paiements » +- **Grafana** : dashboards enrichis avec métriques réelles (p50, top endpoints, 4xx, WS connections, messages/s, orders, refunds, payout) +- **E2E commerce** : flow product -> order -> review -> invoice validé en backend (TestMarketplaceFlow_CompleteFlowWithReviewAndInvoice) +- **Métriques Prometheus** : CommerceOrdersTotal, CommerceCheckoutDuration ajoutés + +## Points d’attention + +- **Transfer après vente** : reporté en v0.603 ; procédure manuelle documentée dans PAYOUT_MANUAL.md +- **Tests marketplace** : schéma SQLite in-memory à maintenir aligné avec migrations PostgreSQL (product_licenses, product_images, orders, licenses) +- **Sanitizer** : correction regex object/embed (Go n’a pas de backreferences \1) + +## Prochaines étapes (v0.603) + +- Implémenter transfer automatique Stripe Connect après vente (ou valider procédure manuelle) +- Prioriser les lots selon V0_603_RELEASE_SCOPE.md diff --git a/docs/SCOPE_CONTROL.md b/docs/SCOPE_CONTROL.md index 97aeafe49..dadfe06d6 100644 --- a/docs/SCOPE_CONTROL.md +++ b/docs/SCOPE_CONTROL.md @@ -1,23 +1,23 @@ # Contrôle du scope — Anti-scope-creep **Objectif** : Éviter toute dérive de scope. Chaque modification doit être intentionnelle et traçable. -**Référence active** : [V0_601_RELEASE_SCOPE.md](V0_601_RELEASE_SCOPE.md) -**Version précédente** : [V0_503_RELEASE_SCOPE.md](archive/V0_503_RELEASE_SCOPE.md) +**Référence active** : [V0_603_RELEASE_SCOPE.md](V0_603_RELEASE_SCOPE.md) +**Version précédente** : [V0_602_RELEASE_SCOPE.md](archive/V0_602_RELEASE_SCOPE.md) --- ## 1. Règle d'or -> **Avant d'ajouter quoi que ce soit : vérifier si c'est dans le scope v0.601.** +> **Avant d'ajouter quoi que ce soit : vérifier si c'est dans le scope v0.603.** > Si non → ne pas ajouter. Créer un ticket pour une version ultérieure. --- -## 2. Pendant la phase v0.601 (jusqu'au tag) +## 2. Pendant la phase v0.603 (jusqu'au tag) ### 2.1 Autorisé -- **Corrections de bugs** sur les features IN SCOPE v0.601 +- **Corrections de bugs** sur les features IN SCOPE v0.603 - **Stabilisation** : tests, refactoring sans changement de comportement - **Nettoyage** : suppression de code mort, consolidation - **Documentation** : mise à jour des docs existantes @@ -26,17 +26,17 @@ ### 2.2 Interdit -- **Nouvelles features** hors scope v0.601 +- **Nouvelles features** hors scope v0.603 - **Nouvelles routes** ou pages hors scope - **Nouvelles dépendances** (sauf correctif sécurité) - **Changements de comportement** sur les features HORS SCOPE -- **"Améliorations"** non liées à un bug identifié ou une feature IN SCOPE v0.601 +- **"Améliorations"** non liées à un bug identifié ou une feature IN SCOPE v0.603 ### 2.3 Cas limite | Situation | Action | |-----------|--------| -| Bug dans une feature HORS SCOPE | Corriger si blocant pour une feature IN SCOPE v0.601. Sinon : ticket pour plus tard. | +| Bug dans une feature HORS SCOPE | Corriger si blocant pour une feature IN SCOPE v0.603. Sinon : ticket pour plus tard. | | Dépendance obsolète/vulnérable | Mettre à jour. Documenter dans la PR. | | Refactoring qui change une API interne | Autorisé si 0 impact sur le contrat public et tests passent. | | "Petite amélioration UX" | **Non.** Créer un ticket pour v0.502+. | @@ -47,13 +47,13 @@ ### 3.1 Checklist pré-commit (dans la tête) -1. **Mon changement modifie-t-il une feature IN SCOPE v0.601 ?** +1. **Mon changement modifie-t-il une feature IN SCOPE v0.603 ?** - Oui → Continuer. S'assurer qu'il n'y a pas de régression. - Non → **STOP.** Est-ce une correction de bug ? Si oui, la feature est-elle IN SCOPE ? 2. **Mon changement ajoute-t-il du code ?** - - Nouvelle route, nouveau composant, nouveau service → Vérifier V0_601_RELEASE_SCOPE. Si hors scope → **STOP.** - - Correction, refactoring, test → OK si lié à une feature IN SCOPE v0.601. + - Nouvelle route, nouveau composant, nouveau service → Vérifier V0_603_RELEASE_SCOPE. Si hors scope → **STOP.** + - Correction, refactoring, test → OK si lié à une feature IN SCOPE v0.603. 3. **Mes tests passent-ils ?** - `npm test -- --run` (frontend) @@ -81,7 +81,7 @@ Format : `type(scope): description` Dans chaque PR, le relecteur doit valider : -- [ ] Le changement est dans le scope v0.601 (voir [V0_601_RELEASE_SCOPE.md](V0_601_RELEASE_SCOPE.md)) +- [ ] Le changement est dans le scope v0.603 (voir [V0_603_RELEASE_SCOPE.md](V0_603_RELEASE_SCOPE.md)) - [ ] Aucune nouvelle feature ajoutée - [ ] Aucune régression sur les flows critiques - [ ] Les tests passent @@ -92,19 +92,19 @@ Dans chaque PR, le relecteur doit valider : Une PR sera rejetée si : - Elle ajoute une nouvelle route, page ou feature -- Elle modifie le comportement d'une feature HORS SCOPE v0.601 (sauf correctif bug critique) +- Elle modifie le comportement d'une feature HORS SCOPE v0.603 (sauf correctif bug critique) - Les tests échouent - Elle introduit une dépendance non justifiée --- -## 5. Proposer une feature pour APRÈS v0.601 +## 5. Proposer une feature pour APRÈS v0.603 ### 5.1 Template Utiliser le template [Feature request](.github/ISSUE_TEMPLATE/feature_request.md) avec : -- **Alignement scope** : cocher "Hors scope v0.601 — pour v0.7+" +- **Alignement scope** : cocher "Hors scope v0.603 — pour v0.7+" - **Justification** : pourquoi cette feature est nécessaire - **Effort estimé** : S / M / L / XL - **Dépendances** : quelles features v0.601 doivent être stables avant @@ -112,8 +112,8 @@ Utiliser le template [Feature request](.github/ISSUE_TEMPLATE/feature_request.md ### 5.2 Workflow 1. Créer une issue avec le template -2. **Ne pas implémenter** tant que v0.601 n'est pas taguée -3. Une fois v0.601 stable, prioriser les issues post-v0.601 dans le scope suivant +2. **Ne pas implémenter** tant que v0.603 n'est pas taguée +3. Une fois v0.603 stable, prioriser les issues post-v0.603 dans le scope suivant --- @@ -132,7 +132,7 @@ Si une vulnérabilité critique est identifiée : Si un bug bloque un déploiement ou un flow critique : - Correctif autorisé -- La feature concernée doit être IN SCOPE v0.502 ou dépendance directe d'une feature IN SCOPE +- La feature concernée doit être IN SCOPE v0.603 ou dépendance directe d'une feature IN SCOPE ### 6.3 Décision collégiale @@ -168,13 +168,15 @@ Pour tout cas ambigu : - v0.404 : Phase 4bis — Stabilisation post-audit (sécurité, infra, nettoyage) — taguée - v0.501 : Phase 5 — Streaming & Cloud (HLS production, Cloud storage MVP, Gear avancé) — taguée - v0.502 : Phase 5 — Chat Server Rewrite (Rust → Go) — taguée -- v0.503 : Phase 5 — Stream Server E2E + Chat Hardening + Cleanup — livrée -- v0.601 : Phase 6 — en planification +- v0.503 : Phase 5 — Stream Server E2E + Chat Hardening + Cleanup — taguée +- v0.601 : Phase 6 — Production Readiness & Commerce — taguée +- v0.602 : Phase 6+ — Payout, Dette Technique & Tests E2E — taguée +- v0.603 : Phase 6+ — en planification --- ## 8. Rappel pour les contributeurs - **Cursor / IA** : Les règles dans `.cursorrules` rappellent de vérifier le scope avant toute modification. -- **Humains** : Lire [V0_601_RELEASE_SCOPE.md](V0_601_RELEASE_SCOPE.md) avant de coder. +- **Humains** : Lire [V0_603_RELEASE_SCOPE.md](V0_603_RELEASE_SCOPE.md) avant de coder. - **En doute ?** Ouvrir une issue "Scope clarification" plutôt que de coder. diff --git a/docs/SMOKE_TEST_V0601.md b/docs/SMOKE_TEST_V0601.md new file mode 100644 index 000000000..372279736 --- /dev/null +++ b/docs/SMOKE_TEST_V0601.md @@ -0,0 +1,40 @@ +# Smoke Test v0.601 — Production Readiness & Commerce + +## Prérequis + +- Docker Compose (prod ou staging) +- Backend API, frontend, Redis, PostgreSQL, RabbitMQ démarrés + +## Checklist + +### Infrastructure + +- [ ] Blue-green: `scripts/deploy-blue-green.sh` bascule correctement +- [ ] HAProxy stats: `http://localhost:8404/stats` accessible +- [ ] Grafana: dashboards api-overview, chat-overview, commerce-overview chargent +- [ ] Alertmanager: `http://localhost:9093` accessible +- [ ] Prometheus: targets backend-api scrappés + +### OAuth + +- [ ] Login page: boutons Discord et Spotify visibles si configurés +- [ ] OAuth Discord: flow complet (redirect, callback, session) +- [ ] OAuth Spotify: flow complet (redirect, callback, session) + +### Commerce + +- [ ] HYPERSWITCH_LIVE_MODE: en prod, warning si false +- [ ] Checkout: flow Hyperswitch fonctionne +- [ ] Codes promo: validation et application + +### Backend + +- [ ] `go build ./...` OK +- [ ] `go test ./internal/core/track/...` OK +- [ ] `go test ./internal/services/... -run OAuth` OK + +### Frontend + +- [ ] `npm run build` OK +- [ ] API requests: interceptors (request/response) fonctionnent +- [ ] Pas d’erreurs console en prod diff --git a/docs/SMOKE_TEST_V0602.md b/docs/SMOKE_TEST_V0602.md new file mode 100644 index 000000000..80f524f08 --- /dev/null +++ b/docs/SMOKE_TEST_V0602.md @@ -0,0 +1,45 @@ +# Smoke Test v0.602 — Payout, Dette Technique & Tests E2E + +## Prérequis + +- Docker Compose lancé, tous services up +- Backend API, frontend, Redis, PostgreSQL démarrés + +## Checklist + +### Interceptors (CLN2) + +- [ ] auth.ts extrait (< 200 LOC) +- [ ] error.ts extrait (< 400 LOC) +- [ ] interceptors.ts facade (< 30 LOC) +- [ ] Build frontend OK, aucune régression + +### Payout (P3) + +- [ ] POST /sell/connect/onboard retourne onboarding_url +- [ ] GET /sell/balance retourne available/pending (ou connected) +- [ ] SellerDashboard affiche balance ou bouton onboarding +- [ ] Transfer après vente (ou documenté pour v0.603) + +### Grafana (INF2) + +- [ ] api-overview : panels request rate, p50/p95, 4xx/5xx, top endpoints +- [ ] chat-overview : WS connections, messages/s +- [ ] commerce-overview : orders, refunds, payout + +### Commerce E2E (QA2) + +- [ ] Flow backend : product -> order -> review -> invoice +- [ ] Frontend purchase.spec.ts passe +- [ ] OAuth Discord login OK +- [ ] OAuth Spotify login OK + +### Backend + +- [ ] `go build ./...` OK +- [ ] `go test -tags marketplace ./tests/marketplace/...` OK + +### Frontend + +- [ ] `npm run build` OK +- [ ] `npm run test:storybook` OK (depuis apps/web) diff --git a/docs/V0_603_RELEASE_SCOPE.md b/docs/V0_603_RELEASE_SCOPE.md new file mode 100644 index 000000000..0ada8c9d7 --- /dev/null +++ b/docs/V0_603_RELEASE_SCOPE.md @@ -0,0 +1,30 @@ +# V0.603 Release Scope — Placeholder + +**Statut** : En planification +**Phase** : 6+ +**Prérequis** : v0.602 (taguée) +**Date cible** : TBD +**Précédente** : [v0.602](archive/V0_602_RELEASE_SCOPE.md) + +--- + +## 1. Objectif + +À définir. Prochaines priorités possibles : + +- Transfer automatique après vente (Stripe Connect) — documenté dans PAYOUT_MANUAL.md +- Autres lots à prioriser selon rétrospective v0.602 + +--- + +## 2. Lots (à définir) + +_Placeholder — le scope sera défini après la rétrospective v0.602._ + +--- + +## 3. Références + +- [RETROSPECTIVE_V0602.md](RETROSPECTIVE_V0602.md) +- [PAYOUT_MANUAL.md](PAYOUT_MANUAL.md) +- [SCOPE_CONTROL.md](SCOPE_CONTROL.md) diff --git a/docs/V0_601_RELEASE_SCOPE.md b/docs/archive/V0_601_RELEASE_SCOPE.md similarity index 100% rename from docs/V0_601_RELEASE_SCOPE.md rename to docs/archive/V0_601_RELEASE_SCOPE.md diff --git a/docs/archive/V0_602_RELEASE_SCOPE.md b/docs/archive/V0_602_RELEASE_SCOPE.md new file mode 100644 index 000000000..fa35198de --- /dev/null +++ b/docs/archive/V0_602_RELEASE_SCOPE.md @@ -0,0 +1,153 @@ +# V0.602 Release Scope — Payout, Dette Technique & Tests E2E + +**Statut** : Livré (taguée v0.602) +**Phase** : 6+ +**Prérequis** : v0.601 (taguée) +**Date cible** : TBD +**Estimation** : ~5 sprints (25 jours ouvrés) +**Précédente** : [v0.601](V0_601_RELEASE_SCOPE.md) + +--- + +## 1. Objectif + +Finaliser la boucle commerce avec le **payout vendeurs** (Stripe Connect), réduire la dette technique restante (split interceptors auth/error), consolider les dashboards Grafana avec métriques réelles, et valider le flux commerce par des **tests E2E**. Optionnel : migration React 19 si faisable sans risque. + +--- + +## 2. État actuel (post-v0.601) + +| Composant | État | Détail | +|-----------|------|--------| +| **Payout** | ❌ Non implémenté | Stripe Connect reporté depuis v0.601 | +| **Interceptors** | ⚠️ Partiel | auth.ts et error.ts non extraits ; error handler ~600 LOC dans interceptors.ts | +| **Grafana** | ⚠️ Placeholders | Dashboards créés mais métriques réelles à connecter | +| **Tests E2E commerce** | ❌ Absent | Pas de flow upload → achat → review → facture → remboursement | +| **React** | 18.x | Migration React 19 optionnelle | + +--- + +## 3. Lots + +### Lot P3 — Payout vendeurs (Stripe Connect) + +**Objectif** : Permettre aux vendeurs de recevoir les revenus des ventes. + +| # | Tâche | Fichiers impactés | Effort | +|---|-------|--------------------|--------| +| P3-01 | Config Stripe Connect — client_id, secret, webhook secret | `internal/config/config.go`, `.env.example` | S | +| P3-02 | Migration seller_stripe_accounts — lien user ↔ Stripe account | `migrations/114_seller_stripe_accounts.sql` | S | +| P3-03 | POST /sell/connect/onboard — redirect Stripe Express onboarding | `internal/handlers/sell_handler.go`, routes | M | +| P3-04 | GET /sell/balance — balance disponible, en attente (depuis Stripe ou table) | `internal/services/sell_service.go`, handlers | M | +| P3-05 | Transfert après vente — webhook ou post-order : transfer vers compte Connect | `internal/services/hyperswitch/`, webhooks | M | +| P3-06 | SellerDashboardView — carte balance, bouton « Configurer les paiements » | `apps/web/src/features/seller/`, `marketplaceService.ts` | M | +| P3-07 | MSW + Stories — handlers balance, onboarding | `apps/web/src/mocks/`, stories | S | + +### Lot CLN2 — Dette technique (split interceptors) + +**Objectif** : Finaliser le découpage des interceptors (auth, error) identifié en rétro v0.601. + +| # | Tâche | Fichiers impactés | Effort | +|---|-------|--------------------|--------| +| CLN2-01 | Extraire auth interceptor — token refresh, queue, processQueue | `interceptors/auth.ts`, `interceptors.ts` | M | +| CLN2-02 | Extraire error interceptor — gestion erreurs, retry, toast, parseApiError | `interceptors/error.ts`, `interceptors.ts` | M | +| CLN2-03 | Réduire interceptors.ts à facade — composition des 5 modules | `interceptors.ts`, `interceptors/index.ts` | S | +| CLN2-04 | Validation — chaque fichier < 400 LOC, tests passent | — | S | + +### Lot INF2 — Dashboards Grafana (métriques réelles) + +**Objectif** : Connecter les dashboards aux métriques Prometheus réelles du backend. + +| # | Tâche | Fichiers impactés | Effort | +|---|-------|--------------------|--------| +| INF2-01 | API dashboard — panels request_rate, latency p50/p95, error_rate, top endpoints | `config/grafana/dashboards/api-overview.json` | M | +| INF2-02 | Chat dashboard — connexions WS actives, messages/s, présence | `config/grafana/dashboards/chat-overview.json` | S | +| INF2-03 | Commerce dashboard — orders/min, checkout success, refunds, promo usage | `config/grafana/dashboards/commerce-overview.json` | S | +| INF2-04 | Vérifier exposition métriques backend — middleware Prometheus, labels | `veza-backend-api/internal/middleware/` | S | + +### Lot QA2 — Tests E2E commerce + +**Objectif** : Valider le flux commerce complet de bout en bout. + +| # | Tâche | Fichiers impactés | Effort | +|---|-------|--------------------|--------| +| QA2-01 | E2E commerce — upload produit → achat → review → facture → remboursement (optionnel) | `veza-backend-api/internal/integration/` ou Playwright | M | +| QA2-02 | Smoke test v0.602 — checklist 30+ features E2E incluant payout | `docs/SMOKE_TEST_V0602.md` | S | +| QA2-03 | Mise à jour PROJECT_STATE, FEATURE_STATUS, CHANGELOG | `docs/` | S | +| QA2-04 | Archiver V0_602_RELEASE_SCOPE, placeholder V0_603, rétrospective, tag | `docs/archive/`, Git | S | + +### Lot OPT — Optionnel (si temps disponible) + +| # | Tâche | Fichiers impactés | Effort | +|---|-------|--------------------|--------| +| OPT-01 | Migration React 19 — upgrade, vérifier compatibilité | `apps/web/package.json`, tests | M | + +--- + +## 4. Hors scope v0.602 + +| Élément | Version cible | +|---------|---------------| +| Go Live (streaming vidéo) | v0.703 | +| 2FA SMS / Passkeys | v0.104 | +| IaC (Terraform/Pulumi) | v0.801 | +| Payout manuel (sans Stripe Connect) | v0.603 si P3 trop complexe | + +--- + +## 5. Fichiers impactés (récapitulatif) + +### Backend Go (nouveau) + +| Fichier | Action | +|---------|--------| +| `migrations/114_seller_stripe_accounts.sql` | Nouveau — lien vendeur ↔ Stripe | +| `internal/services/stripe_connect_service.go` | Nouveau — onboarding, balance, transferts | + +### Backend Go (modifier) + +| Fichier | Action | +|---------|--------| +| `internal/config/config.go` | Stripe Connect config | +| `internal/handlers/sell_handler.go` | Onboard, balance | +| `internal/services/hyperswitch/` | Transfer après vente | +| `internal/middleware/` | Métriques Prometheus (si manquantes) | + +### Frontend (nouveau/modifier) + +| Fichier | Action | +|---------|--------| +| `apps/web/src/services/api/interceptors/auth.ts` | Nouveau — extrait | +| `apps/web/src/services/api/interceptors/error.ts` | Nouveau — extrait | +| `apps/web/src/services/api/interceptors.ts` | Réduire à facade | +| `apps/web/src/features/seller/SellerDashboardView.tsx` | Balance, bouton onboarding | +| `apps/web/src/mocks/` | Handlers payout | + +### Infra + +| Fichier | Action | +|---------|--------| +| `config/grafana/dashboards/*.json` | Panels métriques réelles | + +--- + +## 6. Critères d'acceptation globaux + +- [x] Payout : vendeur peut s'onboarder Stripe Connect, voir sa balance +- [x] Transfert après vente documenté (PAYOUT_MANUAL.md pour v0.603) +- [x] interceptors : auth.ts et error.ts extraits, chaque fichier < 400 LOC +- [x] Dashboards Grafana alimentés par métriques réelles +- [x] Tests E2E commerce : flow product → order → review → invoice +- [x] Smoke test v0.602 documenté +- [x] Tag v0.602 créé + +--- + +## 7. Risques + +| Risque | Mitigation | +|--------|------------| +| Stripe Connect complexité / quotas | MVP = balance affichée sans transfert auto ; documenter limites | +| Hyperswitch vs Stripe Connect | Vérifier compatibilité ; Hyperswitch peut avoir Connect-like | +| Métriques backend non exposées | Audit middleware Prometheus, ajouter si manquant | +| React 19 breaking changes | Lot optionnel ; reporter si régression | diff --git a/go.work.sum b/go.work.sum index 8cfd49d21..0fec9b9d4 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,100 +1,203 @@ +github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 h1:59MxjQVfjXsBpLy+dbd2/ELV5ofnUkUZBvWSC85sheA= github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0/go.mod h1:OahwfttHWG6eJ0clwcfBAHoDI6X/LV/15hx/wlMZSrU= +github.com/Microsoft/hcsshim v0.11.5 h1:haEcLNpj9Ka1gd3B3tAEs9CpE0c+1IhoL59w/exYU38= github.com/Microsoft/hcsshim v0.11.5/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA09d4bExKcU= +github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/cilium/ebpf v0.9.1 h1:64sn2K3UKw8NbP/blsixRpF3nXuyhz/VjRlRzvlBRu4= github.com/cilium/ebpf v0.9.1/go.mod h1:+OhNOIXx/Fnu1IE8bJz2dzOA+VSfyTfdNUVdlQnxUFY= +github.com/containerd/aufs v1.0.0 h1:2oeJiwX5HstO7shSrPZjrohJZLzK36wvpdmzDRkL/LY= github.com/containerd/aufs v1.0.0/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU= +github.com/containerd/btrfs/v2 v2.0.0 h1:FN4wsx7KQrYoLXN7uLP0vBV4oVWHOIKDRQ1G2Z0oL5M= github.com/containerd/btrfs/v2 v2.0.0/go.mod h1:swkD/7j9HApWpzl8OHfrHNxppPd9l44DFZdF94BUj9k= +github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM= github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw= +github.com/containerd/cgroups/v3 v3.0.2 h1:f5WFqIVSgo5IZmtTT3qVBo6TzI1ON6sycSBKkymb9L0= github.com/containerd/cgroups/v3 v3.0.2/go.mod h1:JUgITrzdFqp42uI2ryGA+ge0ap/nxzYgkGmIcetmErE= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/containerd/continuity v0.4.2 h1:v3y/4Yz5jwnvqPKJJ+7Wf93fyWoCB3F5EclWG023MDM= github.com/containerd/continuity v0.4.2/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= +github.com/containerd/errdefs v0.1.0 h1:m0wCRBiu1WJT/Fr+iOoQHMQS/eP5myQ8lCv4Dz5ZURM= github.com/containerd/errdefs v0.1.0/go.mod h1:YgWiiHtLmSeBrvpw+UfPijzbLaB77mEG1WwJTDETIV0= +github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY= github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o= +github.com/containerd/go-cni v1.1.9 h1:ORi7P1dYzCwVM6XPN4n3CbkuOx/NZ2DOqy+SHRdo9rU= github.com/containerd/go-cni v1.1.9/go.mod h1:XYrZJ1d5W6E2VOvjffL3IZq0Dz6bsVlERHbekNK90PM= +github.com/containerd/go-runc v1.0.0 h1:oU+lLv1ULm5taqgV/CJivypVODI4SUz1znWjv3nNYS0= github.com/containerd/go-runc v1.0.0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok= +github.com/containerd/imgcrypt v1.1.8 h1:ZS7TuywcRNLoHpU0g+v4/PsKynl6TYlw5xDVWWoIyFA= github.com/containerd/imgcrypt v1.1.8/go.mod h1:x6QvFIkMyO2qGIY2zXc88ivEzcbgvLdWjoZyGqDap5U= +github.com/containerd/nri v0.6.1 h1:xSQ6elnQ4Ynidm9u49ARK9wRKHs80HCUI+bkXOxV4mA= github.com/containerd/nri v0.6.1/go.mod h1:7+sX3wNx+LR7RzhjnJiUkFDhn18P5Bg/0VnJ/uXpRJM= +github.com/containerd/ttrpc v1.2.4 h1:eQCQK4h9dxDmpOb9QOOMh2NHTfzroH1IkmHiKZi05Oo= github.com/containerd/ttrpc v1.2.4/go.mod h1:ojvb8SJBSch0XkqNO0L0YX/5NxR3UnVk2LzFKBK0upc= +github.com/containerd/typeurl v1.0.2 h1:Chlt8zIieDbzQFzXzAeBEF92KhExuE4p9p92/QmY7aY= github.com/containerd/typeurl v1.0.2/go.mod h1:9trJWW2sRlGub4wZJRTW83VtbOLS6hwcDZXTn6oPz9s= +github.com/containerd/typeurl/v2 v2.1.1 h1:3Q4Pt7i8nYwy2KmQWIw2+1hTvwTE/6w9FqcttATPO/4= github.com/containerd/typeurl/v2 v2.1.1/go.mod h1:IDp2JFvbwZ31H8dQbEIY7sDl2L3o3HZj1hsSQlywkQ0= +github.com/containerd/zfs v1.1.0 h1:n7OZ7jZumLIqNJqXrEc/paBM840mORnmGdJDmAmJZHM= github.com/containerd/zfs v1.1.0/go.mod h1:oZF9wBnrnQjpWLaPKEinrx3TQ9a+W/RJO7Zb41d8YLE= +github.com/containernetworking/cni v1.1.2 h1:wtRGZVv7olUHMOqouPpn3cXJWpJgM6+EUl31EQbXALQ= github.com/containernetworking/cni v1.1.2/go.mod h1:sDpYKmGVENF3s6uvMvGgldDWeG8dMxakj/u+i9ht9vw= +github.com/containernetworking/plugins v1.2.0 h1:SWgg3dQG1yzUo4d9iD8cwSVh1VqI+bP7mkPDoSfP9VU= github.com/containernetworking/plugins v1.2.0/go.mod h1:/VjX4uHecW5vVimFa1wkG4s+r/s9qIfPdqlLF4TW8c4= +github.com/containers/ocicrypt v1.1.10 h1:r7UR6o8+lyhkEywetubUUgcKFjOWOaWz8cEBrCPX0ic= github.com/containers/ocicrypt v1.1.10/go.mod h1:YfzSSr06PTHQwSTUKqDSjish9BeW1E4HUmreluQcMd8= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131 h1:siEGb+iB1Ea75U7BnkYVSqSRzE6QHlXCbqEXenxRmhQ= github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131/go.mod h1:eVWQJVQ67aMvYhpkDwaH2Goy2vo6v8JCMfGXfQ9sPtw= +github.com/dhowden/plist v0.0.0-20141002110153-5db6e0d9931a h1:7MucP9rMAsQRcRE1sGpvMZoTxFYZlDmfDvCH+z7H+90= github.com/dhowden/plist v0.0.0-20141002110153-5db6e0d9931a/go.mod h1:sLjdR6uwx3L6/Py8F+QgAfeiuY87xuYGwCDqRFrvCzw= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= +github.com/emicklei/go-restful/v3 v3.10.1 h1:rc42Y5YTp7Am7CS630D7JmhRjq4UlEUuEKfrDac4bSQ= github.com/emicklei/go-restful/v3 v3.10.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= +github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/intel/goresctrl v0.3.0 h1:K2D3GOzihV7xSBedGxONSlaw/un1LZgWsc9IfqipN4c= github.com/intel/goresctrl v0.3.0/go.mod h1:fdz3mD85cmP9sHD8JUlrNWAxvwM86CrbmVXltEKd7zk= +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kisielk/errcheck v1.5.0 h1:e8esj/e4R+SAOwFwN+n3zr0nYeCyeweozKfO23MvHzY= +github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46 h1:veS9QfglfvqAw2e+eeNT/SbGySq8ajECXJ9e4fPoLhY= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g= github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM= +github.com/mistifyio/go-zfs/v3 v3.0.1 h1:YaoXgBePoMA12+S1u/ddkv+QqxcfiZK4prI6HPnkFiU= github.com/mistifyio/go-zfs/v3 v3.0.1/go.mod h1:CzVgeB0RvF2EGzQnytKVvVSDwmKJXxkOTUGbNrTja/k= +github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc= +github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8= github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c= +github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= +github.com/moby/sys/signal v0.7.0 h1:25RW3d5TnQEoKvRbEKUGay6DCQ46IxAVTT9CUMgmsSI= github.com/moby/sys/signal v0.7.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg= +github.com/moby/sys/symlink v0.2.0 h1:tk1rOM+Ljp0nFmfOIBtlV3rTDlWOwFRhjEeAhZB0nZc= github.com/moby/sys/symlink v0.2.0/go.mod h1:7uZVF2dqJjG/NsClqul95CqKOBRQyYSNnJ6BMgR/gFs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/opencontainers/runtime-spec v1.1.0 h1:HHUyrt9mwHUjtasSbXSMvs4cyFxh+Bll4AjJ9odEGpg= github.com/opencontainers/runtime-spec v1.1.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626 h1:DmNGcqH3WDbV5k8OJ+esPWbqUOX5rMLR2PMvziDMJi0= github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626/go.mod h1:BRHJJd0E+cx42OybVYSgUvZmU0B8P9gZuRXlZUP7TKI= +github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaLpt7tQ7oU= github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/phpdave11/gofpdi v1.0.13 h1:o61duiW8M9sMlkVXWlvP92sZJtGKENvW3VExs6dZukQ= github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245 h1:K1Xf3bKttbF+koVGaX5xngRIZ5bVjbmPnaxE/dR08uY= github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 h1:pnnLyeX7o/5aX8qUQ69P/mLojDqwda8hFOCBTmP/6hw= github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h1:39R/xuhNgVhi+K0/zst4TLrJrVmbm6LVgl4A0+ZFS5M= +github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI= github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes= github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/urfave/cli v1.22.12 h1:igJgVw1JdKH+trcLWLeLwZjU9fEfPesQ+9/e4MQ44S8= github.com/urfave/cli v1.22.12/go.mod h1:sSBEIC79qR6OvcmsD4U3KABeOTxDqQtdDnaFuUN30b8= +github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= +github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f h1:p4VB7kIXpOQvVn1ZaTIVp+3vuYAXFe3OJEvjbUYJLaA= github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= +go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ= go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw= +go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 h1:A/5uWzF44DlIgdm/PQFwfMkW0JX+cIcQi/SwLAmZP5M= go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.45.0 h1:RsQi0qJ2imFfCvZabqzM9cNXBG8k6gXMv1A0cXRmH6A= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.45.0/go.mod h1:vsh3ySueQCiKPxFLvjWC4Z135gIa34TQ/NSqkDTZYUM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S281UTjM+AbF31XSOYn1qXn3BgIdWl8HNEpx08Jk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I= golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:CCviP9RmpZ1mxVr8MUjCnSiY09IbAXZxhLE6EhHIdPU= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +k8s.io/api v0.26.2 h1:dM3cinp3PGB6asOySalOZxEG4CZ0IAdJsrYZXE/ovGQ= k8s.io/api v0.26.2/go.mod h1:1kjMQsFE+QHPfskEcVNgL3+Hp88B80uj0QtSOlj8itU= +k8s.io/apimachinery v0.26.2 h1:da1u3D5wfR5u2RpLhE/ZtZS2P7QvDgLZTi9wrNZl/tQ= k8s.io/apimachinery v0.26.2/go.mod h1:ats7nN1LExKHvJ9TmwootT00Yz05MuYqPXEXaVeOy5I= +k8s.io/apiserver v0.26.2 h1:Pk8lmX4G14hYqJd1poHGC08G03nIHVqdJMR0SD3IH3o= k8s.io/apiserver v0.26.2/go.mod h1:GHcozwXgXsPuOJ28EnQ/jXEM9QeG6HT22YxSNmpYNh8= +k8s.io/client-go v0.26.2 h1:s1WkVujHX3kTp4Zn4yGNFK+dlDXy1bAAkIl+cFAiuYI= k8s.io/client-go v0.26.2/go.mod h1:u5EjOuSyBa09yqqyY7m3abZeovO/7D/WehVVlZ2qcqU= +k8s.io/component-base v0.26.2 h1:IfWgCGUDzrD6wLLgXEstJKYZKAFS2kO+rBRi0p3LqcI= k8s.io/component-base v0.26.2/go.mod h1:DxbuIe9M3IZPRxPIzhch2m1eT7uFrSBJUBuVCQEBivs= +k8s.io/cri-api v0.27.1 h1:KWO+U8MfI9drXB/P4oU9VchaWYOlwDglJZVHWMpTT3Q= k8s.io/cri-api v0.27.1/go.mod h1:+Ts/AVYbIo04S86XbTD73UPp/DkTiYxtsFeOFEu32L0= +k8s.io/klog/v2 v2.90.1 h1:m4bYOKall2MmOiRaR1J+We67Do7vm9KiQVlT96lnHUw= k8s.io/klog/v2 v2.90.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0= +k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5 h1:kmDqav+P+/5e1i9tFfHq1qcF3sOrDp+YEkVDAHu7Jwk= k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 h1:iXTIw73aPyC+oRdyqqvVJuloN1p0AC/kzH07hu3NE+k= sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE= sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= +tags.cncf.io/container-device-interface v0.7.2 h1:MLqGnWfOr1wB7m08ieI4YJ3IoLKKozEnnNYBtacDPQU= tags.cncf.io/container-device-interface v0.7.2/go.mod h1:Xb1PvXv2BhfNb3tla4r9JL129ck1Lxv9KuU6eVOfKto= +tags.cncf.io/container-device-interface/specs-go v0.7.0 h1:w/maMGVeLP6TIQJVYT5pbqTi8SCw/iHZ+n4ignuGHqg= tags.cncf.io/container-device-interface/specs-go v0.7.0/go.mod h1:hMAwAbMZyBLdmYqWgYcKH0F/yctNpV3P35f+/088A80= diff --git a/package-lock.json b/package-lock.json index 352634c95..5d1a2d55c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "apps/web", "packages/*", "veza-backend-api", - "veza-chat-server", "veza-stream-server" ], "devDependencies": { @@ -21,7 +20,7 @@ "globals": "^16.5.0", "prettier": "3.6.2", "turbo": "^2.3.0", - "typescript": "^5.9.3", + "typescript": "5.9.3", "typescript-eslint": "^8.46.3" } }, @@ -113,7 +112,7 @@ "swagger-ui-react": "^5.31.0", "tailwindcss": "^4.0.0", "tw-animate-css": "^1.4.0", - "typescript": "^5.3.3", + "typescript": "5.9.3", "vite": "^7.1.5", "vitest": "^3.2.4" } @@ -16803,10 +16802,6 @@ "resolved": "veza-backend-api", "link": true }, - "node_modules/veza-chat-server": { - "resolved": "veza-chat-server", - "link": true - }, "node_modules/veza-frontend": { "resolved": "apps/web", "link": true @@ -17582,7 +17577,9 @@ } }, "veza-backend-api": {}, - "veza-chat-server": {}, + "veza-chat-server": { + "extraneous": true + }, "veza-stream-server": {} } } diff --git a/scripts/squash_migrations.sh b/scripts/squash_migrations.sh index 66d1e66d3..d4e77f0c0 100755 --- a/scripts/squash_migrations.sh +++ b/scripts/squash_migrations.sh @@ -2,9 +2,10 @@ set -euo pipefail MIGRATIONS_DIR="veza-backend-api/migrations" -OUTPUT_FILE="veza-backend-api/migrations/baseline_v0501.sql" +OUTPUT_FILE="veza-backend-api/migrations/baseline_v0601.sql" -echo "-- Baseline SQL generated from migrations 001-108" +{ +echo "-- Baseline SQL generated from migrations 001-113" echo "-- Generated on: $(date -u '+%Y-%m-%d %H:%M:%S UTC')" echo "-- DO NOT EDIT: This file is auto-generated" echo "" @@ -25,3 +26,5 @@ for migration in $(ls "$MIGRATIONS_DIR"/*.sql 2>/dev/null | sort); do done echo "COMMIT;" +} > "$OUTPUT_FILE" +echo "Baseline written to $OUTPUT_FILE" diff --git a/veza-backend-api/go.mod b/veza-backend-api/go.mod index d006001bf..ce320b4cd 100644 --- a/veza-backend-api/go.mod +++ b/veza-backend-api/go.mod @@ -138,6 +138,7 @@ require ( github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/stripe/stripe-go/v82 v82.5.1 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/veza-backend-api/go.sum b/veza-backend-api/go.sum index 51652c34a..c4e83c341 100644 --- a/veza-backend-api/go.sum +++ b/veza-backend-api/go.sum @@ -301,6 +301,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8= +github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8= github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= diff --git a/veza-backend-api/internal/api/routes_marketplace.go b/veza-backend-api/internal/api/routes_marketplace.go index f4eeb7c89..abcaa68b9 100644 --- a/veza-backend-api/internal/api/routes_marketplace.go +++ b/veza-backend-api/internal/api/routes_marketplace.go @@ -96,6 +96,15 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) { sellProtected.GET("/stats/evolution", marketHandler.GetSellStatsEvolution) sellProtected.GET("/stats/top-products", marketHandler.GetSellTopProducts) sellProtected.GET("/sales", marketHandler.GetSellSales) + + var stripeConnectSvc *services.StripeConnectService + if r.config.StripeConnectEnabled && r.config.StripeConnectSecretKey != "" { + stripeConnectSvc = services.NewStripeConnectService(r.db.GormDB, r.config.StripeConnectSecretKey, r.logger) + } + sellHandler := handlers.NewSellHandler(stripeConnectSvc, r.logger) + sellProtected.POST("/connect/onboard", sellHandler.ConnectOnboard) + sellProtected.GET("/connect/callback", sellHandler.ConnectCallback) + sellProtected.GET("/balance", sellHandler.GetBalance) } commerce := router.Group("/commerce") diff --git a/veza-backend-api/internal/core/marketplace/cart.go b/veza-backend-api/internal/core/marketplace/cart.go index cc89b5fa5..b5d283433 100644 --- a/veza-backend-api/internal/core/marketplace/cart.go +++ b/veza-backend-api/internal/core/marketplace/cart.go @@ -7,6 +7,8 @@ import ( "github.com/google/uuid" "go.uber.org/zap" + + "veza-backend-api/internal/monitoring" ) // CartItem represents an item in a user's shopping cart @@ -99,6 +101,9 @@ func (s *Service) RemoveFromCart(ctx context.Context, userID uuid.UUID, itemID u // Cart is only cleared when order is completed immediately (simulated payment). // promoCode is optional (v0.402 P2). func (s *Service) Checkout(ctx context.Context, userID uuid.UUID, promoCode string) (*CreateOrderResponse, error) { + start := time.Now() + defer func() { monitoring.CommerceCheckoutDuration.Observe(time.Since(start).Seconds()) }() + cartItems, err := s.GetCart(ctx, userID) if err != nil { return nil, err diff --git a/veza-backend-api/internal/core/marketplace/service.go b/veza-backend-api/internal/core/marketplace/service.go index 18c7cef6a..584144082 100644 --- a/veza-backend-api/internal/core/marketplace/service.go +++ b/veza-backend-api/internal/core/marketplace/service.go @@ -13,6 +13,7 @@ import ( "gorm.io/gorm" "veza-backend-api/internal/models" + "veza-backend-api/internal/monitoring" ) var ( @@ -506,6 +507,7 @@ func (s *Service) CreateOrder(ctx context.Context, buyerID uuid.UUID, items []Ne return nil, err } + monitoring.CommerceOrdersTotal.WithLabelValues(order.Status).Inc() resp := &CreateOrderResponse{Order: *order, ClientSecret: clientSecret, PaymentID: paymentID} s.logger.Info("Order created successfully", zap.String("order_id", order.ID.String()), zap.Bool("hyperswitch", s.hyperswitchEnabled)) return resp, nil diff --git a/veza-backend-api/internal/core/track/handler.go b/veza-backend-api/internal/core/track/handler.go index e9e58b1b0..4bb130ac7 100644 --- a/veza-backend-api/internal/core/track/handler.go +++ b/veza-backend-api/internal/core/track/handler.go @@ -1,25 +1,15 @@ package track import ( - "context" - "errors" - "fmt" "net/http" - "strconv" - "strings" - "time" "github.com/google/uuid" - "veza-backend-api/internal/common" apperrors "veza-backend-api/internal/errors" "veza-backend-api/internal/handlers" - "veza-backend-api/internal/models" - "veza-backend-api/internal/response" "veza-backend-api/internal/services" "github.com/gin-gonic/gin" - "gorm.io/gorm" ) // TrackHandler gère les opérations sur les tracks @@ -171,1293 +161,3 @@ func (h *TrackHandler) respondWithError(c *gin.Context, httpStatus int, message handlers.RespondWithAppError(c, apperrors.New(errCode, message)) } -// ListTracks gère la liste des tracks avec pagination, filtres et tri -// @Summary List Tracks -// @Description Get a paginated list of tracks with filters -// @Tags Track -// @Accept json -// @Produce json -// @Param page query int false "Page number" default(1) -// @Param limit query int false "Items per page" default(20) -// @Param user_id query string false "Filter by User ID" -// @Param genre query string false "Filter by Genre" -// @Param format query string false "Filter by Format" -// @Param sort_by query string false "Sort field" default(created_at) -// @Param sort_order query string false "Sort order (asc/desc)" default(desc) -// @Success 200 {object} response.APIResponse{data=object{tracks=[]models.Track,pagination=object}} -// @Failure 500 {object} response.APIResponse "Internal Error" -// @Router /tracks [get] -func (h *TrackHandler) ListTracks(c *gin.Context) { - // Récupérer les paramètres de query - page := c.DefaultQuery("page", "1") - limit := c.DefaultQuery("limit", "20") - userIDStr := c.Query("user_id") - genre := c.Query("genre") - format := c.Query("format") - sortBy := c.DefaultQuery("sort_by", "created_at") - sortOrder := c.DefaultQuery("sort_order", "desc") - - // Parser les paramètres — return 400 if out of bounds (no silent normalization) - var pageInt, limitInt int - if _, err := fmt.Sscanf(page, "%d", &pageInt); err != nil || pageInt < 1 { - response.BadRequest(c, "pagination: page must be >= 1 and limit must be between 1 and 100") - return - } - if _, err := fmt.Sscanf(limit, "%d", &limitInt); err != nil || limitInt < 1 || limitInt > 100 { - response.BadRequest(c, "pagination: page must be >= 1 and limit must be between 1 and 100") - return - } - - // Construire les paramètres - params := TrackListParams{ - Page: pageInt, - Limit: limitInt, - SortBy: sortBy, - SortOrder: sortOrder, - } - - // Parser user_id si fourni - if userIDStr != "" { - if uid, err := uuid.Parse(userIDStr); err == nil { - params.UserID = &uid - } - } - - // Parser genre si fourni - if genre != "" { - params.Genre = &genre - } - - // Parser format si fourni - if format != "" { - params.Format = &format - } - - // Appeler le service - tracks, total, err := h.trackService.ListTracks(c.Request.Context(), params) - if err != nil { - response.InternalServerError(c, "failed to list tracks") - return - } - - // INT-007: Standardize pagination format - pagination := handlers.BuildPaginationData(pageInt, limitInt, total) - - // Masquer l'URL de stream pour les utilisateurs non authentifiés - _, exists := c.Get("user_id") - if !exists { - for _, t := range tracks { - t.StreamManifestURL = "" - } - } - - response.Success(c, gin.H{ - "tracks": tracks, - "pagination": pagination, - }) -} - -// GetRecommendations returns personalized track recommendations (D2 autoplay) -func (h *TrackHandler) GetRecommendations(c *gin.Context) { - if h.trackRecommendationService == nil { - response.InternalServerError(c, "recommendations unavailable") - return - } - var userID uuid.UUID - if uid, exists := c.Get("user_id"); exists { - if parsed, ok := uid.(uuid.UUID); ok { - userID = parsed - } - } - limitStr := c.DefaultQuery("limit", "20") - var limit int - if _, err := fmt.Sscanf(limitStr, "%d", &limit); err != nil || limit < 1 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - params := services.TrackRecommendationParams{ - UserID: userID, - Limit: limit, - } - if seedStr := c.Query("seed_track_id"); seedStr != "" { - if sid, err := uuid.Parse(seedStr); err == nil { - params.SeedTrackID = &sid - } - } - recs, err := h.trackRecommendationService.GetRecommendations(c.Request.Context(), params) - if err != nil { - response.InternalServerError(c, "failed to get recommendations") - return - } - tracks := make([]*models.Track, 0, len(recs)) - for _, r := range recs { - if r.Track != nil { - tracks = append(tracks, r.Track) - } - } - response.Success(c, gin.H{"tracks": tracks}) -} - -// tagSuggestionsByGenre holds static tag suggestions per genre (E4) -var tagSuggestionsByGenre = map[string][]string{ - "pop": {"Pop", "Catchy", "Radio", "Mainstream", "Vocal"}, - "rock": {"Rock", "Guitar", "Drums", "Alternative", "Indie"}, - "electronic": {"Electronic", "Synth", "EDM", "Techno", "House", "Dubstep"}, - "hip-hop": {"Hip-Hop", "Rap", "Beats", "Urban", "Trap"}, - "jazz": {"Jazz", "Smooth", "Saxophone", "Blues", "Soul"}, - "classical": {"Classical", "Orchestral", "Piano", "Strings"}, - "ambient": {"Ambient", "Chill", "Cinematic", "Atmospheric"}, - "default": {"Synthwave", "Lo-Fi", "Experimental", "Instrumental"}, -} - -// GetSuggestedTags returns tag suggestions based on genre and BPM (E4) -// @Summary Get Suggested Tags -// @Description Get tag suggestions for a track based on genre and optional BPM -// @Tags Track -// @Produce json -// @Param genre query string false "Genre filter" -// @Param bpm query int false "BPM hint" -// @Success 200 {object} response.APIResponse{data=object{tags=[]string}} -// @Router /tracks/suggested-tags [get] -func (h *TrackHandler) GetSuggestedTags(c *gin.Context) { - genre := strings.ToLower(strings.TrimSpace(c.DefaultQuery("genre", ""))) - if genre == "" { - genre = "default" - } - tags, ok := tagSuggestionsByGenre[genre] - if !ok { - tags = tagSuggestionsByGenre["default"] - } - handlers.RespondSuccess(c, http.StatusOK, gin.H{"tags": tags}) -} - -// GetTrack gère la récupération d'un track par son ID -// @Summary Get Track by ID -// @Description Get detailed information about a track -// @Tags Track -// @Accept json -// @Produce json -// @Param id path string true "Track ID" -// @Success 200 {object} response.APIResponse{data=object{track=models.Track}} -// @Failure 400 {object} response.APIResponse "Invalid ID" -// @Failure 404 {object} response.APIResponse "Track not found" -// @Router /tracks/{id} [get] -func (h *TrackHandler) GetTrack(c *gin.Context) { - trackIDStr := c.Param("id") - if trackIDStr == "" { - response.BadRequest(c, "track id is required") - return - } - - // MIGRATION UUID: TrackID is UUID - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - response.BadRequest(c, "invalid track id") - return - } - - track, err := h.trackService.GetTrackByID(c.Request.Context(), trackID) - if err != nil { - if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { - response.NotFound(c, "track not found") - return - } - response.InternalServerError(c, "failed to get track") - return - } - - // Masquer l'URL de stream pour les utilisateurs non authentifiés - _, exists := c.Get("user_id") - if !exists { - track.StreamManifestURL = "" - } - - response.Success(c, gin.H{"track": track}) -} - -// UpdateTrackRequest représente la requête de mise à jour d'un track -// MOD-P1-002: Added validation tags for systematic input validation -type UpdateTrackRequest struct { - Title *string `json:"title" binding:"omitempty,min=1,max=255" validate:"omitempty,min=1,max=255"` - Artist *string `json:"artist" binding:"omitempty,max=255" validate:"omitempty,max=255"` - Album *string `json:"album" binding:"omitempty,max=255" validate:"omitempty,max=255"` - Genre *string `json:"genre" binding:"omitempty,max=100" validate:"omitempty,max=100"` - Tags []string `json:"tags"` - Year *int `json:"year" binding:"omitempty,min=1900,max=2100" validate:"omitempty,min=1900,max=2100"` - BPM *int `json:"bpm" binding:"omitempty,min=0,max=300" validate:"omitempty,min=0,max=300"` - MusicalKey *string `json:"musical_key" binding:"omitempty,max=10" validate:"omitempty,max=10"` - IsPublic *bool `json:"is_public"` -} - -// UpdateTrack gère la mise à jour d'un track -// @Summary Update Track -// @Description Update track metadata -// @Tags Track -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Track ID" -// @Param track body UpdateTrackRequest true "Track Metadata" -// @Success 200 {object} response.APIResponse{data=object{track=models.Track}} -// @Failure 400 {object} response.APIResponse "Validation Error" -// @Failure 401 {object} response.APIResponse "Unauthorized" -// @Failure 403 {object} response.APIResponse "Forbidden" -// @Failure 404 {object} response.APIResponse "Track not found" -// @Router /tracks/{id} [put] -func (h *TrackHandler) UpdateTrack(c *gin.Context) { - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - userID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - trackIDStr := c.Param("id") - if trackIDStr == "" { - response.BadRequest(c, "track id is required") - return - } - - // MIGRATION UUID: TrackID is UUID - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - response.BadRequest(c, "invalid track id") - return - } - - // MOD-P1-002: Utiliser helper centralisé pour bind + validate - var req UpdateTrackRequest - if !common.BindAndValidateJSON(c, &req) { - return // Erreur déjà envoyée au client - } - - // Convertir la requête en paramètres de service - params := UpdateTrackParams{ - Title: req.Title, - Artist: req.Artist, - Album: req.Album, - Genre: req.Genre, - Tags: req.Tags, - Year: req.Year, - BPM: req.BPM, - MusicalKey: req.MusicalKey, - IsPublic: req.IsPublic, - } - - // MOD-P1-003: Check if user is admin for ownership bypass - isAdmin := false - if h.permissionService != nil { - hasRole, err := h.permissionService.HasRole(c.Request.Context(), userID, "admin") - if err == nil && hasRole { - isAdmin = true - } - } - - // Pass isAdmin via context - ctx := context.WithValue(c.Request.Context(), "is_admin", isAdmin) - track, err := h.trackService.UpdateTrack(ctx, trackID, userID, params) - if err != nil { - if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { - response.NotFound(c, "track not found") - return - } - if errors.Is(err, ErrForbidden) { - response.Forbidden(c, "forbidden") - return - } - // Erreur de validation (title empty, year negative, etc.) - if strings.Contains(err.Error(), "cannot be") { - // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.BadRequest - h.respondWithError(c, http.StatusBadRequest, err.Error()) - return - } - // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.InternalServerError - h.respondWithError(c, http.StatusInternalServerError, "failed to update track") - return - } - - // MOD-P1-RES-001: Utiliser RespondSuccess au lieu de response.Success - handlers.RespondSuccess(c, http.StatusOK, gin.H{"track": track}) -} - -// GetLyrics gère la récupération des paroles d'un track (E3) -// @Summary Get Track Lyrics -// @Description Get lyrics for a track (public) -// @Tags Track -// @Produce json -// @Param id path string true "Track ID" -// @Success 200 {object} response.APIResponse{data=object{lyrics=models.TrackLyrics}} -// @Failure 404 {object} response.APIResponse "Track not found" -// @Router /tracks/{id}/lyrics [get] -func (h *TrackHandler) GetLyrics(c *gin.Context) { - trackIDStr := c.Param("id") - if trackIDStr == "" { - h.respondWithError(c, http.StatusBadRequest, "track id is required") - return - } - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - // Verify track exists - if _, err := h.trackService.GetTrackByID(c.Request.Context(), trackID); err != nil { - if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { - h.respondWithError(c, http.StatusNotFound, "track not found") - return - } - h.respondWithError(c, http.StatusInternalServerError, "failed to get track") - return - } - lyrics, err := h.trackService.GetLyrics(c.Request.Context(), trackID) - if err != nil { - h.respondWithError(c, http.StatusInternalServerError, "failed to get lyrics") - return - } - if lyrics == nil { - handlers.RespondSuccess(c, http.StatusOK, gin.H{"lyrics": nil}) - return - } - handlers.RespondSuccess(c, http.StatusOK, gin.H{"lyrics": lyrics}) -} - -// UpdateLyricsRequest représente la requête pour créer/mettre à jour les paroles -type UpdateLyricsRequest struct { - Content string `json:"content"` -} - -// UpdateLyrics gère la création/mise à jour des paroles (E3) -// @Summary Update Track Lyrics -// @Description Create or update lyrics for a track (track owner only) -// @Tags Track -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Track ID" -// @Param body body UpdateLyricsRequest true "Lyrics content" -// @Success 200 {object} response.APIResponse{data=object{lyrics=models.TrackLyrics}} -// @Failure 401 {object} response.APIResponse "Unauthorized" -// @Failure 403 {object} response.APIResponse "Forbidden" -// @Failure 404 {object} response.APIResponse "Track not found" -// @Router /tracks/{id}/lyrics [put] -func (h *TrackHandler) UpdateLyrics(c *gin.Context) { - userID, ok := h.getUserID(c) - if !ok { - return - } - trackIDStr := c.Param("id") - if trackIDStr == "" { - h.respondWithError(c, http.StatusBadRequest, "track id is required") - return - } - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - var req UpdateLyricsRequest - if err := c.ShouldBindJSON(&req); err != nil { - h.respondWithError(c, http.StatusBadRequest, "invalid request body") - return - } - lyrics, err := h.trackService.CreateOrUpdateLyrics(c.Request.Context(), trackID, userID, req.Content) - if err != nil { - if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { - h.respondWithError(c, http.StatusNotFound, "track not found") - return - } - if errors.Is(err, ErrForbidden) { - h.respondWithError(c, http.StatusForbidden, "forbidden") - return - } - h.respondWithError(c, http.StatusInternalServerError, "failed to update lyrics") - return - } - handlers.RespondSuccess(c, http.StatusOK, gin.H{"lyrics": lyrics}) -} - -// DeleteTrack gère la suppression d'un track -// @Summary Delete Track -// @Description Permanently delete a track -// @Tags Track -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param id path string true "Track ID" -// @Success 200 {object} response.APIResponse{data=object{message=string}} -// @Failure 401 {object} response.APIResponse "Unauthorized" -// @Failure 403 {object} response.APIResponse "Forbidden" -// @Failure 404 {object} response.APIResponse "Track not found" -// @Router /tracks/{id} [delete] -func (h *TrackHandler) DeleteTrack(c *gin.Context) { - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - userID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - trackIDStr := c.Param("id") - if trackIDStr == "" { - // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de c.JSON - h.respondWithError(c, http.StatusBadRequest, "track id is required") - return - } - - // MIGRATION UUID: TrackID is UUID - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.BadRequest - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - - // MOD-P1-003: Check if user is admin for ownership bypass - isAdmin := false - if h.permissionService != nil { - hasRole, err := h.permissionService.HasRole(c.Request.Context(), userID, "admin") - if err == nil && hasRole { - isAdmin = true - } - } - - // Pass isAdmin via context - ctx := context.WithValue(c.Request.Context(), "is_admin", isAdmin) - err = h.trackService.DeleteTrack(ctx, trackID, userID) - if err != nil { - if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { - // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.NotFound - h.respondWithError(c, http.StatusNotFound, "track not found") - return - } - if errors.Is(err, ErrForbidden) { - // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.Forbidden - h.respondWithError(c, http.StatusForbidden, "forbidden") - return - } - // MOD-P1-RES-001: Utiliser RespondWithAppError au lieu de response.InternalServerError - h.respondWithError(c, http.StatusInternalServerError, "failed to delete track") - return - } - - // MOD-P1-RES-001: Utiliser RespondSuccess au lieu de response.Success - handlers.RespondSuccess(c, http.StatusOK, gin.H{"message": "track deleted successfully"}) -} - -// BatchDeleteRequest représente la requête pour supprimer plusieurs tracks -type BatchDeleteRequest struct { - TrackIDs []string `json:"track_ids" binding:"required" validate:"required,min=1,dive,uuid"` -} - -// BatchDeleteTracks gère la suppression en lot de plusieurs tracks -// POST /api/v1/tracks/batch/delete -// BE-API-024: Implement track batch operations validation -// @Summary Batch Delete Tracks -// @Description Delete multiple tracks at once -// @Tags Track -// @Accept json -// @Produce json -// @Security BearerAuth -// @Param request body BatchDeleteRequest true "List of Track IDs" -// @Success 200 {object} response.APIResponse{data=object{deleted=[]string,failed=object}} -// @Failure 400 {object} response.APIResponse "Validation Error" -// @Failure 500 {object} response.APIResponse "Internal Error" -// @Router /tracks/batch/delete [post] -func (h *TrackHandler) BatchDeleteTracks(c *gin.Context) { - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - userID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - // MOD-P1-002: Utiliser helper centralisé pour bind + validate - var req BatchDeleteRequest - if !common.BindAndValidateJSON(c, &req) { - return // Erreur déjà envoyée au client - } - - // Convertir les IDs en UUIDs - var trackUUIDs []uuid.UUID - for _, idStr := range req.TrackIDs { - if uid, err := uuid.Parse(idStr); err == nil { - trackUUIDs = append(trackUUIDs, uid) - } - } - - // MOD-P1-003: Check if user is admin for ownership bypass - isAdmin := false - if h.permissionService != nil { - hasRole, err := h.permissionService.HasRole(c.Request.Context(), userID, "admin") - if err == nil && hasRole { - isAdmin = true - } - } - - // Pass isAdmin via context - ctx := context.WithValue(c.Request.Context(), "is_admin", isAdmin) - result, err := h.trackService.BatchDeleteTracks(ctx, trackUUIDs, userID) - if err != nil { - // Vérifier si c'est une erreur de taille de batch - if strings.Contains(err.Error(), "batch size exceeds maximum") { - handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error())) - return - } - handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to delete tracks", err)) - return - } - - // BE-API-024: Standardize batch delete response format - handlers.RespondSuccess(c, http.StatusOK, gin.H{ - "deleted": result.Deleted, - "failed": result.Failed, - }) -} - -// BatchUpdateRequest représente la requête pour mettre à jour plusieurs tracks -type BatchUpdateRequest struct { - TrackIDs []string `json:"track_ids" binding:"required" validate:"required,min=1,dive,uuid"` - Updates map[string]interface{} `json:"updates" binding:"required" validate:"required,min=1"` -} - -// BatchUpdateTracks gère la mise à jour en lot de plusieurs tracks -// POST /api/v1/tracks/batch/update -// BE-API-024: Implement track batch operations validation -func (h *TrackHandler) BatchUpdateTracks(c *gin.Context) { - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - userID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - // MOD-P1-002: Utiliser helper centralisé pour bind + validate - var req BatchUpdateRequest - if !common.BindAndValidateJSON(c, &req) { - return // Erreur déjà envoyée au client - } - - // Convertir les IDs en UUIDs - var trackUUIDs []uuid.UUID - for _, idStr := range req.TrackIDs { - if uid, err := uuid.Parse(idStr); err == nil { - trackUUIDs = append(trackUUIDs, uid) - } - } - - // MOD-P1-003: Check if user is admin for ownership bypass - isAdmin := false - if h.permissionService != nil { - hasRole, err := h.permissionService.HasRole(c.Request.Context(), userID, "admin") - if err == nil && hasRole { - isAdmin = true - } - } - - // Pass isAdmin via context - ctx := context.WithValue(c.Request.Context(), "is_admin", isAdmin) - result, err := h.trackService.BatchUpdateTracks(ctx, trackUUIDs, userID, req.Updates) - if err != nil { - // Vérifier si c'est une erreur de validation - if strings.Contains(err.Error(), "batch size exceeds maximum") || - strings.Contains(err.Error(), "cannot be empty") || - strings.Contains(err.Error(), "invalid value") || - strings.Contains(err.Error(), "exceeds maximum length") || - strings.Contains(err.Error(), "must be between") { - // BE-API-024: Standardize batch update error response format - handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error())) - return - } - // BE-API-024: Standardize batch update error response format - handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to update tracks", err)) - return - } - - // BE-API-024: Standardize batch update response format - handlers.RespondSuccess(c, http.StatusOK, gin.H{ - "updated": result.Updated, - "failed": result.Failed, - }) -} - -// LikeTrack gère l'ajout d'un like sur un track -func (h *TrackHandler) LikeTrack(c *gin.Context) { - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - userID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - trackIDStr := c.Param("id") - if trackIDStr == "" { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusBadRequest, "track id is required") - return - } - - // MIGRATION UUID: TrackID is UUID - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - - if err := h.likeService.LikeTrack(c.Request.Context(), userID, trackID); err != nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - if err.Error() == "track not found" { - h.respondWithError(c, http.StatusNotFound, "track not found") - return - } - h.respondWithError(c, http.StatusInternalServerError, err.Error()) - return - } - - // Phase 2.2: Create notification for track creator (skip if user likes own track) - if h.notificationService != nil { - track, err := h.trackService.GetTrackByID(c.Request.Context(), trackID) - if err == nil && track.UserID != userID { - link := "/tracks/" + trackID.String() - if err := h.notificationService.CreateNotification(track.UserID, "like", "New like", "Someone liked your track", link); err != nil { - // Log but don't fail the request - } - } - } - - c.JSON(http.StatusOK, gin.H{"message": "track liked"}) -} - -// UnlikeTrack gère la suppression d'un like sur un track -func (h *TrackHandler) UnlikeTrack(c *gin.Context) { - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - userID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - trackIDStr := c.Param("id") - if trackIDStr == "" { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusBadRequest, "track id is required") - return - } - - // MIGRATION UUID: TrackID is UUID - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - - if err := h.likeService.UnlikeTrack(c.Request.Context(), userID, trackID); err != nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusInternalServerError, err.Error()) - return - } - - handlers.RespondSuccess(c, http.StatusOK, gin.H{"message": "track unliked"}) -} - -// GetTrackLikes gère la récupération du nombre de likes d'un track -func (h *TrackHandler) GetTrackLikes(c *gin.Context) { - trackIDStr := c.Param("id") - if trackIDStr == "" { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusBadRequest, "track id is required") - return - } - - // MIGRATION UUID: TrackID is UUID - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - - count, err := h.likeService.GetTrackLikesCount(c.Request.Context(), trackID) - if err != nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusInternalServerError, err.Error()) - return - } - - // Vérifier si l'utilisateur a liké ce track (optionnel) - var isLiked bool - if userIDInterface, exists := c.Get("user_id"); exists { - userID, ok := userIDInterface.(uuid.UUID) - if ok && userID != uuid.Nil { - isLiked, _ = h.likeService.IsLiked(c.Request.Context(), userID, trackID) - } - } - - handlers.RespondSuccess(c, http.StatusOK, gin.H{ - "count": count, - "is_liked": isLiked, - }) -} - -// GetUserLikedTracks gère la récupération des tracks likés par un utilisateur -// GET /api/v1/users/:id/likes -// BE-API-027: Implement user liked tracks endpoint -func (h *TrackHandler) GetUserLikedTracks(c *gin.Context) { - userIDStr := c.Param("id") - if userIDStr == "" { - handlers.RespondWithAppError(c, apperrors.NewValidationError("user id is required")) - return - } - - userID, err := uuid.Parse(userIDStr) - if err != nil { - handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid user id")) - return - } - - // Parse pagination parameters - limit := 20 // default - if limitStr := c.Query("limit"); limitStr != "" { - if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { - // Limiter à un maximum raisonnable - if parsedLimit > 100 { - parsedLimit = 100 - } - limit = parsedLimit - } - } - - offset := 0 // default - if offsetStr := c.Query("offset"); offsetStr != "" { - if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { - offset = parsedOffset - } - } - - tracks, err := h.likeService.GetUserLikedTracks(c.Request.Context(), userID, limit, offset) - if err != nil { - handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get user liked tracks", err)) - return - } - - total, err := h.likeService.GetUserLikedTracksCount(c.Request.Context(), userID) - if err != nil { - handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get user liked tracks count", err)) - return - } - - // BE-API-027: Standardize response format - handlers.RespondSuccess(c, http.StatusOK, gin.H{ - "tracks": tracks, - "total": total, - "limit": limit, - "offset": offset, - }) -} - -// SearchTracks gère la recherche avancée de tracks -func (h *TrackHandler) SearchTracks(c *gin.Context) { - if h.searchService == nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusInternalServerError, "search service not available") - return - } - - // Récupérer les paramètres de query - params := services.TrackSearchParams{ - Query: c.Query("q"), - TagMode: c.DefaultQuery("tag_mode", "OR"), - Page: 1, - Limit: 20, - SortBy: c.DefaultQuery("sort_by", "created_at"), - SortOrder: c.DefaultQuery("sort_order", "desc"), - } - - // Parser page - if pageStr := c.Query("page"); pageStr != "" { - if page, err := strconv.Atoi(pageStr); err == nil && page > 0 { - params.Page = page - } - } - - // Parser limit - if limitStr := c.Query("limit"); limitStr != "" { - if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 { - params.Limit = limit - } - } - if params.Limit > 100 { - params.Limit = 100 - } - - // Parser tags - if tagsStr := c.Query("tags"); tagsStr != "" { - params.Tags = strings.Split(tagsStr, ",") - for i := range params.Tags { - params.Tags[i] = strings.TrimSpace(params.Tags[i]) - } - } - - // Parser min_duration - if minDurationStr := c.Query("min_duration"); minDurationStr != "" { - if minDuration, err := strconv.Atoi(minDurationStr); err == nil && minDuration >= 0 { - params.MinDuration = &minDuration - } - } - - // Parser max_duration - if maxDurationStr := c.Query("max_duration"); maxDurationStr != "" { - if maxDuration, err := strconv.Atoi(maxDurationStr); err == nil && maxDuration >= 0 { - params.MaxDuration = &maxDuration - } - } - - // Parser min_bpm - if minBPMStr := c.Query("min_bpm"); minBPMStr != "" { - if minBPM, err := strconv.Atoi(minBPMStr); err == nil && minBPM >= 0 { - params.MinBPM = &minBPM - } - } - - // Parser max_bpm - if maxBPMStr := c.Query("max_bpm"); maxBPMStr != "" { - if maxBPM, err := strconv.Atoi(maxBPMStr); err == nil && maxBPM >= 0 { - params.MaxBPM = &maxBPM - } - } - - // Parser genre - if genre := c.Query("genre"); genre != "" { - params.Genre = &genre - } - - // Parser format - if format := c.Query("format"); format != "" { - params.Format = &format - } - - // Parser musical_key - if musicalKey := c.Query("musical_key"); musicalKey != "" { - params.MusicalKey = &musicalKey - } - - // Parser min_date - if minDate := c.Query("min_date"); minDate != "" { - params.MinDate = &minDate - } - - // Parser max_date - if maxDate := c.Query("max_date"); maxDate != "" { - params.MaxDate = &maxDate - } - - // Effectuer la recherche avec filtres combinés - tracks, total, err := h.searchService.SearchTracks(c.Request.Context(), params) - if err != nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusInternalServerError, "failed to search tracks") - return - } - - // Calculer les métadonnées de pagination - totalPages := (int(total) + params.Limit - 1) / params.Limit - if totalPages == 0 { - totalPages = 1 - } - - c.JSON(http.StatusOK, gin.H{ - "tracks": tracks, - "pagination": gin.H{ - "page": params.Page, - "limit": params.Limit, - "total": total, - "total_pages": totalPages, - }, - }) -} - -// CreateShareRequest représente la requête pour créer un lien de partage -type CreateShareRequest struct { - Permissions string `json:"permissions" binding:"required" validate:"required,oneof=read write admin"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` -} - -// CreateShare crée un nouveau lien de partage pour un track -func (h *TrackHandler) CreateShare(c *gin.Context) { - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - userID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - trackIDStr := c.Param("id") - if trackIDStr == "" { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusBadRequest, "track id is required") - return - } - - // MIGRATION UUID: TrackID is UUID - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - - if h.shareService == nil { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusInternalServerError, "share service not available") - return - } - - // MOD-P1-002: Utiliser helper centralisé pour bind + validate - var req CreateShareRequest - if !common.BindAndValidateJSON(c, &req) { - return // Erreur déjà envoyée au client - } - - share, err := h.shareService.CreateShare(c.Request.Context(), trackID, userID, req.Permissions, req.ExpiresAt) - if err != nil { - if errors.Is(err, services.ErrForbidden) { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusForbidden, "forbidden") - return - } - if errors.Is(err, services.ErrTrackNotFound) { - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusNotFound, "track not found") - return - } - // MOD-P2-003: Utiliser AppError au lieu de gin.H - h.respondWithError(c, http.StatusInternalServerError, "failed to create share") - return - } - - handlers.RespondSuccess(c, http.StatusOK, gin.H{"share": share}) -} - -// GetSharedTrack récupère un track via son token de partage -// GET /api/v1/tracks/shared/:token -// BE-API-029: Implement shared track access endpoint validation -func (h *TrackHandler) GetSharedTrack(c *gin.Context) { - token := c.Param("token") - if token == "" { - handlers.RespondWithAppError(c, apperrors.NewValidationError("share token is required")) - return - } - - if h.shareService == nil { - handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "share service not available", nil)) - return - } - - share, err := h.shareService.ValidateShareToken(c.Request.Context(), token) - if err != nil { - if errors.Is(err, services.ErrShareNotFound) { - handlers.RespondWithAppError(c, apperrors.NewNotFoundError("share")) - return - } - if errors.Is(err, services.ErrShareExpired) { - handlers.RespondWithAppError(c, apperrors.NewForbiddenError("share link expired")) - return - } - handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to validate share token", err)) - return - } - - // Récupérer le track - track, err := h.trackService.GetTrackByID(c.Request.Context(), share.TrackID) - if err != nil { - if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { - handlers.RespondWithAppError(c, apperrors.NewNotFoundError("track")) - return - } - handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get track", err)) - return - } - - // BE-API-029: Standardize response format - handlers.RespondSuccess(c, http.StatusOK, gin.H{ - "track": track, - "share": share, - }) -} - -// RevokeShare révoque un lien de partage -// DELETE /api/v1/tracks/share/:id -// BE-API-028: Implement track share revoke endpoint validation -func (h *TrackHandler) RevokeShare(c *gin.Context) { - // MOD-P1-RES-003: Utiliser helper fail-secure au lieu de c.MustGet() - userID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - shareIDStr := c.Param("id") - if shareIDStr == "" { - handlers.RespondWithAppError(c, apperrors.NewValidationError("share id is required")) - return - } - - // MIGRATION UUID: ShareID is UUID - shareID, err := uuid.Parse(shareIDStr) - if err != nil { - handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid share id")) - return - } - - if h.shareService == nil { - handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "share service not available", nil)) - return - } - - err = h.shareService.RevokeShare(c.Request.Context(), shareID, userID) - if err != nil { - if errors.Is(err, services.ErrShareNotFound) { - handlers.RespondWithAppError(c, apperrors.NewNotFoundError("share")) - return - } - if errors.Is(err, services.ErrForbidden) { - handlers.RespondWithAppError(c, apperrors.NewForbiddenError("forbidden")) - return - } - handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to revoke share", err)) - return - } - - // BE-API-028: Standardize response format - handlers.RespondSuccess(c, http.StatusOK, gin.H{"message": "share revoked"}) -} - -// GetTrackStats returns track statistics (plays, likes, views, etc.) -// GET /api/v1/tracks/:id/stats -func (h *TrackHandler) GetTrackStats(c *gin.Context) { - trackIDStr := c.Param("id") - if trackIDStr == "" { - h.respondWithError(c, http.StatusBadRequest, "track id is required") - return - } - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - - stats, err := h.trackService.GetTrackStats(c.Request.Context(), trackID) - if err != nil { - if errors.Is(err, ErrTrackNotFound) { - h.respondWithError(c, http.StatusNotFound, "track not found") - return - } - h.respondWithError(c, http.StatusInternalServerError, "failed to get track stats") - return - } - - // Map to response format expected by frontend - avgDuration := float64(0) - if stats.Views > 0 { - avgDuration = float64(stats.TotalPlayTime) / float64(stats.Views) - } - resp := gin.H{ - "total_plays": stats.Views, - "unique_listeners": 0, // TrackService does not compute; use /analytics/tracks/:id/stats for full analytics - "average_duration": avgDuration, - "completion_rate": 0, - "views": stats.Views, - "likes": stats.Likes, - "comments": stats.Comments, - "total_play_time": stats.TotalPlayTime, - "downloads": stats.Downloads, - } - handlers.RespondSuccess(c, http.StatusOK, gin.H{"stats": resp}) -} - -// GetTrackHistory returns modification history for a track -// GET /api/v1/tracks/:id/history -func (h *TrackHandler) GetTrackHistory(c *gin.Context) { - if h.historyService == nil { - h.respondWithError(c, http.StatusInternalServerError, "history service not available") - return - } - - trackIDStr := c.Param("id") - if trackIDStr == "" { - h.respondWithError(c, http.StatusBadRequest, "track id is required") - return - } - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - - limit := 50 - offset := 0 - if l := c.Query("limit"); l != "" { - if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { - limit = parsed - } - } - if o := c.Query("offset"); o != "" { - if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { - offset = parsed - } - } - - histories, total, err := h.historyService.GetHistory(c.Request.Context(), trackID, limit, offset) - if err != nil { - if errors.Is(err, services.ErrTrackNotFound) { - h.respondWithError(c, http.StatusNotFound, "track not found") - return - } - h.respondWithError(c, http.StatusInternalServerError, "failed to get track history") - return - } - - // Map models to response format - historyItems := make([]gin.H, 0, len(histories)) - for _, item := range histories { - historyItems = append(historyItems, gin.H{ - "id": item.ID.String(), - "track_id": item.TrackID.String(), - "user_id": item.UserID.String(), - "action": string(item.Action), - "old_value": item.OldValue, - "new_value": item.NewValue, - "created_at": item.CreatedAt, - }) - } - - handlers.RespondSuccess(c, http.StatusOK, gin.H{ - "history": historyItems, - "total": total, - "limit": limit, - "offset": offset, - }) -} - -// RecordPlayRequest représente la requête pour enregistrer un événement de lecture -// BE-API-019: Implement track play analytics endpoint -type RecordPlayRequest struct { - PlayTime int `json:"play_time,omitempty" binding:"omitempty,min=0"` // seconds, optional, defaults to 0 -} - -// RecordPlay enregistre un événement de lecture pour un track -// POST /api/v1/tracks/:id/play -// BE-API-019: Implement track play analytics endpoint -func (h *TrackHandler) RecordPlay(c *gin.Context) { - if h.playbackAnalyticsService == nil { - h.respondWithError(c, http.StatusInternalServerError, "playback analytics service not available") - return - } - - // Récupérer l'ID du track depuis l'URL - trackIDStr := c.Param("id") - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - - // Récupérer l'ID utilisateur du contexte - userID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - // Parser la requête (optionnelle, peut être vide) - var req RecordPlayRequest - if c.Request.ContentLength > 0 { - if err := c.ShouldBindJSON(&req); err != nil { - // Si le body est présent mais invalide, retourner une erreur - h.respondWithError(c, http.StatusBadRequest, "invalid request body") - return - } - } - - // Créer un événement d'analytics basique - // Note: Le service RecordPlayback vérifie que le track existe - playTime := req.PlayTime - if playTime < 0 { - playTime = 0 - } - - analytics := &models.PlaybackAnalytics{ - TrackID: trackID, - UserID: userID, - PlayTime: playTime, - PauseCount: 0, - SeekCount: 0, - CompletionRate: 0, // Sera calculé par le service si track.Duration > 0 - StartedAt: time.Now(), - EndedAt: nil, - } - - // Enregistrer l'événement via le service - err = h.playbackAnalyticsService.RecordPlayback(c.Request.Context(), analytics) - if err != nil { - if errors.Is(err, services.ErrTrackNotFound) { - h.respondWithError(c, http.StatusNotFound, "track not found") - return - } - h.respondWithError(c, http.StatusInternalServerError, "failed to record play event") - return - } - - // Retourner le succès - handlers.RespondSuccess(c, http.StatusOK, gin.H{ - "message": "Play event recorded", - "id": analytics.ID.String(), - }) -} - -// RestoreVersion restaure une version spécifique d'un track -// POST /api/v1/tracks/:id/versions/:versionId/restore -// BE-API-014: Implement track versions restore endpoint -func (h *TrackHandler) RestoreVersion(c *gin.Context) { - if h.versionService == nil { - h.respondWithError(c, http.StatusInternalServerError, "version service not available") - return - } - - // Récupérer l'ID du track depuis l'URL - trackIDStr := c.Param("id") - trackID, err := uuid.Parse(trackIDStr) - if err != nil { - h.respondWithError(c, http.StatusBadRequest, "invalid track id") - return - } - - // Récupérer l'ID de la version depuis l'URL - versionIDStr := c.Param("versionId") - versionID, err := uuid.Parse(versionIDStr) - if err != nil { - h.respondWithError(c, http.StatusBadRequest, "invalid version id") - return - } - - // Récupérer l'ID utilisateur du contexte - userID, ok := h.getUserID(c) - if !ok { - return // Erreur déjà envoyée par getUserID - } - - // Restaurer la version - err = h.versionService.RestoreVersion(c.Request.Context(), trackID, versionID, userID) - if err != nil { - if errors.Is(err, services.ErrTrackNotFound) { - h.respondWithError(c, http.StatusNotFound, "track not found") - return - } - if errors.Is(err, services.ErrVersionNotFound) { - h.respondWithError(c, http.StatusNotFound, "version not found") - return - } - if errors.Is(err, services.ErrForbidden) { - h.respondWithError(c, http.StatusForbidden, "forbidden: only track owner can restore versions") - return - } - h.respondWithError(c, http.StatusInternalServerError, "failed to restore version") - return - } - - c.JSON(http.StatusOK, gin.H{"message": "Version restored successfully"}) -} diff --git a/veza-backend-api/internal/core/track/track_analytics_handler.go b/veza-backend-api/internal/core/track/track_analytics_handler.go new file mode 100644 index 000000000..305033196 --- /dev/null +++ b/veza-backend-api/internal/core/track/track_analytics_handler.go @@ -0,0 +1,230 @@ +package track + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/google/uuid" + + "veza-backend-api/internal/handlers" + "veza-backend-api/internal/models" + "veza-backend-api/internal/services" + + "github.com/gin-gonic/gin" +) + +// RecordPlayRequest représente la requête pour enregistrer un événement de lecture +type RecordPlayRequest struct { + PlayTime int `json:"play_time,omitempty" binding:"omitempty,min=0"` +} + +// GetTrackStats returns track statistics (plays, likes, views, etc.) +func (h *TrackHandler) GetTrackStats(c *gin.Context) { + trackIDStr := c.Param("id") + if trackIDStr == "" { + h.respondWithError(c, http.StatusBadRequest, "track id is required") + return + } + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + stats, err := h.trackService.GetTrackStats(c.Request.Context(), trackID) + if err != nil { + if errors.Is(err, ErrTrackNotFound) { + h.respondWithError(c, http.StatusNotFound, "track not found") + return + } + h.respondWithError(c, http.StatusInternalServerError, "failed to get track stats") + return + } + + avgDuration := float64(0) + if stats.Views > 0 { + avgDuration = float64(stats.TotalPlayTime) / float64(stats.Views) + } + resp := gin.H{ + "total_plays": stats.Views, + "unique_listeners": 0, + "average_duration": avgDuration, + "completion_rate": 0, + "views": stats.Views, + "likes": stats.Likes, + "comments": stats.Comments, + "total_play_time": stats.TotalPlayTime, + "downloads": stats.Downloads, + } + handlers.RespondSuccess(c, http.StatusOK, gin.H{"stats": resp}) +} + +// GetTrackHistory returns modification history for a track +func (h *TrackHandler) GetTrackHistory(c *gin.Context) { + if h.historyService == nil { + h.respondWithError(c, http.StatusInternalServerError, "history service not available") + return + } + + trackIDStr := c.Param("id") + if trackIDStr == "" { + h.respondWithError(c, http.StatusBadRequest, "track id is required") + return + } + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + limit := 50 + offset := 0 + if l := c.Query("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + if o := c.Query("offset"); o != "" { + if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { + offset = parsed + } + } + + histories, total, err := h.historyService.GetHistory(c.Request.Context(), trackID, limit, offset) + if err != nil { + if errors.Is(err, services.ErrTrackNotFound) { + h.respondWithError(c, http.StatusNotFound, "track not found") + return + } + h.respondWithError(c, http.StatusInternalServerError, "failed to get track history") + return + } + + historyItems := make([]gin.H, 0, len(histories)) + for _, item := range histories { + historyItems = append(historyItems, gin.H{ + "id": item.ID.String(), + "track_id": item.TrackID.String(), + "user_id": item.UserID.String(), + "action": string(item.Action), + "old_value": item.OldValue, + "new_value": item.NewValue, + "created_at": item.CreatedAt, + }) + } + + handlers.RespondSuccess(c, http.StatusOK, gin.H{ + "history": historyItems, + "total": total, + "limit": limit, + "offset": offset, + }) +} + +// RecordPlay enregistre un événement de lecture pour un track +func (h *TrackHandler) RecordPlay(c *gin.Context) { + if h.playbackAnalyticsService == nil { + h.respondWithError(c, http.StatusInternalServerError, "playback analytics service not available") + return + } + + trackIDStr := c.Param("id") + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + userID, ok := h.getUserID(c) + if !ok { + return + } + + var req RecordPlayRequest + if c.Request.ContentLength > 0 { + if err := c.ShouldBindJSON(&req); err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid request body") + return + } + } + + playTime := req.PlayTime + if playTime < 0 { + playTime = 0 + } + + analytics := &models.PlaybackAnalytics{ + TrackID: trackID, + UserID: userID, + PlayTime: playTime, + PauseCount: 0, + SeekCount: 0, + CompletionRate: 0, + StartedAt: time.Now(), + EndedAt: nil, + } + + err = h.playbackAnalyticsService.RecordPlayback(c.Request.Context(), analytics) + if err != nil { + if errors.Is(err, services.ErrTrackNotFound) { + h.respondWithError(c, http.StatusNotFound, "track not found") + return + } + h.respondWithError(c, http.StatusInternalServerError, "failed to record play event") + return + } + + handlers.RespondSuccess(c, http.StatusOK, gin.H{ + "message": "Play event recorded", + "id": analytics.ID.String(), + }) +} + +// RestoreVersion restaure une version spécifique d'un track +func (h *TrackHandler) RestoreVersion(c *gin.Context) { + if h.versionService == nil { + h.respondWithError(c, http.StatusInternalServerError, "version service not available") + return + } + + trackIDStr := c.Param("id") + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + versionIDStr := c.Param("versionId") + versionID, err := uuid.Parse(versionIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid version id") + return + } + + userID, ok := h.getUserID(c) + if !ok { + return + } + + err = h.versionService.RestoreVersion(c.Request.Context(), trackID, versionID, userID) + if err != nil { + if errors.Is(err, services.ErrTrackNotFound) { + h.respondWithError(c, http.StatusNotFound, "track not found") + return + } + if errors.Is(err, services.ErrVersionNotFound) { + h.respondWithError(c, http.StatusNotFound, "version not found") + return + } + if errors.Is(err, services.ErrForbidden) { + h.respondWithError(c, http.StatusForbidden, "forbidden: only track owner can restore versions") + return + } + h.respondWithError(c, http.StatusInternalServerError, "failed to restore version") + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Version restored successfully"}) +} diff --git a/veza-backend-api/internal/core/track/track_crud_handler.go b/veza-backend-api/internal/core/track/track_crud_handler.go new file mode 100644 index 000000000..496d652d9 --- /dev/null +++ b/veza-backend-api/internal/core/track/track_crud_handler.go @@ -0,0 +1,407 @@ +package track + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + + "veza-backend-api/internal/common" + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/handlers" + "veza-backend-api/internal/response" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// UpdateTrackRequest représente la requête de mise à jour d'un track +type UpdateTrackRequest struct { + Title *string `json:"title" binding:"omitempty,min=1,max=255" validate:"omitempty,min=1,max=255"` + Artist *string `json:"artist" binding:"omitempty,max=255" validate:"omitempty,max=255"` + Album *string `json:"album" binding:"omitempty,max=255" validate:"omitempty,max=255"` + Genre *string `json:"genre" binding:"omitempty,max=100" validate:"omitempty,max=100"` + Tags []string `json:"tags"` + Year *int `json:"year" binding:"omitempty,min=1900,max=2100" validate:"omitempty,min=1900,max=2100"` + BPM *int `json:"bpm" binding:"omitempty,min=0,max=300" validate:"omitempty,min=0,max=300"` + MusicalKey *string `json:"musical_key" binding:"omitempty,max=10" validate:"omitempty,max=10"` + IsPublic *bool `json:"is_public"` +} + +// UpdateLyricsRequest représente la requête pour créer/mettre à jour les paroles +type UpdateLyricsRequest struct { + Content string `json:"content"` +} + +// BatchDeleteRequest représente la requête pour supprimer plusieurs tracks +type BatchDeleteRequest struct { + TrackIDs []string `json:"track_ids" binding:"required" validate:"required,min=1,dive,uuid"` +} + +// BatchUpdateRequest représente la requête pour mettre à jour plusieurs tracks +type BatchUpdateRequest struct { + TrackIDs []string `json:"track_ids" binding:"required" validate:"required,min=1,dive,uuid"` + Updates map[string]interface{} `json:"updates" binding:"required" validate:"required,min=1"` +} + +// ListTracks gère la liste des tracks avec pagination, filtres et tri +func (h *TrackHandler) ListTracks(c *gin.Context) { + page := c.DefaultQuery("page", "1") + limit := c.DefaultQuery("limit", "20") + userIDStr := c.Query("user_id") + genre := c.Query("genre") + format := c.Query("format") + sortBy := c.DefaultQuery("sort_by", "created_at") + sortOrder := c.DefaultQuery("sort_order", "desc") + + var pageInt, limitInt int + if _, err := fmt.Sscanf(page, "%d", &pageInt); err != nil || pageInt < 1 { + response.BadRequest(c, "pagination: page must be >= 1 and limit must be between 1 and 100") + return + } + if _, err := fmt.Sscanf(limit, "%d", &limitInt); err != nil || limitInt < 1 || limitInt > 100 { + response.BadRequest(c, "pagination: page must be >= 1 and limit must be between 1 and 100") + return + } + + params := TrackListParams{ + Page: pageInt, + Limit: limitInt, + SortBy: sortBy, + SortOrder: sortOrder, + } + if userIDStr != "" { + if uid, err := uuid.Parse(userIDStr); err == nil { + params.UserID = &uid + } + } + if genre != "" { + params.Genre = &genre + } + if format != "" { + params.Format = &format + } + + tracks, total, err := h.trackService.ListTracks(c.Request.Context(), params) + if err != nil { + response.InternalServerError(c, "failed to list tracks") + return + } + + pagination := handlers.BuildPaginationData(pageInt, limitInt, total) + _, exists := c.Get("user_id") + if !exists { + for _, t := range tracks { + t.StreamManifestURL = "" + } + } + + response.Success(c, gin.H{ + "tracks": tracks, + "pagination": pagination, + }) +} + +// GetTrack gère la récupération d'un track par ID +func (h *TrackHandler) GetTrack(c *gin.Context) { + trackIDStr := c.Param("id") + if trackIDStr == "" { + response.BadRequest(c, "track id is required") + return + } + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + response.BadRequest(c, "invalid track id") + return + } + + track, err := h.trackService.GetTrackByID(c.Request.Context(), trackID) + if err != nil { + if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + response.NotFound(c, "track not found") + return + } + response.InternalServerError(c, "failed to get track") + return + } + + _, exists := c.Get("user_id") + if !exists { + track.StreamManifestURL = "" + } + + response.Success(c, gin.H{"track": track}) +} + +// UpdateTrack gère la mise à jour d'un track +func (h *TrackHandler) UpdateTrack(c *gin.Context) { + userID, ok := h.getUserID(c) + if !ok { + return + } + + trackIDStr := c.Param("id") + if trackIDStr == "" { + response.BadRequest(c, "track id is required") + return + } + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + response.BadRequest(c, "invalid track id") + return + } + + var req UpdateTrackRequest + if !common.BindAndValidateJSON(c, &req) { + return + } + + params := UpdateTrackParams{ + Title: req.Title, + Artist: req.Artist, + Album: req.Album, + Genre: req.Genre, + Tags: req.Tags, + Year: req.Year, + BPM: req.BPM, + MusicalKey: req.MusicalKey, + IsPublic: req.IsPublic, + } + + isAdmin := false + if h.permissionService != nil { + hasRole, err := h.permissionService.HasRole(c.Request.Context(), userID, "admin") + if err == nil && hasRole { + isAdmin = true + } + } + + ctx := context.WithValue(c.Request.Context(), "is_admin", isAdmin) + track, err := h.trackService.UpdateTrack(ctx, trackID, userID, params) + if err != nil { + if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + response.NotFound(c, "track not found") + return + } + if errors.Is(err, ErrForbidden) { + response.Forbidden(c, "forbidden") + return + } + if strings.Contains(err.Error(), "cannot be") { + h.respondWithError(c, http.StatusBadRequest, err.Error()) + return + } + h.respondWithError(c, http.StatusInternalServerError, "failed to update track") + return + } + + handlers.RespondSuccess(c, http.StatusOK, gin.H{"track": track}) +} + +// GetLyrics gère la récupération des paroles d'un track +func (h *TrackHandler) GetLyrics(c *gin.Context) { + trackIDStr := c.Param("id") + if trackIDStr == "" { + h.respondWithError(c, http.StatusBadRequest, "track id is required") + return + } + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + if _, err := h.trackService.GetTrackByID(c.Request.Context(), trackID); err != nil { + if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + h.respondWithError(c, http.StatusNotFound, "track not found") + return + } + h.respondWithError(c, http.StatusInternalServerError, "failed to get track") + return + } + lyrics, err := h.trackService.GetLyrics(c.Request.Context(), trackID) + if err != nil { + h.respondWithError(c, http.StatusInternalServerError, "failed to get lyrics") + return + } + if lyrics == nil { + handlers.RespondSuccess(c, http.StatusOK, gin.H{"lyrics": nil}) + return + } + handlers.RespondSuccess(c, http.StatusOK, gin.H{"lyrics": lyrics}) +} + +// UpdateLyrics gère la création/mise à jour des paroles +func (h *TrackHandler) UpdateLyrics(c *gin.Context) { + userID, ok := h.getUserID(c) + if !ok { + return + } + trackIDStr := c.Param("id") + if trackIDStr == "" { + h.respondWithError(c, http.StatusBadRequest, "track id is required") + return + } + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + var req UpdateLyricsRequest + if err := c.ShouldBindJSON(&req); err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid request body") + return + } + lyrics, err := h.trackService.CreateOrUpdateLyrics(c.Request.Context(), trackID, userID, req.Content) + if err != nil { + if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + h.respondWithError(c, http.StatusNotFound, "track not found") + return + } + if errors.Is(err, ErrForbidden) { + h.respondWithError(c, http.StatusForbidden, "forbidden") + return + } + h.respondWithError(c, http.StatusInternalServerError, "failed to update lyrics") + return + } + handlers.RespondSuccess(c, http.StatusOK, gin.H{"lyrics": lyrics}) +} + +// DeleteTrack gère la suppression d'un track +func (h *TrackHandler) DeleteTrack(c *gin.Context) { + userID, ok := h.getUserID(c) + if !ok { + return + } + + trackIDStr := c.Param("id") + if trackIDStr == "" { + h.respondWithError(c, http.StatusBadRequest, "track id is required") + return + } + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + isAdmin := false + if h.permissionService != nil { + hasRole, err := h.permissionService.HasRole(c.Request.Context(), userID, "admin") + if err == nil && hasRole { + isAdmin = true + } + } + + ctx := context.WithValue(c.Request.Context(), "is_admin", isAdmin) + err = h.trackService.DeleteTrack(ctx, trackID, userID) + if err != nil { + if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + h.respondWithError(c, http.StatusNotFound, "track not found") + return + } + if errors.Is(err, ErrForbidden) { + h.respondWithError(c, http.StatusForbidden, "forbidden") + return + } + h.respondWithError(c, http.StatusInternalServerError, "failed to delete track") + return + } + + handlers.RespondSuccess(c, http.StatusOK, gin.H{"message": "track deleted successfully"}) +} + +// BatchDeleteTracks gère la suppression en lot de plusieurs tracks +func (h *TrackHandler) BatchDeleteTracks(c *gin.Context) { + userID, ok := h.getUserID(c) + if !ok { + return + } + + var req BatchDeleteRequest + if !common.BindAndValidateJSON(c, &req) { + return + } + + var trackUUIDs []uuid.UUID + for _, idStr := range req.TrackIDs { + if uid, err := uuid.Parse(idStr); err == nil { + trackUUIDs = append(trackUUIDs, uid) + } + } + + isAdmin := false + if h.permissionService != nil { + hasRole, err := h.permissionService.HasRole(c.Request.Context(), userID, "admin") + if err == nil && hasRole { + isAdmin = true + } + } + + ctx := context.WithValue(c.Request.Context(), "is_admin", isAdmin) + result, err := h.trackService.BatchDeleteTracks(ctx, trackUUIDs, userID) + if err != nil { + if strings.Contains(err.Error(), "batch size exceeds maximum") { + handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error())) + return + } + handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to delete tracks", err)) + return + } + + handlers.RespondSuccess(c, http.StatusOK, gin.H{ + "deleted": result.Deleted, + "failed": result.Failed, + }) +} + +// BatchUpdateTracks gère la mise à jour en lot de plusieurs tracks +func (h *TrackHandler) BatchUpdateTracks(c *gin.Context) { + userID, ok := h.getUserID(c) + if !ok { + return + } + + var req BatchUpdateRequest + if !common.BindAndValidateJSON(c, &req) { + return + } + + var trackUUIDs []uuid.UUID + for _, idStr := range req.TrackIDs { + if uid, err := uuid.Parse(idStr); err == nil { + trackUUIDs = append(trackUUIDs, uid) + } + } + + isAdmin := false + if h.permissionService != nil { + hasRole, err := h.permissionService.HasRole(c.Request.Context(), userID, "admin") + if err == nil && hasRole { + isAdmin = true + } + } + + ctx := context.WithValue(c.Request.Context(), "is_admin", isAdmin) + result, err := h.trackService.BatchUpdateTracks(ctx, trackUUIDs, userID, req.Updates) + if err != nil { + if strings.Contains(err.Error(), "batch size exceeds maximum") || + strings.Contains(err.Error(), "cannot be empty") || + strings.Contains(err.Error(), "invalid value") || + strings.Contains(err.Error(), "exceeds maximum length") || + strings.Contains(err.Error(), "must be between") { + handlers.RespondWithAppError(c, apperrors.NewValidationError(err.Error())) + return + } + handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to update tracks", err)) + return + } + + handlers.RespondSuccess(c, http.StatusOK, gin.H{ + "updated": result.Updated, + "failed": result.Failed, + }) +} diff --git a/veza-backend-api/internal/core/track/track_search_handler.go b/veza-backend-api/internal/core/track/track_search_handler.go new file mode 100644 index 000000000..a8754dda1 --- /dev/null +++ b/veza-backend-api/internal/core/track/track_search_handler.go @@ -0,0 +1,189 @@ +package track + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/google/uuid" + + "veza-backend-api/internal/handlers" + "veza-backend-api/internal/models" + "veza-backend-api/internal/response" + "veza-backend-api/internal/services" + + "github.com/gin-gonic/gin" +) + +// tagSuggestionsByGenre holds static tag suggestions per genre (E4) +var tagSuggestionsByGenre = map[string][]string{ + "pop": {"Pop", "Catchy", "Radio", "Mainstream", "Vocal"}, + "rock": {"Rock", "Guitar", "Drums", "Alternative", "Indie"}, + "electronic": {"Electronic", "Synth", "EDM", "Techno", "House", "Dubstep"}, + "hip-hop": {"Hip-Hop", "Rap", "Beats", "Urban", "Trap"}, + "jazz": {"Jazz", "Smooth", "Saxophone", "Blues", "Soul"}, + "classical": {"Classical", "Orchestral", "Piano", "Strings"}, + "ambient": {"Ambient", "Chill", "Cinematic", "Atmospheric"}, + "default": {"Synthwave", "Lo-Fi", "Experimental", "Instrumental"}, +} + +// GetRecommendations returns personalized track recommendations (D2 autoplay) +func (h *TrackHandler) GetRecommendations(c *gin.Context) { + if h.trackRecommendationService == nil { + response.InternalServerError(c, "recommendations unavailable") + return + } + var userID uuid.UUID + if uid, exists := c.Get("user_id"); exists { + if parsed, ok := uid.(uuid.UUID); ok { + userID = parsed + } + } + limitStr := c.DefaultQuery("limit", "20") + var limit int + if _, err := fmt.Sscanf(limitStr, "%d", &limit); err != nil || limit < 1 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + params := services.TrackRecommendationParams{ + UserID: userID, + Limit: limit, + } + if seedStr := c.Query("seed_track_id"); seedStr != "" { + if sid, err := uuid.Parse(seedStr); err == nil { + params.SeedTrackID = &sid + } + } + recs, err := h.trackRecommendationService.GetRecommendations(c.Request.Context(), params) + if err != nil { + response.InternalServerError(c, "failed to get recommendations") + return + } + tracks := make([]*models.Track, 0, len(recs)) + for _, r := range recs { + if r.Track != nil { + tracks = append(tracks, r.Track) + } + } + response.Success(c, gin.H{"tracks": tracks}) +} + +// GetSuggestedTags returns tag suggestions based on genre and BPM (E4) +func (h *TrackHandler) GetSuggestedTags(c *gin.Context) { + genre := strings.ToLower(strings.TrimSpace(c.DefaultQuery("genre", ""))) + if genre == "" { + genre = "default" + } + tags, ok := tagSuggestionsByGenre[genre] + if !ok { + tags = tagSuggestionsByGenre["default"] + } + handlers.RespondSuccess(c, http.StatusOK, gin.H{"tags": tags}) +} + +// SearchTracks gère la recherche avancée de tracks +func (h *TrackHandler) SearchTracks(c *gin.Context) { + if h.searchService == nil { + h.respondWithError(c, http.StatusInternalServerError, "search service not available") + return + } + + params := services.TrackSearchParams{ + Query: c.Query("q"), + TagMode: c.DefaultQuery("tag_mode", "OR"), + Page: 1, + Limit: 20, + SortBy: c.DefaultQuery("sort_by", "created_at"), + SortOrder: c.DefaultQuery("sort_order", "desc"), + } + + if pageStr := c.Query("page"); pageStr != "" { + if page, err := strconv.Atoi(pageStr); err == nil && page > 0 { + params.Page = page + } + } + + if limitStr := c.Query("limit"); limitStr != "" { + if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 { + params.Limit = limit + } + } + if params.Limit > 100 { + params.Limit = 100 + } + + if tagsStr := c.Query("tags"); tagsStr != "" { + params.Tags = strings.Split(tagsStr, ",") + for i := range params.Tags { + params.Tags[i] = strings.TrimSpace(params.Tags[i]) + } + } + + if minDurationStr := c.Query("min_duration"); minDurationStr != "" { + if minDuration, err := strconv.Atoi(minDurationStr); err == nil && minDuration >= 0 { + params.MinDuration = &minDuration + } + } + + if maxDurationStr := c.Query("max_duration"); maxDurationStr != "" { + if maxDuration, err := strconv.Atoi(maxDurationStr); err == nil && maxDuration >= 0 { + params.MaxDuration = &maxDuration + } + } + + if minBPMStr := c.Query("min_bpm"); minBPMStr != "" { + if minBPM, err := strconv.Atoi(minBPMStr); err == nil && minBPM >= 0 { + params.MinBPM = &minBPM + } + } + + if maxBPMStr := c.Query("max_bpm"); maxBPMStr != "" { + if maxBPM, err := strconv.Atoi(maxBPMStr); err == nil && maxBPM >= 0 { + params.MaxBPM = &maxBPM + } + } + + if genre := c.Query("genre"); genre != "" { + params.Genre = &genre + } + + if format := c.Query("format"); format != "" { + params.Format = &format + } + + if musicalKey := c.Query("musical_key"); musicalKey != "" { + params.MusicalKey = &musicalKey + } + + if minDate := c.Query("min_date"); minDate != "" { + params.MinDate = &minDate + } + + if maxDate := c.Query("max_date"); maxDate != "" { + params.MaxDate = &maxDate + } + + tracks, total, err := h.searchService.SearchTracks(c.Request.Context(), params) + if err != nil { + h.respondWithError(c, http.StatusInternalServerError, "failed to search tracks") + return + } + + totalPages := (int(total) + params.Limit - 1) / params.Limit + if totalPages == 0 { + totalPages = 1 + } + + c.JSON(http.StatusOK, gin.H{ + "tracks": tracks, + "pagination": gin.H{ + "page": params.Page, + "limit": params.Limit, + "total": total, + "total_pages": totalPages, + }, + }) +} diff --git a/veza-backend-api/internal/core/track/track_social_handler.go b/veza-backend-api/internal/core/track/track_social_handler.go new file mode 100644 index 000000000..96d219b1c --- /dev/null +++ b/veza-backend-api/internal/core/track/track_social_handler.go @@ -0,0 +1,308 @@ +package track + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/google/uuid" + + "veza-backend-api/internal/common" + apperrors "veza-backend-api/internal/errors" + "veza-backend-api/internal/handlers" + "veza-backend-api/internal/services" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// CreateShareRequest représente la requête pour créer un lien de partage +type CreateShareRequest struct { + Permissions string `json:"permissions" binding:"required" validate:"required,oneof=read write admin"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +// LikeTrack gère l'ajout d'un like sur un track +func (h *TrackHandler) LikeTrack(c *gin.Context) { + userID, ok := h.getUserID(c) + if !ok { + return + } + + trackIDStr := c.Param("id") + if trackIDStr == "" { + h.respondWithError(c, http.StatusBadRequest, "track id is required") + return + } + + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + if err := h.likeService.LikeTrack(c.Request.Context(), userID, trackID); err != nil { + if err.Error() == "track not found" { + h.respondWithError(c, http.StatusNotFound, "track not found") + return + } + h.respondWithError(c, http.StatusInternalServerError, err.Error()) + return + } + + // Phase 2.2: Create notification for track creator (skip if user likes own track) + if h.notificationService != nil { + track, err := h.trackService.GetTrackByID(c.Request.Context(), trackID) + if err == nil && track.UserID != userID { + link := "/tracks/" + trackID.String() + if err := h.notificationService.CreateNotification(track.UserID, "like", "New like", "Someone liked your track", link); err != nil { + // Log but don't fail the request + } + } + } + + c.JSON(http.StatusOK, gin.H{"message": "track liked"}) +} + +// UnlikeTrack gère la suppression d'un like sur un track +func (h *TrackHandler) UnlikeTrack(c *gin.Context) { + userID, ok := h.getUserID(c) + if !ok { + return + } + + trackIDStr := c.Param("id") + if trackIDStr == "" { + h.respondWithError(c, http.StatusBadRequest, "track id is required") + return + } + + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + if err := h.likeService.UnlikeTrack(c.Request.Context(), userID, trackID); err != nil { + h.respondWithError(c, http.StatusInternalServerError, err.Error()) + return + } + + handlers.RespondSuccess(c, http.StatusOK, gin.H{"message": "track unliked"}) +} + +// GetTrackLikes gère la récupération du nombre de likes d'un track +func (h *TrackHandler) GetTrackLikes(c *gin.Context) { + trackIDStr := c.Param("id") + if trackIDStr == "" { + h.respondWithError(c, http.StatusBadRequest, "track id is required") + return + } + + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + count, err := h.likeService.GetTrackLikesCount(c.Request.Context(), trackID) + if err != nil { + h.respondWithError(c, http.StatusInternalServerError, err.Error()) + return + } + + var isLiked bool + if userIDInterface, exists := c.Get("user_id"); exists { + userID, ok := userIDInterface.(uuid.UUID) + if ok && userID != uuid.Nil { + isLiked, _ = h.likeService.IsLiked(c.Request.Context(), userID, trackID) + } + } + + handlers.RespondSuccess(c, http.StatusOK, gin.H{ + "count": count, + "is_liked": isLiked, + }) +} + +// GetUserLikedTracks gère la récupération des tracks likés par un utilisateur +func (h *TrackHandler) GetUserLikedTracks(c *gin.Context) { + userIDStr := c.Param("id") + if userIDStr == "" { + handlers.RespondWithAppError(c, apperrors.NewValidationError("user id is required")) + return + } + + userID, err := uuid.Parse(userIDStr) + if err != nil { + handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid user id")) + return + } + + limit := 20 + if limitStr := c.Query("limit"); limitStr != "" { + if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 { + if parsedLimit > 100 { + parsedLimit = 100 + } + limit = parsedLimit + } + } + + offset := 0 + if offsetStr := c.Query("offset"); offsetStr != "" { + if parsedOffset, err := strconv.Atoi(offsetStr); err == nil && parsedOffset >= 0 { + offset = parsedOffset + } + } + + tracks, err := h.likeService.GetUserLikedTracks(c.Request.Context(), userID, limit, offset) + if err != nil { + handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get user liked tracks", err)) + return + } + + total, err := h.likeService.GetUserLikedTracksCount(c.Request.Context(), userID) + if err != nil { + handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get user liked tracks count", err)) + return + } + + handlers.RespondSuccess(c, http.StatusOK, gin.H{ + "tracks": tracks, + "total": total, + "limit": limit, + "offset": offset, + }) +} + +// CreateShare crée un nouveau lien de partage pour un track +func (h *TrackHandler) CreateShare(c *gin.Context) { + userID, ok := h.getUserID(c) + if !ok { + return + } + + trackIDStr := c.Param("id") + if trackIDStr == "" { + h.respondWithError(c, http.StatusBadRequest, "track id is required") + return + } + + trackID, err := uuid.Parse(trackIDStr) + if err != nil { + h.respondWithError(c, http.StatusBadRequest, "invalid track id") + return + } + + if h.shareService == nil { + h.respondWithError(c, http.StatusInternalServerError, "share service not available") + return + } + + var req CreateShareRequest + if !common.BindAndValidateJSON(c, &req) { + return + } + + share, err := h.shareService.CreateShare(c.Request.Context(), trackID, userID, req.Permissions, req.ExpiresAt) + if err != nil { + if errors.Is(err, services.ErrForbidden) { + h.respondWithError(c, http.StatusForbidden, "forbidden") + return + } + if errors.Is(err, services.ErrTrackNotFound) { + h.respondWithError(c, http.StatusNotFound, "track not found") + return + } + h.respondWithError(c, http.StatusInternalServerError, "failed to create share") + return + } + + handlers.RespondSuccess(c, http.StatusOK, gin.H{"share": share}) +} + +// GetSharedTrack récupère un track via son token de partage +func (h *TrackHandler) GetSharedTrack(c *gin.Context) { + token := c.Param("token") + if token == "" { + handlers.RespondWithAppError(c, apperrors.NewValidationError("share token is required")) + return + } + + if h.shareService == nil { + handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "share service not available", nil)) + return + } + + share, err := h.shareService.ValidateShareToken(c.Request.Context(), token) + if err != nil { + if errors.Is(err, services.ErrShareNotFound) { + handlers.RespondWithAppError(c, apperrors.NewNotFoundError("share")) + return + } + if errors.Is(err, services.ErrShareExpired) { + handlers.RespondWithAppError(c, apperrors.NewForbiddenError("share link expired")) + return + } + handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to validate share token", err)) + return + } + + track, err := h.trackService.GetTrackByID(c.Request.Context(), share.TrackID) + if err != nil { + if errors.Is(err, ErrTrackNotFound) || errors.Is(err, gorm.ErrRecordNotFound) { + handlers.RespondWithAppError(c, apperrors.NewNotFoundError("track")) + return + } + handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to get track", err)) + return + } + + handlers.RespondSuccess(c, http.StatusOK, gin.H{ + "track": track, + "share": share, + }) +} + +// RevokeShare révoque un lien de partage +func (h *TrackHandler) RevokeShare(c *gin.Context) { + userID, ok := h.getUserID(c) + if !ok { + return + } + + shareIDStr := c.Param("id") + if shareIDStr == "" { + handlers.RespondWithAppError(c, apperrors.NewValidationError("share id is required")) + return + } + + shareID, err := uuid.Parse(shareIDStr) + if err != nil { + handlers.RespondWithAppError(c, apperrors.NewValidationError("invalid share id")) + return + } + + if h.shareService == nil { + handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "share service not available", nil)) + return + } + + err = h.shareService.RevokeShare(c.Request.Context(), shareID, userID) + if err != nil { + if errors.Is(err, services.ErrShareNotFound) { + handlers.RespondWithAppError(c, apperrors.NewNotFoundError("share")) + return + } + if errors.Is(err, services.ErrForbidden) { + handlers.RespondWithAppError(c, apperrors.NewForbiddenError("forbidden")) + return + } + handlers.RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to revoke share", err)) + return + } + + handlers.RespondSuccess(c, http.StatusOK, gin.H{"message": "share revoked"}) +} diff --git a/veza-backend-api/internal/handlers/sell_handler.go b/veza-backend-api/internal/handlers/sell_handler.go new file mode 100644 index 000000000..244452a74 --- /dev/null +++ b/veza-backend-api/internal/handlers/sell_handler.go @@ -0,0 +1,127 @@ +package handlers + +import ( + "errors" + "net/http" + + "veza-backend-api/internal/services" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// SellHandler handles Stripe Connect seller payout endpoints +type SellHandler struct { + stripeConnect *services.StripeConnectService + logger *zap.Logger +} + +// NewSellHandler creates a new SellHandler +func NewSellHandler(stripeConnect *services.StripeConnectService, logger *zap.Logger) *SellHandler { + return &SellHandler{ + stripeConnect: stripeConnect, + logger: logger, + } +} + +// ConnectOnboardRequest is the request body for onboarding +type ConnectOnboardRequest struct { + ReturnURL string `json:"return_url"` + RefreshURL string `json:"refresh_url"` +} + +// ConnectOnboard starts Stripe Connect onboarding and returns the onboarding URL +func (h *SellHandler) ConnectOnboard(c *gin.Context) { + if h.stripeConnect == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe Connect is not enabled"}) + return + } + + userID, ok := GetUserIDUUID(c) + if !ok { + return + } + + var req ConnectOnboardRequest + _ = c.ShouldBindJSON(&req) + returnURL := req.ReturnURL + if returnURL == "" { + returnURL = c.Request.URL.Scheme + "://" + c.Request.Host + "/sell/dashboard?onboarded=success" + } + refreshURL := req.RefreshURL + if refreshURL == "" { + refreshURL = c.Request.URL.Scheme + "://" + c.Request.Host + "/sell/dashboard?onboarded=refresh" + } + + url, err := h.stripeConnect.CreateOnboardingLink(c.Request.Context(), userID, returnURL, refreshURL) + if err != nil { + if errors.Is(err, services.ErrStripeConnectDisabled) { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe Connect is not enabled"}) + return + } + h.logger.Error("CreateOnboardingLink failed", zap.Error(err), zap.String("user_id", userID.String())) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create onboarding link"}) + return + } + + RespondSuccess(c, http.StatusOK, gin.H{"onboarding_url": url}) +} + +// ConnectCallback syncs account status after Stripe redirect (called by frontend after return) +func (h *SellHandler) ConnectCallback(c *gin.Context) { + if h.stripeConnect == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe Connect is not enabled"}) + return + } + + userID, ok := GetUserIDUUID(c) + if !ok { + return + } + + if err := h.stripeConnect.HandleOnboardingCallback(c.Request.Context(), userID); err != nil { + if errors.Is(err, services.ErrNoStripeAccount) { + c.JSON(http.StatusNotFound, gin.H{"error": "No Stripe account found"}) + return + } + if errors.Is(err, services.ErrStripeConnectDisabled) { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe Connect is not enabled"}) + return + } + h.logger.Error("HandleOnboardingCallback failed", zap.Error(err), zap.String("user_id", userID.String())) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync account status"}) + return + } + + RespondSuccess(c, http.StatusOK, gin.H{"message": "Account synced"}) +} + +// GetBalance returns the seller's Stripe Connect balance +func (h *SellHandler) GetBalance(c *gin.Context) { + if h.stripeConnect == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe Connect is not enabled"}) + return + } + + userID, ok := GetUserIDUUID(c) + if !ok { + return + } + + bal, err := h.stripeConnect.GetBalance(c.Request.Context(), userID) + if err != nil { + if errors.Is(err, services.ErrStripeConnectDisabled) { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe Connect is not enabled"}) + return + } + h.logger.Error("GetBalance failed", zap.Error(err), zap.String("user_id", userID.String())) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get balance"}) + return + } + + RespondSuccess(c, http.StatusOK, gin.H{ + "connected": bal.Connected, + "available": bal.Available, + "pending": bal.Pending, + }) +} diff --git a/veza-backend-api/internal/monitoring/metrics.go b/veza-backend-api/internal/monitoring/metrics.go index 1f68d7a4c..87c083341 100644 --- a/veza-backend-api/internal/monitoring/metrics.go +++ b/veza-backend-api/internal/monitoring/metrics.go @@ -172,6 +172,23 @@ var ( }, ) + // v0.602 INF2: Commerce Metrics + CommerceOrdersTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "veza_commerce_orders_total", + Help: "Total number of commerce orders created", + }, + []string{"status"}, + ) + + CommerceCheckoutDuration = promauto.NewHistogram( + prometheus.HistogramOpts{ + Name: "veza_commerce_checkout_duration_seconds", + Help: "Checkout (cart to order) duration in seconds", + Buckets: []float64{0.1, 0.25, 0.5, 1.0, 2.0, 5.0}, + }, + ) + UsersRegisteredTotal = promauto.NewCounter( prometheus.CounterOpts{ Name: "veza_users_registered_total", diff --git a/veza-backend-api/internal/services/oauth_service_test.go b/veza-backend-api/internal/services/oauth_service_test.go index 818fd3635..01cdbac7c 100644 --- a/veza-backend-api/internal/services/oauth_service_test.go +++ b/veza-backend-api/internal/services/oauth_service_test.go @@ -2,7 +2,10 @@ package services import ( "database/sql" + "io" + "net/http" "regexp" + "strings" "testing" "time" @@ -150,3 +153,146 @@ func TestOAuthService_ValidateStateToken_Expired(t *testing.T) { assert.Nil(t, state) assert.NoError(t, mock.ExpectationsWereMet()) } + +func TestOAuthService_GetAuthURL_Discord(t *testing.T) { + db, mock := setupMockDB(t) + defer db.DB.Close() + + svc := NewOAuthService(db, zap.NewNop(), []byte("secret")) + svc.InitializeConfigs("", "", "", "", "discord-client", "discord-secret", "", "", "http://localhost:8080") + + mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO oauth_states`)). + WithArgs(sqlmock.AnyArg(), "discord", "", sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + + url, err := svc.GetAuthURL("discord") + + assert.NoError(t, err) + assert.NotEmpty(t, url) + assert.Contains(t, url, "discord.com/api/oauth2/authorize") + assert.Contains(t, url, "identify") + assert.Contains(t, url, "email") + assert.Contains(t, url, "discord-client") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestOAuthService_GetAuthURL_Spotify(t *testing.T) { + db, mock := setupMockDB(t) + defer db.DB.Close() + + svc := NewOAuthService(db, zap.NewNop(), []byte("secret")) + svc.InitializeConfigs("", "", "", "", "", "", "spotify-client", "spotify-secret", "http://localhost:8080") + + mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO oauth_states`)). + WithArgs(sqlmock.AnyArg(), "spotify", "", sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + + url, err := svc.GetAuthURL("spotify") + + assert.NoError(t, err) + assert.NotEmpty(t, url) + assert.Contains(t, url, "accounts.spotify.com/authorize") + assert.Contains(t, url, "user-read-email") + assert.Contains(t, url, "user-read-private") + assert.Contains(t, url, "spotify-client") + assert.NoError(t, mock.ExpectationsWereMet()) +} + +func TestOAuthService_GetAvailableProviders(t *testing.T) { + db, _ := setupMockDB(t) + defer db.DB.Close() + + svc := NewOAuthService(db, zap.NewNop(), []byte("secret")) + providers := svc.GetAvailableProviders() + assert.Empty(t, providers) + + svc.InitializeConfigs("", "", "gid", "gsec", "did", "dsec", "sid", "ssec", "http://localhost") // github, discord, spotify + providers = svc.GetAvailableProviders() + assert.Contains(t, providers, "github") + assert.Contains(t, providers, "discord") + assert.Contains(t, providers, "spotify") + assert.Len(t, providers, 3) +} + +// mockOAuthTransport mocks HTTP responses for Discord/Spotify user info APIs +type mockOAuthTransport struct { + discordResponse string + spotifyResponse string +} + +func (m *mockOAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { + url := req.URL.String() + body := "" + if strings.Contains(url, "discord.com") { + body = m.discordResponse + } else if strings.Contains(url, "spotify.com") { + body = m.spotifyResponse + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil +} + +func TestOAuthService_GetUserInfo_Discord(t *testing.T) { + discordJSON := `{"id":"123456","username":"testuser","email":"test@example.com","avatar":"abc123"}` + transport := &mockOAuthTransport{discordResponse: discordJSON} + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + + db, mock := setupMockDB(t) + defer db.DB.Close() + + svc := NewOAuthService(db, zap.NewNop(), []byte("secret")) + svc.InitializeConfigs("", "", "", "", "did", "dsec", "", "", "http://localhost") + svc.circuitBreaker = NewCircuitBreakerHTTPClient(client, "oauth-test", zap.NewNop()) + + user, err := svc.getUserInfo("discord", "fake-token") + require.NoError(t, err) + assert.Equal(t, "123456", user.ProviderID) + assert.Equal(t, "testuser", user.Username) + assert.Equal(t, "test@example.com", user.Email) + assert.Equal(t, "testuser", user.Name) + assert.Equal(t, "abc123", user.Avatar) + _ = mock +} + +func TestOAuthService_GetUserInfo_Spotify(t *testing.T) { + spotifyJSON := `{"id":"spot123","display_name":"Spot User","email":"spot@example.com","images":[{"url":"https://avatar.url"}]}` + transport := &mockOAuthTransport{spotifyResponse: spotifyJSON} + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + + db, _ := setupMockDB(t) + defer db.DB.Close() + + svc := NewOAuthService(db, zap.NewNop(), []byte("secret")) + svc.InitializeConfigs("", "", "", "", "", "", "sid", "ssec", "http://localhost") + svc.circuitBreaker = NewCircuitBreakerHTTPClient(client, "oauth-test", zap.NewNop()) + + user, err := svc.getUserInfo("spotify", "fake-token") + require.NoError(t, err) + assert.Equal(t, "spot123", user.ProviderID) + assert.Equal(t, "Spot User", user.Username) + assert.Equal(t, "spot@example.com", user.Email) + assert.Equal(t, "https://avatar.url", user.Avatar) +} + +func TestOAuthService_GetUserInfo_Spotify_FallbackEmail(t *testing.T) { + // Spotify without email - should fallback to id@spotify.user + spotifyJSON := `{"id":"spot456","display_name":"","images":[]}` + transport := &mockOAuthTransport{spotifyResponse: spotifyJSON} + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + + db, _ := setupMockDB(t) + defer db.DB.Close() + + svc := NewOAuthService(db, zap.NewNop(), []byte("secret")) + svc.InitializeConfigs("", "", "", "", "", "", "sid", "ssec", "http://localhost") + svc.circuitBreaker = NewCircuitBreakerHTTPClient(client, "oauth-test", zap.NewNop()) + + user, err := svc.getUserInfo("spotify", "fake-token") + require.NoError(t, err) + assert.Equal(t, "spot456", user.ProviderID) + assert.Equal(t, "spot456", user.Username) // fallback to ID when display_name empty + assert.Equal(t, "spot456@spotify.user", user.Email) +} diff --git a/veza-backend-api/internal/services/stripe_connect_service.go b/veza-backend-api/internal/services/stripe_connect_service.go new file mode 100644 index 000000000..d1799d5ac --- /dev/null +++ b/veza-backend-api/internal/services/stripe_connect_service.go @@ -0,0 +1,209 @@ +package services + +import ( + "context" + "errors" + "fmt" + + "github.com/google/uuid" + "github.com/stripe/stripe-go/v82" + "github.com/stripe/stripe-go/v82/account" + "github.com/stripe/stripe-go/v82/accountlink" + "github.com/stripe/stripe-go/v82/balance" + "github.com/stripe/stripe-go/v82/transfer" + "go.uber.org/zap" + "gorm.io/gorm" + + "veza-backend-api/internal/models" +) + +var ( + ErrStripeConnectDisabled = errors.New("Stripe Connect is not enabled") + ErrNoStripeAccount = errors.New("seller has no Stripe Connect account") +) + +// BalanceResponse is the balance for a connected account +type BalanceResponse struct { + Connected bool `json:"connected"` // true if seller has completed Stripe Connect onboarding + Available int64 `json:"available"` // in minor units (cents) + Pending int64 `json:"pending"` // in minor units (cents) +} + +// StripeConnectService handles Stripe Connect operations for seller payouts +type StripeConnectService struct { + db *gorm.DB + secretKey string + logger *zap.Logger +} + +// NewStripeConnectService creates a new Stripe Connect service +func NewStripeConnectService(db *gorm.DB, secretKey string, logger *zap.Logger) *StripeConnectService { + return &StripeConnectService{ + db: db, + secretKey: secretKey, + logger: logger, + } +} + +// CreateOnboardingLink creates or retrieves a Stripe Express account and returns an onboarding URL +func (s *StripeConnectService) CreateOnboardingLink(ctx context.Context, userID uuid.UUID, returnURL, refreshURL string) (string, error) { + if s.secretKey == "" { + return "", ErrStripeConnectDisabled + } + stripe.Key = s.secretKey + + // Get user email + var user models.User + if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", fmt.Errorf("user not found: %w", err) + } + return "", err + } + + // Check if account already exists + var existing models.SellerStripeAccount + if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&existing).Error; err == nil { + // Account exists, create new account link for onboarding/update + params := &stripe.AccountLinkParams{ + Account: stripe.String(existing.StripeAccountID), + ReturnURL: stripe.String(returnURL), + RefreshURL: stripe.String(refreshURL), + Type: stripe.String(string(stripe.AccountLinkTypeAccountOnboarding)), + } + link, err := accountlink.New(params) + if err != nil { + return "", fmt.Errorf("create account link: %w", err) + } + return link.URL, nil + } + + // Create new Express account + accountParams := &stripe.AccountParams{ + Type: stripe.String(string(stripe.AccountTypeExpress)), + Email: stripe.String(user.Email), + } + acct, err := account.New(accountParams) + if err != nil { + return "", fmt.Errorf("create stripe account: %w", err) + } + + // Save to DB + sa := models.SellerStripeAccount{ + UserID: userID, + StripeAccountID: acct.ID, + OnboardingCompleted: false, + } + if err := s.db.WithContext(ctx).Create(&sa).Error; err != nil { + return "", fmt.Errorf("save seller stripe account: %w", err) + } + + // Create account link + linkParams := &stripe.AccountLinkParams{ + Account: stripe.String(acct.ID), + ReturnURL: stripe.String(returnURL), + RefreshURL: stripe.String(refreshURL), + Type: stripe.String(string(stripe.AccountLinkTypeAccountOnboarding)), + } + link, err := accountlink.New(linkParams) + if err != nil { + return "", fmt.Errorf("create account link: %w", err) + } + return link.URL, nil +} + +// HandleOnboardingCallback updates the account status after Stripe redirect +func (s *StripeConnectService) HandleOnboardingCallback(ctx context.Context, userID uuid.UUID) error { + var sa models.SellerStripeAccount + if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&sa).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNoStripeAccount + } + return err + } + + if s.secretKey == "" { + return ErrStripeConnectDisabled + } + stripe.Key = s.secretKey + + // Retrieve account from Stripe to get charges_enabled, payouts_enabled + params := &stripe.AccountParams{} + acct, err := account.GetByID(sa.StripeAccountID, params) + if err != nil { + return fmt.Errorf("get stripe account: %w", err) + } + + sa.ChargesEnabled = acct.ChargesEnabled + sa.PayoutsEnabled = acct.PayoutsEnabled + sa.OnboardingCompleted = acct.DetailsSubmitted + if err := s.db.WithContext(ctx).Save(&sa).Error; err != nil { + return fmt.Errorf("update seller stripe account: %w", err) + } + return nil +} + +// GetBalance returns the balance for a seller's connected account +func (s *StripeConnectService) GetBalance(ctx context.Context, userID uuid.UUID) (*BalanceResponse, error) { + var sa models.SellerStripeAccount + if err := s.db.WithContext(ctx).Where("user_id = ?", userID).First(&sa).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &BalanceResponse{Connected: false, Available: 0, Pending: 0}, nil + } + return nil, err + } + + if s.secretKey == "" { + return nil, ErrStripeConnectDisabled + } + stripe.Key = s.secretKey + + params := &stripe.BalanceParams{} + params.SetStripeAccount(sa.StripeAccountID) + bal, err := balance.Get(params) + if err != nil { + return nil, fmt.Errorf("get stripe balance: %w", err) + } + + var available, pending int64 + for _, a := range bal.Available { + available += a.Amount + } + for _, p := range bal.Pending { + pending += p.Amount + } + return &BalanceResponse{Connected: true, Available: available, Pending: pending}, nil +} + +// CreateTransfer transfers funds to a connected account (for payout on sale) +func (s *StripeConnectService) CreateTransfer(ctx context.Context, sellerUserID uuid.UUID, amount int64, currency, orderID string) error { + var sa models.SellerStripeAccount + if err := s.db.WithContext(ctx).Where("user_id = ?", sellerUserID).First(&sa).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNoStripeAccount + } + return err + } + if !sa.PayoutsEnabled { + return fmt.Errorf("seller account does not have payouts enabled") + } + + if s.secretKey == "" { + return ErrStripeConnectDisabled + } + stripe.Key = s.secretKey + + params := &stripe.TransferParams{ + Amount: stripe.Int64(amount), + Currency: stripe.String(currency), + Destination: stripe.String(sa.StripeAccountID), + } + if orderID != "" { + params.AddMetadata("order_id", orderID) + } + _, err := transfer.New(params) + if err != nil { + return fmt.Errorf("create stripe transfer: %w", err) + } + return nil +} diff --git a/veza-backend-api/internal/utils/sanitizer.go b/veza-backend-api/internal/utils/sanitizer.go index eb3335f2d..e921c30b9 100644 --- a/veza-backend-api/internal/utils/sanitizer.go +++ b/veza-backend-api/internal/utils/sanitizer.go @@ -143,9 +143,11 @@ func SanitizeHTML(input string, maxLength int) string { iframePattern := regexp.MustCompile(`(?i)]*>.*?`) cleaned = iframePattern.ReplaceAllString(cleaned, "") - // Remove object and embed tags - objectPattern := regexp.MustCompile(`(?i)<(object|embed)[^>]*>.*?`) + // Remove object and embed tags (Go regexp has no backreferences, use two patterns) + objectPattern := regexp.MustCompile(`(?i)]*>.*?`) cleaned = objectPattern.ReplaceAllString(cleaned, "") + embedPattern := regexp.MustCompile(`(?i)]*>.*?`) + cleaned = embedPattern.ReplaceAllString(cleaned, "") // Remove dangerous event handlers (onclick, onerror, etc.) eventHandlerPattern := regexp.MustCompile(`(?i)\s*on\w+\s*=\s*["'][^"']*["']`) diff --git a/veza-backend-api/tests/marketplace/marketplace_flow_test.go b/veza-backend-api/tests/marketplace/marketplace_flow_test.go index 72f0d5f22..6ce63f33d 100644 --- a/veza-backend-api/tests/marketplace/marketplace_flow_test.go +++ b/veza-backend-api/tests/marketplace/marketplace_flow_test.go @@ -70,6 +70,9 @@ func setupMarketplaceFlowTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *datab product_type TEXT NOT NULL, track_id TEXT, license_type TEXT, + bpm INTEGER, + musical_key TEXT, + category TEXT, created_at DATETIME, updated_at DATETIME, deleted_at DATETIME @@ -86,12 +89,29 @@ func setupMarketplaceFlowTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *datab currency TEXT DEFAULT 'EUR', status TEXT DEFAULT 'pending', payment_intent TEXT, + hyperswitch_payment_id TEXT, + payment_status TEXT DEFAULT 'pending', + promo_code_id TEXT, + discount_amount_cents INTEGER DEFAULT 0, created_at DATETIME, updated_at DATETIME ) `).Error require.NoError(t, err) + // Product images table (for Preload in CreateProduct response) + err = db.Exec(` + CREATE TABLE IF NOT EXISTS product_images ( + id TEXT PRIMARY KEY, + product_id TEXT NOT NULL, + url TEXT NOT NULL, + sort_order INTEGER DEFAULT 0, + created_at DATETIME, + FOREIGN KEY(product_id) REFERENCES products(id) ON DELETE CASCADE + ) + `).Error + require.NoError(t, err) + // Order items table err = db.Exec(` CREATE TABLE IF NOT EXISTS order_items ( @@ -104,7 +124,21 @@ func setupMarketplaceFlowTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *datab `).Error require.NoError(t, err) - // Licenses table + // Product licenses table (license types per product) + err = db.Exec(` + CREATE TABLE IF NOT EXISTS product_licenses ( + id TEXT PRIMARY KEY, + product_id TEXT NOT NULL, + license_type TEXT NOT NULL, + price_cents INTEGER NOT NULL, + terms_text TEXT, + created_at DATETIME, + FOREIGN KEY(product_id) REFERENCES products(id) ON DELETE CASCADE + ) + `).Error + require.NoError(t, err) + + // Licenses table (purchased licenses) err = db.Exec(` CREATE TABLE IF NOT EXISTS licenses ( id TEXT PRIMARY KEY, @@ -116,7 +150,8 @@ func setupMarketplaceFlowTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *datab rights TEXT, downloads_left INTEGER DEFAULT 3, created_at DATETIME, - expires_at DATETIME + expires_at DATETIME, + revoked_at DATETIME ) `).Error require.NoError(t, err) @@ -207,6 +242,24 @@ func setupMarketplaceFlowTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *datab c.Next() }) + // product_reviews table for review tests (v0.602 E2E) + err = db.Exec(` + CREATE TABLE IF NOT EXISTS product_reviews ( + id TEXT PRIMARY KEY, + product_id TEXT NOT NULL, + buyer_id TEXT NOT NULL, + order_id TEXT NOT NULL, + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + comment TEXT, + created_at DATETIME, + UNIQUE(product_id, buyer_id), + FOREIGN KEY(product_id) REFERENCES products(id), + FOREIGN KEY(buyer_id) REFERENCES users(id), + FOREIGN KEY(order_id) REFERENCES orders(id) + ) + `).Error + require.NoError(t, err) + // Setup marketplace routes marketplaceGroup := router.Group("/api/v1/marketplace") { @@ -217,6 +270,9 @@ func setupMarketplaceFlowTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *datab marketplaceGroup.GET("/orders/:id", marketHandler.GetOrder) marketplaceGroup.POST("/orders", marketHandler.CreateOrder) marketplaceGroup.GET("/download/:product_id", marketHandler.GetDownloadURL) + marketplaceGroup.POST("/products/:id/reviews", marketHandler.CreateReview) + marketplaceGroup.GET("/products/:id/reviews", marketHandler.ListReviews) + marketplaceGroup.GET("/orders/:id/invoice", marketHandler.GetOrderInvoice) } cleanup := func() { @@ -355,6 +411,76 @@ func TestMarketplaceFlow_CompleteFlow(t *testing.T) { assert.NotEmpty(t, downloadData["url"]) } +// TestMarketplaceFlow_CompleteFlowWithReviewAndInvoice tests product -> order -> review -> invoice (v0.602 E2E) +func TestMarketplaceFlow_CompleteFlowWithReviewAndInvoice(t *testing.T) { + router, db, _, _, cleanup := setupMarketplaceFlowTestRouter(t) + defer cleanup() + + seller := createTestUser(t, db, "seller@example.com", "seller") + buyer := createTestUser(t, db, "buyer@example.com", "buyer") + track := createTestTrack(t, db, seller.ID, "Track For Review") + + // 1. Create product + createProductReq := handlers.CreateProductRequest{ + Title: "Product For Review", + Description: "Great track", + Price: 12.99, + ProductType: "track", + TrackID: track.ID.String(), + LicenseType: "standard", + } + body, _ := json.Marshal(createProductReq) + req := httptest.NewRequest(http.MethodPost, "/api/v1/marketplace/products", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", seller.ID.String()) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code) + + var productResp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &productResp)) + productData := productResp["data"].(map[string]interface{}) + productID := productData["id"].(string) + + // 2. Create order (simulated payment completes immediately) + orderBody, _ := json.Marshal(handlers.CreateOrderRequest{ + Items: []struct { + ProductID string `json:"product_id" binding:"required" validate:"required,uuid"` + }{{ProductID: productID}}, + }) + req = httptest.NewRequest(http.MethodPost, "/api/v1/marketplace/orders", bytes.NewBuffer(orderBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", buyer.ID.String()) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code) + + var orderResp map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &orderResp)) + orderData := orderResp["data"].(map[string]interface{}) + order := orderData["order"].(map[string]interface{}) + orderID := order["id"].(string) + assert.Equal(t, "completed", order["status"]) + + // 3. Create review + reviewBody, _ := json.Marshal(map[string]interface{}{"rating": 5, "comment": "Excellent!"}) + req = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/marketplace/products/%s/reviews", productID), bytes.NewBuffer(reviewBody)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-User-ID", buyer.ID.String()) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusCreated, w.Code) + + // 4. Download invoice (PDF) + req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/api/v1/marketplace/orders/%s/invoice", orderID), nil) + req.Header.Set("X-User-ID", buyer.ID.String()) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "application/pdf", w.Header().Get("Content-Type")) + assert.NotEmpty(t, w.Body.Bytes()) +} + // TestMarketplaceFlow_CreateProduct_Validation teste la validation lors de la création de produit func TestMarketplaceFlow_CreateProduct_Validation(t *testing.T) { router, db, _, _, cleanup := setupMarketplaceFlowTestRouter(t)