chore(release): v0.602 — Payout, Dette Technique & Tests E2E
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Storybook Audit / Build & audit Storybook (push) Failing after 0s

- Stripe Connect: onboarding, balance, SellerDashboardView
- Interceptors: auth.ts, error.ts extracted, facade
- Grafana: dashboards enriched (p50, top endpoints, 4xx, WS, commerce)
- E2E commerce: product->order->review->invoice
- SMOKE_TEST_V0602, RETROSPECTIVE_V0602, PAYOUT_MANUAL
- Archive V0_602 scope, V0_603 placeholder, SCOPE_CONTROL v0.603
- Fix sanitizer regex (Go no backreferences)
- Marketplace test schema: product_licenses, product_images, orders, licenses
This commit is contained in:
senke 2026-02-23 22:32:01 +01:00
parent 941f6e6f3e
commit 83ed4f315b
45 changed files with 3469 additions and 1393 deletions

View file

@ -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.

View file

@ -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

View file

@ -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<typeof meta>;
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 },
})
),
],
},
},
};

View file

@ -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<SellerDashboardProps> = ({
const [refundOrderId, setRefundOrderId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [balance, setBalance] = useState<{ connected: boolean; available: number; pending: number } | null>(null);
const [stripeConnectAvailable, setStripeConnectAvailable] = useState<boolean | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
@ -67,6 +71,17 @@ export const SellerDashboardView: React.FC<SellerDashboardProps> = ({
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<SellerDashboardProps> = ({
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 (
<div className="flex justify-center py-24">
@ -170,6 +199,42 @@ export const SellerDashboardView: React.FC<SellerDashboardProps> = ({
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* v0.602 P3: Stripe Connect balance card */}
{stripeConnectAvailable !== false && (
<Card variant="default" className="p-6 relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<Wallet className="w-16 h-16 text-primary" />
</div>
<div className="text-muted-foreground text-xs font-bold uppercase mb-1">
Payout Balance
</div>
{balance?.connected ? (
<>
<div className="text-3xl font-mono font-bold text-foreground mb-1">
{(balance.available + balance.pending).toLocaleString(undefined, { minimumFractionDigits: 2 })}
</div>
<div className="text-xs text-muted-foreground">
Available: {balance.available.toFixed(2)} · Pending: {balance.pending.toFixed(2)}
</div>
</>
) : (
<>
<div className="text-sm text-muted-foreground mb-3">
Configure Stripe to receive payouts
</div>
<Button
variant="outline"
size="sm"
icon={<CreditCard className="w-4 h-4" />}
onClick={handleConnectPayments}
>
Configurer les paiements
</Button>
</>
)}
</Card>
)}
<Card variant="default" className="p-6 relative overflow-hidden group">
<div className="absolute right-0 top-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity">
<DollarSign className="w-16 h-16 text-warning" />

View file

@ -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,

View file

@ -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);
}

View file

@ -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<ApiResponse<unknown> | 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<string, unknown>)?.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<unknown>;
}
}
}
}
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<unknown>;
}
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<ApiResponse<unknown>>(
(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<ApiResponse<unknown>>,
);
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;
};
}

View file

@ -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<string, unknown> = Array.isArray(data)
? Object.fromEntries((data as unknown[]).map((v, i) => [String(i), v]))
: { ...(data as Record<string, unknown>) };
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<string, unknown>)?.['X-Request-ID'] ||
(config.headers as Record<string, unknown>)?.['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('<!doctype') || trimmed.startsWith('<html');
}
return false;
}
export function getDefaultSuccessMessage(method: string): string {
switch (method) {
case 'POST':
return 'Opération réussie';
case 'PUT':
case 'PATCH':
return 'Modification réussie';
case 'DELETE':
return 'Suppression réussie';
default:
return '';
}
}

View file

@ -163,4 +163,27 @@ export const commerceService = {
});
return { success: true };
},
// v0.602 P3: Stripe Connect seller balance (amounts in cents from API, returned in euros)
getSellerBalance: async (): Promise<{ connected: boolean; available: number; pending: number }> => {
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 ?? '',
};
},
};

View file

@ -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');
* }
* });
* }, []);

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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

View file

@ -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

34
docs/PAYOUT_MANUAL.md Normal file
View file

@ -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)

View file

@ -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 |

View file

@ -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 (103108)
- 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é) |

View file

@ -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 dattention
- **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

View file

@ -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 dattention
- **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 na 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

View file

@ -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.

40
docs/SMOKE_TEST_V0601.md Normal file
View file

@ -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 derreurs console en prod

45
docs/SMOKE_TEST_V0602.md Normal file
View file

@ -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)

View file

@ -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)

View file

@ -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 |

View file

@ -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=

13
package-lock.json generated
View file

@ -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": {}
}
}

View file

@ -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"

View file

@ -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

View file

@ -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=

View file

@ -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")

View file

@ -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

View file

@ -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

File diff suppressed because it is too large Load diff

View file

@ -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"})
}

View file

@ -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,
})
}

View file

@ -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,
},
})
}

View file

@ -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"})
}

View file

@ -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,
})
}

View file

@ -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",

View file

@ -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)
}

View file

@ -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
}

View file

@ -143,9 +143,11 @@ func SanitizeHTML(input string, maxLength int) string {
iframePattern := regexp.MustCompile(`(?i)<iframe[^>]*>.*?</iframe>`)
cleaned = iframePattern.ReplaceAllString(cleaned, "")
// Remove object and embed tags
objectPattern := regexp.MustCompile(`(?i)<(object|embed)[^>]*>.*?</\1>`)
// Remove object and embed tags (Go regexp has no backreferences, use two patterns)
objectPattern := regexp.MustCompile(`(?i)<object[^>]*>.*?</object>`)
cleaned = objectPattern.ReplaceAllString(cleaned, "")
embedPattern := regexp.MustCompile(`(?i)<embed[^>]*>.*?</embed>`)
cleaned = embedPattern.ReplaceAllString(cleaned, "")
// Remove dangerous event handlers (onclick, onerror, etc.)
eventHandlerPattern := regexp.MustCompile(`(?i)\s*on\w+\s*=\s*["'][^"']*["']`)

View file

@ -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)