chore(release): v0.602 — Payout, Dette Technique & Tests E2E
- 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:
parent
941f6e6f3e
commit
83ed4f315b
45 changed files with 3469 additions and 1393 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
41
CHANGELOG.md
41
CHANGELOG.md
|
|
@ -1,5 +1,46 @@
|
|||
# Changelog - Veza
|
||||
|
||||
## [v0.602] - 2026-02-23
|
||||
|
||||
### Added
|
||||
- Stripe Connect seller payout (onboarding, balance, transfer)
|
||||
- seller_stripe_accounts migration (114)
|
||||
- Commerce E2E tests (backend integration: product -> order -> review -> invoice)
|
||||
- docs/SMOKE_TEST_V0602.md
|
||||
- docs/PAYOUT_MANUAL.md (manual payout procedure for v0.603)
|
||||
|
||||
### Changed
|
||||
- interceptors.ts split: auth.ts and error.ts extracted (facade < 30 LOC)
|
||||
- Grafana dashboards enriched with real Prometheus metrics
|
||||
- sanitizer.go: fix invalid regex backreference for object/embed tags (Go regexp has no \1)
|
||||
|
||||
### Infrastructure
|
||||
- Commerce Prometheus metrics (orders_total, checkout_duration)
|
||||
|
||||
---
|
||||
|
||||
## [v0.601] - 2026-02-23
|
||||
|
||||
### Added
|
||||
- Blue-green deployment via HAProxy (backend-api-blue/green, stream-server-blue/green, deploy-blue-green.sh)
|
||||
- 3 Grafana dashboards: api-overview, chat-overview, commerce-overview
|
||||
- Alertmanager config with Slack/email receivers, wired to Prometheus
|
||||
- Hyperswitch LIVE_MODE configuration (HYPERSWITCH_LIVE_MODE env)
|
||||
- OAuth Discord and Spotify unit tests (GetAuthURL, GetUserInfo, GetAvailableProviders)
|
||||
- docs/MIGRATIONS.md documenting squash script and baseline procedure
|
||||
|
||||
### Changed
|
||||
- handler.go split into 4 sub-handlers: track_crud_handler, track_social_handler, track_search_handler, track_analytics_handler (~163 LOC facade)
|
||||
- interceptors.ts split into modules: interceptors/utils, interceptors/request, interceptors/response
|
||||
- squash_migrations.sh: baseline_v0601.sql, migrations 001-113, output to file
|
||||
|
||||
### Infrastructure
|
||||
- docker-compose.prod.yml: blue-green services, Alertmanager (port 9093)
|
||||
- config/alertmanager/alertmanager.yml
|
||||
- config/prometheus.yml: alertmanager_config
|
||||
|
||||
---
|
||||
|
||||
## [v0.503] - 2026-02-22
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
121
apps/web/src/services/api/interceptors/request.ts
Normal file
121
apps/web/src/services/api/interceptors/request.ts
Normal 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);
|
||||
}
|
||||
345
apps/web/src/services/api/interceptors/response.ts
Normal file
345
apps/web/src/services/api/interceptors/response.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
69
apps/web/src/services/api/interceptors/utils.ts
Normal file
69
apps/web/src/services/api/interceptors/utils.ts
Normal 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 '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ?? '',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
* }
|
||||
* });
|
||||
* }, []);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
34
docs/PAYOUT_MANUAL.md
Normal 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)
|
||||
349
docs/PLAN_V0_602_IMPLEMENTATION.md
Normal file
349
docs/PLAN_V0_602_IMPLEMENTATION.md
Normal 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 |
|
||||
|
|
@ -8,10 +8,10 @@
|
|||
|
||||
| Élément | Valeur |
|
||||
|---------|--------|
|
||||
| **Dernier tag** | v0.503 |
|
||||
| **Dernier tag** | v0.602 |
|
||||
| **Branche courante** | `main` |
|
||||
| **Phase** | Phase 5 Streaming & Communication — v0.503 livrée |
|
||||
| **Prochaine version** | v0.601 |
|
||||
| **Phase** | Phase 6+ Payout, Dette Technique & Tests E2E — v0.602 livrée |
|
||||
| **Prochaine version** | v0.603 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -73,6 +73,18 @@
|
|||
- Infra : MinIO S3-compatible (dev, staging, prod), 6 migrations (103–108)
|
||||
- Sécurité : Trivy container scanning CI
|
||||
|
||||
### v0.602 (Phase 6+ Payout, Dette Technique & Tests E2E)
|
||||
- CLN2 : Split interceptors auth.ts, error.ts, facade < 30 LOC
|
||||
- P3 : Stripe Connect payout (onboarding, balance, seller_stripe_accounts ; transfer manuel doc pour v0.603)
|
||||
- INF2 : Grafana dashboards enrichis (p50, top endpoints, 4xx, WS connections, messages/s, orders, refunds, payout)
|
||||
- QA2 : E2E commerce backend (product -> order -> review -> invoice), SMOKE_TEST_V0602.md
|
||||
|
||||
### v0.601 (Phase 6 — Production Readiness & Commerce)
|
||||
- INF1 : Blue-green HAProxy, Grafana dashboards (API, Chat, Commerce), Alertmanager, Hyperswitch LIVE_MODE
|
||||
- AUTH1 : OAuth Discord, OAuth Spotify opérationnels
|
||||
- CLN1 : handler.go split en 4 sous-handlers, interceptors.ts en modules (utils, request, response)
|
||||
- QA1 : Tests OAuth, MIGRATIONS.md, audit console.log
|
||||
|
||||
### v0.503 (Phase 5 — HLS E2E + Chat Hardening + Cleanup)
|
||||
- SS1 : HLS Streaming E2E (backend serving routes, frontend ABR player)
|
||||
- CH1 : Redis rate limiter (sliding window + in-memory fallback), présence persistante Redis (2min TTL), PostgreSQL full-text search (tsvector + GIN index)
|
||||
|
|
@ -102,13 +114,8 @@
|
|||
- QA1 : Tests, documentation
|
||||
- Référence : [V0_503_RELEASE_SCOPE.md](V0_503_RELEASE_SCOPE.md)
|
||||
|
||||
### Prochaine version (v0.601)
|
||||
- **INF1** : Blue-green deployment, Grafana dashboards, health check enrichi, graceful shutdown
|
||||
- **COM1** : Reviews produits, factures PDF, remboursements, Hyperswitch production
|
||||
- **AUTH1** : OAuth Discord & Spotify
|
||||
- **CLN1** : Découpage handler.go (track), interceptors.ts, consolidation migrations
|
||||
- **QA1** : Tests E2E, smoke test, documentation
|
||||
- Référence : [V0_601_RELEASE_SCOPE.md](V0_601_RELEASE_SCOPE.md), [PLAN_V0_601_IMPLEMENTATION.md](PLAN_V0_601_IMPLEMENTATION.md)
|
||||
### Prochaine version (v0.603)
|
||||
- À définir — Référence : [V0_603_RELEASE_SCOPE.md](V0_603_RELEASE_SCOPE.md) (placeholder)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -168,8 +175,10 @@
|
|||
| [V0_501_RELEASE_SCOPE.md](archive/V0_501_RELEASE_SCOPE.md) | Scope v0.501 (Streaming & Cloud, archivé) |
|
||||
| [V0_502_RELEASE_SCOPE.md](archive/V0_502_RELEASE_SCOPE.md) | Scope v0.502 (Chat Server Rewrite, archivé) |
|
||||
| [V0_503_RELEASE_SCOPE.md](archive/V0_503_RELEASE_SCOPE.md) | Scope v0.503 (archivé) |
|
||||
| [V0_601_RELEASE_SCOPE.md](V0_601_RELEASE_SCOPE.md) | Scope v0.601 (en planification) |
|
||||
| [V0_601_RELEASE_SCOPE.md](archive/V0_601_RELEASE_SCOPE.md) | Scope v0.601 (archivé) |
|
||||
| [V0_602_RELEASE_SCOPE.md](archive/V0_602_RELEASE_SCOPE.md) | Scope v0.602 (archivé) |
|
||||
| [PLAN_V0_601_IMPLEMENTATION.md](PLAN_V0_601_IMPLEMENTATION.md) | Plan d'implémentation v0.601 |
|
||||
| [PLAN_V0_602_IMPLEMENTATION.md](PLAN_V0_602_IMPLEMENTATION.md) | Plan d'implémentation v0.602 |
|
||||
| [CHAT_FEATURE_PARITY.md](CHAT_FEATURE_PARITY.md) | Feature parity Rust vs Go (25/25 OK) |
|
||||
| [V0_301_RELEASE_SCOPE.md](V0_301_RELEASE_SCOPE.md) | Scope détaillé v0.301 (Phase 3 Social) |
|
||||
| [V0_203_RELEASE_SCOPE.md](V0_203_RELEASE_SCOPE.md) | Scope v0.203 (archivé) |
|
||||
|
|
|
|||
19
docs/RETROSPECTIVE_V0601.md
Normal file
19
docs/RETROSPECTIVE_V0601.md
Normal 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 d’attention
|
||||
|
||||
- **Interceptors** : auth.ts et error.ts non extraits ; error handler ~600 LOC encore dans interceptors.ts
|
||||
- **Squash migrations** : script écrit maintenant dans baseline_v0601.sql (avant : stdout uniquement)
|
||||
|
||||
## Prochaines étapes (v0.602)
|
||||
|
||||
- Finaliser split interceptors (auth, error)
|
||||
- Consolider dashboards Grafana avec métriques réelles
|
||||
- Tests E2E commerce
|
||||
20
docs/RETROSPECTIVE_V0602.md
Normal file
20
docs/RETROSPECTIVE_V0602.md
Normal 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 d’attention
|
||||
|
||||
- **Transfer après vente** : reporté en v0.603 ; procédure manuelle documentée dans PAYOUT_MANUAL.md
|
||||
- **Tests marketplace** : schéma SQLite in-memory à maintenir aligné avec migrations PostgreSQL (product_licenses, product_images, orders, licenses)
|
||||
- **Sanitizer** : correction regex object/embed (Go n’a pas de backreferences \1)
|
||||
|
||||
## Prochaines étapes (v0.603)
|
||||
|
||||
- Implémenter transfer automatique Stripe Connect après vente (ou valider procédure manuelle)
|
||||
- Prioriser les lots selon V0_603_RELEASE_SCOPE.md
|
||||
|
|
@ -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
40
docs/SMOKE_TEST_V0601.md
Normal 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 d’erreurs console en prod
|
||||
45
docs/SMOKE_TEST_V0602.md
Normal file
45
docs/SMOKE_TEST_V0602.md
Normal 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)
|
||||
30
docs/V0_603_RELEASE_SCOPE.md
Normal file
30
docs/V0_603_RELEASE_SCOPE.md
Normal 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)
|
||||
153
docs/archive/V0_602_RELEASE_SCOPE.md
Normal file
153
docs/archive/V0_602_RELEASE_SCOPE.md
Normal 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 |
|
||||
103
go.work.sum
103
go.work.sum
|
|
@ -1,100 +1,203 @@
|
|||
github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 h1:59MxjQVfjXsBpLy+dbd2/ELV5ofnUkUZBvWSC85sheA=
|
||||
github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0/go.mod h1:OahwfttHWG6eJ0clwcfBAHoDI6X/LV/15hx/wlMZSrU=
|
||||
github.com/Microsoft/hcsshim v0.11.5 h1:haEcLNpj9Ka1gd3B3tAEs9CpE0c+1IhoL59w/exYU38=
|
||||
github.com/Microsoft/hcsshim v0.11.5/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA09d4bExKcU=
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
|
||||
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc=
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
|
||||
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
||||
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/cilium/ebpf v0.9.1 h1:64sn2K3UKw8NbP/blsixRpF3nXuyhz/VjRlRzvlBRu4=
|
||||
github.com/cilium/ebpf v0.9.1/go.mod h1:+OhNOIXx/Fnu1IE8bJz2dzOA+VSfyTfdNUVdlQnxUFY=
|
||||
github.com/containerd/aufs v1.0.0 h1:2oeJiwX5HstO7shSrPZjrohJZLzK36wvpdmzDRkL/LY=
|
||||
github.com/containerd/aufs v1.0.0/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU=
|
||||
github.com/containerd/btrfs/v2 v2.0.0 h1:FN4wsx7KQrYoLXN7uLP0vBV4oVWHOIKDRQ1G2Z0oL5M=
|
||||
github.com/containerd/btrfs/v2 v2.0.0/go.mod h1:swkD/7j9HApWpzl8OHfrHNxppPd9l44DFZdF94BUj9k=
|
||||
github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
|
||||
github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw=
|
||||
github.com/containerd/cgroups/v3 v3.0.2 h1:f5WFqIVSgo5IZmtTT3qVBo6TzI1ON6sycSBKkymb9L0=
|
||||
github.com/containerd/cgroups/v3 v3.0.2/go.mod h1:JUgITrzdFqp42uI2ryGA+ge0ap/nxzYgkGmIcetmErE=
|
||||
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
|
||||
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
|
||||
github.com/containerd/continuity v0.4.2 h1:v3y/4Yz5jwnvqPKJJ+7Wf93fyWoCB3F5EclWG023MDM=
|
||||
github.com/containerd/continuity v0.4.2/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ=
|
||||
github.com/containerd/errdefs v0.1.0 h1:m0wCRBiu1WJT/Fr+iOoQHMQS/eP5myQ8lCv4Dz5ZURM=
|
||||
github.com/containerd/errdefs v0.1.0/go.mod h1:YgWiiHtLmSeBrvpw+UfPijzbLaB77mEG1WwJTDETIV0=
|
||||
github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY=
|
||||
github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o=
|
||||
github.com/containerd/go-cni v1.1.9 h1:ORi7P1dYzCwVM6XPN4n3CbkuOx/NZ2DOqy+SHRdo9rU=
|
||||
github.com/containerd/go-cni v1.1.9/go.mod h1:XYrZJ1d5W6E2VOvjffL3IZq0Dz6bsVlERHbekNK90PM=
|
||||
github.com/containerd/go-runc v1.0.0 h1:oU+lLv1ULm5taqgV/CJivypVODI4SUz1znWjv3nNYS0=
|
||||
github.com/containerd/go-runc v1.0.0/go.mod h1:cNU0ZbCgCQVZK4lgG3P+9tn9/PaJNmoDXPpoJhDR+Ok=
|
||||
github.com/containerd/imgcrypt v1.1.8 h1:ZS7TuywcRNLoHpU0g+v4/PsKynl6TYlw5xDVWWoIyFA=
|
||||
github.com/containerd/imgcrypt v1.1.8/go.mod h1:x6QvFIkMyO2qGIY2zXc88ivEzcbgvLdWjoZyGqDap5U=
|
||||
github.com/containerd/nri v0.6.1 h1:xSQ6elnQ4Ynidm9u49ARK9wRKHs80HCUI+bkXOxV4mA=
|
||||
github.com/containerd/nri v0.6.1/go.mod h1:7+sX3wNx+LR7RzhjnJiUkFDhn18P5Bg/0VnJ/uXpRJM=
|
||||
github.com/containerd/ttrpc v1.2.4 h1:eQCQK4h9dxDmpOb9QOOMh2NHTfzroH1IkmHiKZi05Oo=
|
||||
github.com/containerd/ttrpc v1.2.4/go.mod h1:ojvb8SJBSch0XkqNO0L0YX/5NxR3UnVk2LzFKBK0upc=
|
||||
github.com/containerd/typeurl v1.0.2 h1:Chlt8zIieDbzQFzXzAeBEF92KhExuE4p9p92/QmY7aY=
|
||||
github.com/containerd/typeurl v1.0.2/go.mod h1:9trJWW2sRlGub4wZJRTW83VtbOLS6hwcDZXTn6oPz9s=
|
||||
github.com/containerd/typeurl/v2 v2.1.1 h1:3Q4Pt7i8nYwy2KmQWIw2+1hTvwTE/6w9FqcttATPO/4=
|
||||
github.com/containerd/typeurl/v2 v2.1.1/go.mod h1:IDp2JFvbwZ31H8dQbEIY7sDl2L3o3HZj1hsSQlywkQ0=
|
||||
github.com/containerd/zfs v1.1.0 h1:n7OZ7jZumLIqNJqXrEc/paBM840mORnmGdJDmAmJZHM=
|
||||
github.com/containerd/zfs v1.1.0/go.mod h1:oZF9wBnrnQjpWLaPKEinrx3TQ9a+W/RJO7Zb41d8YLE=
|
||||
github.com/containernetworking/cni v1.1.2 h1:wtRGZVv7olUHMOqouPpn3cXJWpJgM6+EUl31EQbXALQ=
|
||||
github.com/containernetworking/cni v1.1.2/go.mod h1:sDpYKmGVENF3s6uvMvGgldDWeG8dMxakj/u+i9ht9vw=
|
||||
github.com/containernetworking/plugins v1.2.0 h1:SWgg3dQG1yzUo4d9iD8cwSVh1VqI+bP7mkPDoSfP9VU=
|
||||
github.com/containernetworking/plugins v1.2.0/go.mod h1:/VjX4uHecW5vVimFa1wkG4s+r/s9qIfPdqlLF4TW8c4=
|
||||
github.com/containers/ocicrypt v1.1.10 h1:r7UR6o8+lyhkEywetubUUgcKFjOWOaWz8cEBrCPX0ic=
|
||||
github.com/containers/ocicrypt v1.1.10/go.mod h1:YfzSSr06PTHQwSTUKqDSjish9BeW1E4HUmreluQcMd8=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131 h1:siEGb+iB1Ea75U7BnkYVSqSRzE6QHlXCbqEXenxRmhQ=
|
||||
github.com/dhowden/itl v0.0.0-20170329215456-9fbe21093131/go.mod h1:eVWQJVQ67aMvYhpkDwaH2Goy2vo6v8JCMfGXfQ9sPtw=
|
||||
github.com/dhowden/plist v0.0.0-20141002110153-5db6e0d9931a h1:7MucP9rMAsQRcRE1sGpvMZoTxFYZlDmfDvCH+z7H+90=
|
||||
github.com/dhowden/plist v0.0.0-20141002110153-5db6e0d9931a/go.mod h1:sLjdR6uwx3L6/Py8F+QgAfeiuY87xuYGwCDqRFrvCzw=
|
||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8=
|
||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
|
||||
github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8=
|
||||
github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw=
|
||||
github.com/emicklei/go-restful/v3 v3.10.1 h1:rc42Y5YTp7Am7CS630D7JmhRjq4UlEUuEKfrDac4bSQ=
|
||||
github.com/emicklei/go-restful/v3 v3.10.1/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
|
||||
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/intel/goresctrl v0.3.0 h1:K2D3GOzihV7xSBedGxONSlaw/un1LZgWsc9IfqipN4c=
|
||||
github.com/intel/goresctrl v0.3.0/go.mod h1:fdz3mD85cmP9sHD8JUlrNWAxvwM86CrbmVXltEKd7zk=
|
||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/kisielk/errcheck v1.5.0 h1:e8esj/e4R+SAOwFwN+n3zr0nYeCyeweozKfO23MvHzY=
|
||||
github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg=
|
||||
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46 h1:veS9QfglfvqAw2e+eeNT/SbGySq8ajECXJ9e4fPoLhY=
|
||||
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU=
|
||||
github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs=
|
||||
github.com/minio/sha256-simd v1.0.0 h1:v1ta+49hkWZyvaKwrQB8elexRqm6Y0aMLjCNsrYxo6g=
|
||||
github.com/minio/sha256-simd v1.0.0/go.mod h1:OuYzVNI5vcoYIAmbIvHPl3N3jUzVedXbKy5RFepssQM=
|
||||
github.com/mistifyio/go-zfs/v3 v3.0.1 h1:YaoXgBePoMA12+S1u/ddkv+QqxcfiZK4prI6HPnkFiU=
|
||||
github.com/mistifyio/go-zfs/v3 v3.0.1/go.mod h1:CzVgeB0RvF2EGzQnytKVvVSDwmKJXxkOTUGbNrTja/k=
|
||||
github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
|
||||
github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
|
||||
github.com/moby/spdystream v0.2.0 h1:cjW1zVyyoiM0T7b6UoySUFqzXMoqRckQtXwGPiBhOM8=
|
||||
github.com/moby/spdystream v0.2.0/go.mod h1:f7i0iNDQJ059oMTcWxx8MA/zKFIuD/lY+0GqbN2Wy8c=
|
||||
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
|
||||
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
|
||||
github.com/moby/sys/signal v0.7.0 h1:25RW3d5TnQEoKvRbEKUGay6DCQ46IxAVTT9CUMgmsSI=
|
||||
github.com/moby/sys/signal v0.7.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg=
|
||||
github.com/moby/sys/symlink v0.2.0 h1:tk1rOM+Ljp0nFmfOIBtlV3rTDlWOwFRhjEeAhZB0nZc=
|
||||
github.com/moby/sys/symlink v0.2.0/go.mod h1:7uZVF2dqJjG/NsClqul95CqKOBRQyYSNnJ6BMgR/gFs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/opencontainers/runtime-spec v1.1.0 h1:HHUyrt9mwHUjtasSbXSMvs4cyFxh+Bll4AjJ9odEGpg=
|
||||
github.com/opencontainers/runtime-spec v1.1.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
|
||||
github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626 h1:DmNGcqH3WDbV5k8OJ+esPWbqUOX5rMLR2PMvziDMJi0=
|
||||
github.com/opencontainers/runtime-tools v0.9.1-0.20221107090550-2e043c6bd626/go.mod h1:BRHJJd0E+cx42OybVYSgUvZmU0B8P9gZuRXlZUP7TKI=
|
||||
github.com/opencontainers/selinux v1.11.0 h1:+5Zbo97w3Lbmb3PeqQtpmTkMwsW5nRI3YaLpt7tQ7oU=
|
||||
github.com/opencontainers/selinux v1.11.0/go.mod h1:E5dMC3VPuVvVHDYmi78qvhJp8+M586T4DlDRYpFkyec=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/phpdave11/gofpdi v1.0.13 h1:o61duiW8M9sMlkVXWlvP92sZJtGKENvW3VExs6dZukQ=
|
||||
github.com/phpdave11/gofpdi v1.0.13/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
|
||||
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
|
||||
github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245 h1:K1Xf3bKttbF+koVGaX5xngRIZ5bVjbmPnaxE/dR08uY=
|
||||
github.com/ruudk/golang-pdf417 v0.0.0-20201230142125-a7e3863a1245/go.mod h1:pQAZKsJ8yyVxGRWYNEm9oFB8ieLgKFnamEyDmSA0BRk=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6 h1:pnnLyeX7o/5aX8qUQ69P/mLojDqwda8hFOCBTmP/6hw=
|
||||
github.com/stefanberger/go-pkcs11uri v0.0.0-20230803200340-78284954bff6/go.mod h1:39R/xuhNgVhi+K0/zst4TLrJrVmbm6LVgl4A0+ZFS5M=
|
||||
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635 h1:kdXcSzyDtseVEc4yCz2qF8ZrQvIDBJLl4S1c3GCXmoI=
|
||||
github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww=
|
||||
github.com/tchap/go-patricia/v2 v2.3.1 h1:6rQp39lgIYZ+MHmdEq4xzuk1t7OdC35z/xm0BGhTkes=
|
||||
github.com/tchap/go-patricia/v2 v2.3.1/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k=
|
||||
github.com/urfave/cli v1.22.12 h1:igJgVw1JdKH+trcLWLeLwZjU9fEfPesQ+9/e4MQ44S8=
|
||||
github.com/urfave/cli v1.22.12/go.mod h1:sSBEIC79qR6OvcmsD4U3KABeOTxDqQtdDnaFuUN30b8=
|
||||
github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs=
|
||||
github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho=
|
||||
github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f h1:p4VB7kIXpOQvVn1ZaTIVp+3vuYAXFe3OJEvjbUYJLaA=
|
||||
github.com/vishvananda/netns v0.0.0-20210104183010-2eb08e3e575f/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
|
||||
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
|
||||
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
|
||||
go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
|
||||
go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||
go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1 h1:A/5uWzF44DlIgdm/PQFwfMkW0JX+cIcQi/SwLAmZP5M=
|
||||
go.mozilla.org/pkcs7 v0.0.0-20200128120323-432b2356ecb1/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.45.0 h1:RsQi0qJ2imFfCvZabqzM9cNXBG8k6gXMv1A0cXRmH6A=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.45.0/go.mod h1:vsh3ySueQCiKPxFLvjWC4Z135gIa34TQ/NSqkDTZYUM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0 h1:3d+S281UTjM+AbF31XSOYn1qXn3BgIdWl8HNEpx08Jk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.19.0/go.mod h1:0+KuTDyKL4gjKCF75pHOX4wuzYDUZYfAQdSu43o+Z2I=
|
||||
golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo=
|
||||
golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13/go.mod h1:CCviP9RmpZ1mxVr8MUjCnSiY09IbAXZxhLE6EhHIdPU=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
k8s.io/api v0.26.2 h1:dM3cinp3PGB6asOySalOZxEG4CZ0IAdJsrYZXE/ovGQ=
|
||||
k8s.io/api v0.26.2/go.mod h1:1kjMQsFE+QHPfskEcVNgL3+Hp88B80uj0QtSOlj8itU=
|
||||
k8s.io/apimachinery v0.26.2 h1:da1u3D5wfR5u2RpLhE/ZtZS2P7QvDgLZTi9wrNZl/tQ=
|
||||
k8s.io/apimachinery v0.26.2/go.mod h1:ats7nN1LExKHvJ9TmwootT00Yz05MuYqPXEXaVeOy5I=
|
||||
k8s.io/apiserver v0.26.2 h1:Pk8lmX4G14hYqJd1poHGC08G03nIHVqdJMR0SD3IH3o=
|
||||
k8s.io/apiserver v0.26.2/go.mod h1:GHcozwXgXsPuOJ28EnQ/jXEM9QeG6HT22YxSNmpYNh8=
|
||||
k8s.io/client-go v0.26.2 h1:s1WkVujHX3kTp4Zn4yGNFK+dlDXy1bAAkIl+cFAiuYI=
|
||||
k8s.io/client-go v0.26.2/go.mod h1:u5EjOuSyBa09yqqyY7m3abZeovO/7D/WehVVlZ2qcqU=
|
||||
k8s.io/component-base v0.26.2 h1:IfWgCGUDzrD6wLLgXEstJKYZKAFS2kO+rBRi0p3LqcI=
|
||||
k8s.io/component-base v0.26.2/go.mod h1:DxbuIe9M3IZPRxPIzhch2m1eT7uFrSBJUBuVCQEBivs=
|
||||
k8s.io/cri-api v0.27.1 h1:KWO+U8MfI9drXB/P4oU9VchaWYOlwDglJZVHWMpTT3Q=
|
||||
k8s.io/cri-api v0.27.1/go.mod h1:+Ts/AVYbIo04S86XbTD73UPp/DkTiYxtsFeOFEu32L0=
|
||||
k8s.io/klog/v2 v2.90.1 h1:m4bYOKall2MmOiRaR1J+We67Do7vm9KiQVlT96lnHUw=
|
||||
k8s.io/klog/v2 v2.90.1/go.mod h1:y1WjHnz7Dj687irZUWR/WLkLc5N1YHtjLdmgWjndZn0=
|
||||
k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5 h1:kmDqav+P+/5e1i9tFfHq1qcF3sOrDp+YEkVDAHu7Jwk=
|
||||
k8s.io/utils v0.0.0-20230220204549-a5ecb0141aa5/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 h1:iXTIw73aPyC+oRdyqqvVJuloN1p0AC/kzH07hu3NE+k=
|
||||
sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.2.3 h1:PRbqxJClWWYMNV1dhaG4NsibJbArud9kFxnAMREiWFE=
|
||||
sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ihdVs8cGKBraizNC69E=
|
||||
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
|
||||
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
|
||||
tags.cncf.io/container-device-interface v0.7.2 h1:MLqGnWfOr1wB7m08ieI4YJ3IoLKKozEnnNYBtacDPQU=
|
||||
tags.cncf.io/container-device-interface v0.7.2/go.mod h1:Xb1PvXv2BhfNb3tla4r9JL129ck1Lxv9KuU6eVOfKto=
|
||||
tags.cncf.io/container-device-interface/specs-go v0.7.0 h1:w/maMGVeLP6TIQJVYT5pbqTi8SCw/iHZ+n4ignuGHqg=
|
||||
tags.cncf.io/container-device-interface/specs-go v0.7.0/go.mod h1:hMAwAbMZyBLdmYqWgYcKH0F/yctNpV3P35f+/088A80=
|
||||
|
|
|
|||
13
package-lock.json
generated
13
package-lock.json
generated
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
230
veza-backend-api/internal/core/track/track_analytics_handler.go
Normal file
230
veza-backend-api/internal/core/track/track_analytics_handler.go
Normal 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"})
|
||||
}
|
||||
407
veza-backend-api/internal/core/track/track_crud_handler.go
Normal file
407
veza-backend-api/internal/core/track/track_crud_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
189
veza-backend-api/internal/core/track/track_search_handler.go
Normal file
189
veza-backend-api/internal/core/track/track_search_handler.go
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
308
veza-backend-api/internal/core/track/track_social_handler.go
Normal file
308
veza-backend-api/internal/core/track/track_social_handler.go
Normal 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"})
|
||||
}
|
||||
127
veza-backend-api/internal/handlers/sell_handler.go
Normal file
127
veza-backend-api/internal/handlers/sell_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
209
veza-backend-api/internal/services/stripe_connect_service.go
Normal file
209
veza-backend-api/internal/services/stripe_connect_service.go
Normal 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
|
||||
}
|
||||
|
|
@ -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*["'][^"']*["']`)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue