feat(v0.701): AdminTransfers page/route, MSW, stories, Deep Health, API ref, docs, scope v0.702
- Step 13: AdminTransfersPage, LazyAdminTransfers, route /admin/transfers - Step 14: MSW handlers admin transfers - Step 15: AdminTransfersView stories (Default, Empty, WithFailedTransfers, Error, Loading) - Step 16-17: DeepHealth handler (disk, config), GET /health/deep - Step 19: health_deep_test.go (4 tests) - Step 20: docs/API_REFERENCE.md - Step 21: Archive V0_604, MIGRATIONS.md migration 116 - Step 22: CHANGELOG, PROJECT_STATE, FEATURE_STATUS v0.701 - Step 23: RETROSPECTIVE_V0701, V0_702 placeholder, SCOPE_CONTROL, .cursorrules - Step 24: Archive V0_701_RELEASE_SCOPE - Fix: AdminTransfersView Select component (use options API)
This commit is contained in:
parent
36e7bfc355
commit
c785e61e69
22 changed files with 1232 additions and 30 deletions
|
|
@ -1,10 +1,10 @@
|
|||
# Règles de Développement UI - Projet SaaS
|
||||
|
||||
## 0. Scope v0.701 (priorité absolue)
|
||||
## 0. Scope v0.702 (priorité absolue)
|
||||
|
||||
- **Référence** : `docs/V0_701_RELEASE_SCOPE.md` et `docs/SCOPE_CONTROL.md`
|
||||
- Avant toute modification : vérifier si le changement est **dans le scope v0.701**
|
||||
- **Autorisé v0.701** : Retry transfers, Admin transfers dashboard, Deep health checks, API documentation (voir V0_701_RELEASE_SCOPE.md)
|
||||
- **Référence** : `docs/V0_702_RELEASE_SCOPE.md` et `docs/SCOPE_CONTROL.md`
|
||||
- Avant toute modification : vérifier si le changement est **dans le scope v0.702**
|
||||
- **Autorisé v0.702** : À définir (voir V0_702_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.
|
||||
|
||||
|
|
|
|||
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -1,5 +1,25 @@
|
|||
# Changelog - Veza
|
||||
|
||||
## [v0.701] - 2026-02-23
|
||||
|
||||
### Added
|
||||
- Transfer Retry Worker: automatic retry of failed Stripe Connect transfers (exponential backoff, max 3 retries)
|
||||
- Migration 116: retry_count, next_retry_at columns on seller_transfers
|
||||
- GET /admin/transfers: paginated admin view of all platform transfers (filters: status, seller, date)
|
||||
- POST /admin/transfers/:id/retry: manual retry of failed transfers (admin only)
|
||||
- AdminTransfersView frontend component with status badges, retry button
|
||||
- GET /health/deep: deep health check (DB, Redis, S3, disk, config summary)
|
||||
- Startup config validation: PlatformFeeRate range, Stripe Connect coherence, retry config
|
||||
- Prometheus metrics: transfer retry (total, success, failures, permanent)
|
||||
- docs/API_REFERENCE.md: API documentation with curl examples
|
||||
|
||||
### Changed
|
||||
- SellerTransfer model: added retry_count, next_retry_at fields
|
||||
- Config: added TRANSFER_RETRY_ENABLED, TRANSFER_RETRY_MAX, TRANSFER_RETRY_INTERVAL
|
||||
- Health handler: added DeepHealth method with disk space and config
|
||||
|
||||
---
|
||||
|
||||
## [v0.603] - 2026-02-23
|
||||
|
||||
### Added
|
||||
|
|
|
|||
122
apps/web/src/components/admin/AdminTransfersView.stories.tsx
Normal file
122
apps/web/src/components/admin/AdminTransfersView.stories.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { AdminTransfersView } from './AdminTransfersView';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Admin/AdminTransfersView',
|
||||
component: AdminTransfersView,
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof AdminTransfersView>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
/** Default state with mocked transfers (MSW handlers-admin) */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Empty state — no transfers */
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/admin/transfers', () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
data: { transfers: [], total: 0 },
|
||||
})
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Only failed transfers (Retry button visible) */
|
||||
export const WithFailedTransfers: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/admin/transfers', () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
transfers: [
|
||||
{
|
||||
id: 'tr-fail-1',
|
||||
seller_id: 'seller-1',
|
||||
order_id: 'ord-1',
|
||||
amount_cents: 2699,
|
||||
platform_fee_cents: 300,
|
||||
currency: 'EUR',
|
||||
status: 'failed',
|
||||
error_message: 'Stripe API error',
|
||||
retry_count: 1,
|
||||
next_retry_at: new Date(Date.now() + 600000).toISOString(),
|
||||
created_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'tr-fail-2',
|
||||
seller_id: 'seller-2',
|
||||
order_id: 'ord-2',
|
||||
amount_cents: 4500,
|
||||
platform_fee_cents: 500,
|
||||
currency: 'EUR',
|
||||
status: 'failed',
|
||||
error_message: 'Insufficient funds',
|
||||
retry_count: 2,
|
||||
next_retry_at: new Date(Date.now() + 1200000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
})
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Error state — API returns error */
|
||||
export const Error: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/admin/transfers', () =>
|
||||
HttpResponse.json({ success: false, error: 'Internal server error' }, { status: 500 })
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** Loading state — delayed response shows skeleton */
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/admin/transfers', async () => {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
transfers: [
|
||||
{
|
||||
id: 'tr-admin-1',
|
||||
seller_id: 'seller-1',
|
||||
order_id: 'ord-1',
|
||||
amount_cents: 2699,
|
||||
platform_fee_cents: 300,
|
||||
currency: 'EUR',
|
||||
status: 'completed',
|
||||
retry_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
249
apps/web/src/components/admin/AdminTransfersView.tsx
Normal file
249
apps/web/src/components/admin/AdminTransfersView.tsx
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
|
||||
import { RefreshCcw, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { commerceService, type SellerTransfer } from '@/services/commerceService';
|
||||
import { useToast } from '@/components/feedback/ToastProvider';
|
||||
|
||||
const LIMIT = 20;
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-success/20 text-success';
|
||||
case 'failed':
|
||||
return 'bg-destructive/20 text-destructive';
|
||||
case 'permanently_failed':
|
||||
return 'bg-muted text-muted-foreground';
|
||||
case 'pending':
|
||||
return 'bg-warning/20 text-warning';
|
||||
case 'skipped':
|
||||
return 'bg-primary/20 text-primary';
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
function truncateId(id: string): string {
|
||||
if (!id || id.length < 12) return id;
|
||||
return `${id.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
export function AdminTransfersView() {
|
||||
const { addToast } = useToast();
|
||||
const [transfers, setTransfers] = useState<SellerTransfer[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sellerIdFilter, setSellerIdFilter] = useState('');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [retrying, setRetrying] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setOffset(0);
|
||||
}, [statusFilter, sellerIdFilter]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params: Record<string, string | number | undefined> = {
|
||||
limit: LIMIT,
|
||||
offset,
|
||||
};
|
||||
if (statusFilter && statusFilter !== 'all') params.status = statusFilter;
|
||||
if (sellerIdFilter.trim()) params.seller_id = sellerIdFilter.trim();
|
||||
const res = await commerceService.getAdminTransfers(params);
|
||||
setTransfers(res.transfers);
|
||||
setTotal(res.total);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e : new Error(String(e)));
|
||||
setTransfers([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [offset, statusFilter, sellerIdFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleRetry = async (transferId: string) => {
|
||||
setRetrying(transferId);
|
||||
try {
|
||||
await commerceService.retryAdminTransfer(transferId);
|
||||
addToast({ message: 'Transfer retried successfully', type: 'success' });
|
||||
fetchData();
|
||||
} catch (e) {
|
||||
addToast({
|
||||
message: e instanceof Error ? e.message : 'Retry failed',
|
||||
type: 'error',
|
||||
});
|
||||
} finally {
|
||||
setRetrying(null);
|
||||
}
|
||||
};
|
||||
|
||||
const hasNext = offset + LIMIT < total;
|
||||
const hasPrev = offset > 0;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-layout-content">
|
||||
<ErrorDisplay
|
||||
error={error}
|
||||
onRetry={fetchData}
|
||||
title="Failed to load transfers"
|
||||
context={{ action: 'loading', resource: 'admin transfers' }}
|
||||
variant="card"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-layout-content space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-2xl font-bold text-foreground">Platform Transfers</h1>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'all', label: 'All statuses' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
{ value: 'permanently_failed', label: 'Permanent fail' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'skipped', label: 'Skipped' },
|
||||
]}
|
||||
value={statusFilter}
|
||||
onChange={(v) => setStatusFilter(Array.isArray(v) ? v[0] ?? 'all' : v ?? 'all')}
|
||||
placeholder="Status"
|
||||
className="w-40"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Seller ID"
|
||||
value={sellerIdFilter}
|
||||
onChange={(e) => setSellerIdFilter(e.target.value)}
|
||||
className="w-48"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={fetchData} disabled={loading}>
|
||||
<RefreshCcw className="w-4 h-4 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card variant="default" className="overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-6 space-y-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : transfers.length === 0 ? (
|
||||
<div className="p-12 text-center text-muted-foreground">
|
||||
No transfers found
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table aria-label="Platform transfers">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Seller</TableHead>
|
||||
<TableHead>Order</TableHead>
|
||||
<TableHead>Net (EUR)</TableHead>
|
||||
<TableHead>Fee (EUR)</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Retries</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transfers.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{truncateId(t.seller_id ?? t.id)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{truncateId(t.order_id)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
€{(t.amount_cents / 100).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
€{(t.platform_fee_cents / 100).toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded ${statusBadgeClass(
|
||||
t.status
|
||||
)}`}
|
||||
>
|
||||
{t.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{t.retry_count ?? 0}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">
|
||||
{new Date(t.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{t.status === 'failed' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRetry(t.id)}
|
||||
disabled={retrying === t.id}
|
||||
>
|
||||
{retrying === t.id ? '…' : 'Retry'}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{offset + 1}–{Math.min(offset + LIMIT, total)} of {total}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOffset((o) => Math.max(0, o - LIMIT))}
|
||||
disabled={!hasPrev}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOffset((o) => o + LIMIT)}
|
||||
disabled={!hasNext}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ export {
|
|||
LazyTrackDetail,
|
||||
LazyPlaylistRoutes,
|
||||
LazyAdminDashboard,
|
||||
LazyAdminTransfers,
|
||||
LazyAnalytics,
|
||||
LazyWebhooks,
|
||||
LazyDesignSystemDemo,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export {
|
|||
LazyTrackDetail,
|
||||
LazyPlaylistRoutes,
|
||||
LazyAdminDashboard,
|
||||
LazyAdminTransfers,
|
||||
LazyAnalytics,
|
||||
LazyWebhooks,
|
||||
LazyDesignSystemDemo,
|
||||
|
|
|
|||
|
|
@ -115,6 +115,14 @@ export const LazyAdminDashboard = createLazyComponent(
|
|||
undefined,
|
||||
'Admin Dashboard',
|
||||
);
|
||||
export const LazyAdminTransfers = createLazyComponent(
|
||||
() =>
|
||||
import('@/features/admin/pages/AdminTransfersPage').then((m) => ({
|
||||
default: m.AdminTransfersPage,
|
||||
})),
|
||||
undefined,
|
||||
'Admin Transfers',
|
||||
);
|
||||
export const LazyAnalytics = createLazyComponent(
|
||||
() =>
|
||||
import('@/features/analytics/pages/AnalyticsPage').then((m) => ({
|
||||
|
|
|
|||
11
apps/web/src/features/admin/pages/AdminTransfersPage.tsx
Normal file
11
apps/web/src/features/admin/pages/AdminTransfersPage.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/**
|
||||
* Admin transfers page — v0.701
|
||||
*/
|
||||
|
||||
import { AdminTransfersView } from '@/components/admin/AdminTransfersView';
|
||||
|
||||
export function AdminTransfersPage() {
|
||||
return <AdminTransfersView />;
|
||||
}
|
||||
|
||||
export default AdminTransfersPage;
|
||||
|
|
@ -184,4 +184,62 @@ export const handlersAdmin = [
|
|||
],
|
||||
});
|
||||
}),
|
||||
|
||||
// v0.701: Admin transfers
|
||||
http.get('*/api/v1/admin/transfers', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
transfers: [
|
||||
{
|
||||
id: 'tr-admin-1',
|
||||
seller_id: 'seller-1',
|
||||
order_id: 'ord-1',
|
||||
amount_cents: 2699,
|
||||
platform_fee_cents: 300,
|
||||
currency: 'EUR',
|
||||
status: 'completed',
|
||||
retry_count: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'tr-admin-2',
|
||||
seller_id: 'seller-2',
|
||||
order_id: 'ord-2',
|
||||
amount_cents: 4500,
|
||||
platform_fee_cents: 500,
|
||||
currency: 'EUR',
|
||||
status: 'failed',
|
||||
error_message: 'Stripe error',
|
||||
retry_count: 1,
|
||||
next_retry_at: new Date(Date.now() + 600000).toISOString(),
|
||||
created_at: new Date(Date.now() - 86400000).toISOString(),
|
||||
},
|
||||
{
|
||||
id: 'tr-admin-3',
|
||||
seller_id: 'seller-1',
|
||||
order_id: 'ord-3',
|
||||
amount_cents: 1800,
|
||||
platform_fee_cents: 200,
|
||||
currency: 'EUR',
|
||||
status: 'permanently_failed',
|
||||
retry_count: 3,
|
||||
created_at: new Date(Date.now() - 172800000).toISOString(),
|
||||
},
|
||||
],
|
||||
total: 3,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/admin/transfers/:id/retry', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'tr-admin-2',
|
||||
status: 'completed',
|
||||
retry_count: 2,
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
LazyAnalytics,
|
||||
LazyWebhooks,
|
||||
LazyAdminDashboard,
|
||||
LazyAdminTransfers,
|
||||
LazyDesignSystemDemo,
|
||||
LazySocial,
|
||||
LazySellerDashboard,
|
||||
|
|
@ -97,6 +98,7 @@ export function getProtectedRoutes(): RouteEntry[] {
|
|||
{ path: '/analytics', element: wrapProtected(<LazyAnalytics />) },
|
||||
{ path: '/webhooks', element: wrapProtected(<LazyWebhooks />) },
|
||||
{ path: '/admin', element: wrapProtected(<LazyAdminDashboard />) },
|
||||
{ path: '/admin/transfers', element: wrapProtected(<LazyAdminTransfers />) },
|
||||
{ path: '/social', element: wrapProtected(<LazySocial />) },
|
||||
{ path: '/queue', element: wrapProtected(<LazyQueue />) },
|
||||
{ path: '/developer', element: wrapProtected(<LazyDeveloper />) },
|
||||
|
|
|
|||
412
docs/API_REFERENCE.md
Normal file
412
docs/API_REFERENCE.md
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
# API Reference — Veza Backend
|
||||
|
||||
Base URL: `http://localhost:8080/api/v1` (or your `APP_DOMAIN`)
|
||||
|
||||
All responses use the format: `{ "success": true|false, "data": {...} }` or `{ "success": false, "error": "..." }`.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
### POST /auth/register
|
||||
|
||||
Register a new user.
|
||||
|
||||
**Auth:** None
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"username": "johndoe",
|
||||
"email": "john@example.com",
|
||||
"password": "SecureP@ssw0rd"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"johndoe","email":"john@example.com","password":"SecureP@ssw0rd"}'
|
||||
```
|
||||
|
||||
**Response (201):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"user": { "id": "uuid", "username": "johndoe", "email": "john@example.com" },
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "eyJ...",
|
||||
"expires_in": 3600
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /auth/login
|
||||
|
||||
Login with email and password.
|
||||
|
||||
**Auth:** None
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"email": "john@example.com",
|
||||
"password": "SecureP@ssw0rd"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"john@example.com","password":"SecureP@ssw0rd"}'
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"access_token": "eyJ...",
|
||||
"refresh_token": "eyJ...",
|
||||
"expires_in": 3600,
|
||||
"user": { "id": "uuid", "username": "johndoe", "email": "john@example.com" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /auth/refresh
|
||||
|
||||
Refresh access token using refresh token.
|
||||
|
||||
**Auth:** None (refresh token in body or cookie)
|
||||
|
||||
**Body:**
|
||||
```json
|
||||
{
|
||||
"refresh_token": "eyJ..."
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/auth/refresh \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"refresh_token":"eyJ..."}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /auth/logout
|
||||
|
||||
Logout and invalidate refresh token.
|
||||
|
||||
**Auth:** Bearer token (optional)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/auth/logout \
|
||||
-H "Authorization: Bearer eyJ..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Marketplace
|
||||
|
||||
### GET /marketplace/products
|
||||
|
||||
List marketplace products (paginated).
|
||||
|
||||
**Auth:** None (public)
|
||||
|
||||
**Query params:** `limit`, `offset`, `search`, `sort`
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl "http://localhost:8080/api/v1/marketplace/products?limit=20&offset=0"
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"products": [...],
|
||||
"total": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /marketplace/products/:id
|
||||
|
||||
Get a single product by ID.
|
||||
|
||||
**Auth:** None (public)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/marketplace/products/uuid-here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /marketplace/orders
|
||||
|
||||
Create an order (checkout).
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
**Body:** Cart items, payment method, etc.
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/marketplace/orders \
|
||||
-H "Authorization: Bearer eyJ..." \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"items":[{"product_id":"uuid","quantity":1}],"payment_method":"card"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Seller
|
||||
|
||||
### GET /sell/balance
|
||||
|
||||
Get seller balance (Stripe Connect).
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/sell/balance \
|
||||
-H "Authorization: Bearer eyJ..."
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"connected": true,
|
||||
"available": 12500,
|
||||
"pending": 3200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /sell/transfers
|
||||
|
||||
List seller transfers (payout history).
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/sell/transfers \
|
||||
-H "Authorization: Bearer eyJ..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /sell/connect/onboard
|
||||
|
||||
Get Stripe Connect onboarding URL.
|
||||
|
||||
**Auth:** Bearer token (required)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/sell/connect/onboard \
|
||||
-H "Authorization: Bearer eyJ..."
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"onboarding_url": "https://connect.stripe.com/..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Admin
|
||||
|
||||
### GET /admin/transfers
|
||||
|
||||
List all platform transfers (admin only, paginated).
|
||||
|
||||
**Auth:** Bearer token (admin role required)
|
||||
|
||||
**Query params:** `status`, `seller_id`, `from`, `to`, `limit`, `offset`
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl "http://localhost:8080/api/v1/admin/transfers?status=failed&limit=50&offset=0" \
|
||||
-H "Authorization: Bearer eyJ..."
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"transfers": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"seller_id": "uuid",
|
||||
"order_id": "uuid",
|
||||
"amount_cents": 2699,
|
||||
"platform_fee_cents": 300,
|
||||
"currency": "EUR",
|
||||
"status": "failed",
|
||||
"retry_count": 1,
|
||||
"next_retry_at": "2026-02-24T10:00:00Z",
|
||||
"created_at": "2026-02-23T12:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /admin/transfers/:id/retry
|
||||
|
||||
Manually retry a failed transfer (admin only).
|
||||
|
||||
**Auth:** Bearer token (admin role required)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/admin/transfers/uuid-here/retry \
|
||||
-H "Authorization: Bearer eyJ..."
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"status": "completed",
|
||||
"retry_count": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Health
|
||||
|
||||
### GET /health
|
||||
|
||||
Simple health check (always returns OK).
|
||||
|
||||
**Auth:** None
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/health
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": { "status": "ok" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /health/deep
|
||||
|
||||
Deep health check (DB, Redis, S3, disk, config summary). v0.701
|
||||
|
||||
**Auth:** None
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl http://localhost:8080/api/v1/health/deep
|
||||
```
|
||||
|
||||
**Response (200 healthy/degraded, 503 unhealthy):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"status": "healthy",
|
||||
"timestamp": "2026-02-23T12:00:00Z",
|
||||
"checks": {
|
||||
"database": { "status": "ok" },
|
||||
"redis": { "status": "ok" },
|
||||
"disk": { "status": "ok", "details": { "free_gb": 42.5 } }
|
||||
},
|
||||
"config": {
|
||||
"jwt_secret_set": true,
|
||||
"stripe_connect_enabled": true,
|
||||
"platform_fee_rate": 0.10,
|
||||
"transfer_retry_enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /healthz
|
||||
|
||||
Liveness probe (Kubernetes).
|
||||
|
||||
**Auth:** None
|
||||
|
||||
---
|
||||
|
||||
### GET /readyz
|
||||
|
||||
Readiness probe (Kubernetes). Returns `ready`, `degraded`, or `not_ready`.
|
||||
|
||||
**Auth:** None
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
### POST /webhooks/hyperswitch
|
||||
|
||||
Hyperswitch payment webhook (signature verification required).
|
||||
|
||||
**Auth:** Webhook secret (signature header)
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/api/v1/webhooks/hyperswitch \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Hyperswitch-Signature: ..." \
|
||||
-d '{"event":"payment.succeeded",...}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Legacy routes (deprecated)
|
||||
|
||||
The following routes are also available at the root (without `/api/v1` prefix) but are deprecated:
|
||||
|
||||
- `GET /health`, `GET /health/deep`, `GET /healthz`, `GET /readyz`
|
||||
- `GET /metrics`
|
||||
|
||||
Use `/api/v1/...` for new integrations.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Statut des fonctionnalités — Veza
|
||||
|
||||
**Dernière mise à jour** : février 2026 — v0.603 livrée (Transfer automatique Stripe Connect, commission plateforme)
|
||||
**Dernière mise à jour** : février 2026 — v0.701 livrée (Retry transferts, Admin Dashboard, Deep Health)
|
||||
|
||||
Ce document décrit le statut réel des fonctionnalités par rapport au code.
|
||||
|
||||
|
|
@ -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). v0.603 : Transfer automatique Stripe Connect après vente, commission plateforme, GET /sell/transfers |
|
||||
| Marketplace | Oui | Oui | Complet (Hyperswitch). v0.603 : Transfer auto Stripe Connect. v0.701 : Retry auto failed transfers, Admin Dashboard GET/POST /admin/transfers |
|
||||
| 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 |
|
||||
|
|
@ -169,6 +169,19 @@ Voir [V0_602_RELEASE_SCOPE.md](archive/V0_602_RELEASE_SCOPE.md) pour le détail.
|
|||
|
||||
Voir [V0_603_RELEASE_SCOPE.md](archive/V0_603_RELEASE_SCOPE.md) pour le détail.
|
||||
|
||||
## Livré en v0.701 (Phase 7 — Retry, Admin Dashboard, Deep Health)
|
||||
|
||||
| Lot | Feature |
|
||||
|-----|---------|
|
||||
| R1 | Transfer Retry Worker : retry automatique des transferts failed (backoff exponentiel, max 3) |
|
||||
| R1 | Migration 116 : retry_count, next_retry_at sur seller_transfers |
|
||||
| A1 | Admin Transfers Dashboard : GET /admin/transfers, POST /admin/transfers/:id/retry |
|
||||
| A1 | AdminTransfersView : tableau avec filtres, pagination, bouton Retry |
|
||||
| H1 | Deep Health : GET /health/deep (DB, Redis, S3, disk, config summary) |
|
||||
| D1 | docs/API_REFERENCE.md : documentation API avec exemples curl |
|
||||
|
||||
Voir [V0_701_RELEASE_SCOPE.md](archive/V0_701_RELEASE_SCOPE.md) pour le détail.
|
||||
|
||||
## Prévu en v0.403 (Phase 4 Commerce — suite)
|
||||
|
||||
| Lot | Feature |
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ Output: `veza-backend-api/migrations/baseline_v0601.sql`
|
|||
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
|
||||
|
||||
## Recent Migrations
|
||||
|
||||
| # | File | Description |
|
||||
|---|------|-------------|
|
||||
| 116 | `116_seller_transfers_retry.sql` | v0.701: Add `retry_count`, `next_retry_at` to `seller_transfers`; index for failed retries |
|
||||
|
||||
## Adding New Migrations
|
||||
|
||||
1. Create a new file: `veza-backend-api/migrations/NNN_description.sql`
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@
|
|||
|
||||
| Élément | Valeur |
|
||||
|---------|--------|
|
||||
| **Dernier tag** | v0.603 |
|
||||
| **Dernier tag** | v0.701 |
|
||||
| **Branche courante** | `main` |
|
||||
| **Phase** | Phase 7 — Production Readiness |
|
||||
| **Prochaine version** | v0.701 |
|
||||
| **Prochaine version** | v0.702 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -73,6 +73,14 @@
|
|||
- Infra : MinIO S3-compatible (dev, staging, prod), 6 migrations (103–108)
|
||||
- Sécurité : Trivy container scanning CI
|
||||
|
||||
### v0.701 (Phase 7 — Retry, Admin Dashboard, Deep Health)
|
||||
- Transfer Retry Worker : retry automatique des transferts failed (backoff exponentiel, max 3)
|
||||
- Migration 116 : retry_count, next_retry_at sur seller_transfers
|
||||
- GET /admin/transfers, POST /admin/transfers/:id/retry
|
||||
- AdminTransfersView : tableau admin avec filtres, pagination, bouton Retry
|
||||
- GET /health/deep : DB, Redis, S3, disk, config summary
|
||||
- docs/API_REFERENCE.md
|
||||
|
||||
### v0.603 (Phase 6+ Transfer automatique, Commission & Stabilisation)
|
||||
- T1 : Transfer automatique Stripe Connect après paiement réussi (webhook Hyperswitch)
|
||||
- Commission plateforme configurable (PLATFORM_FEE_RATE, défaut 10 %)
|
||||
|
|
|
|||
19
docs/RETROSPECTIVE_V0701.md
Normal file
19
docs/RETROSPECTIVE_V0701.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
# Rétrospective v0.701 — Retry Transfers, Admin Dashboard & Deep Health
|
||||
|
||||
## Ce qui a bien fonctionné
|
||||
|
||||
- **Transfer Retry Worker** : Goroutine avec ticker, backoff exponentiel, métriques Prometheus dédiées
|
||||
- **Admin Transfers Dashboard** : Handler paginé avec filtres (status, seller_id), retry manuel, frontend AdminTransfersView avec badges et pagination
|
||||
- **Deep Health** : GET /health/deep avec disk space (syscall.Statfs), config summary (jwt_secret_set, stripe_connect_enabled, etc.), distinction unhealthy (DB) vs degraded (Redis/S3)
|
||||
- **API Reference** : docs/API_REFERENCE.md avec exemples curl pour auth, marketplace, seller, admin, health, webhooks
|
||||
- **Tests** : transfer_retry_test.go (5 cas), admin_transfer_handler_test.go (6 cas), health_deep_test.go (4 cas)
|
||||
|
||||
## Points d'attention
|
||||
|
||||
- **Select component** : AdminTransfersView utilise le Select avec options/value/onChange (pas Radix SelectTrigger/SelectContent)
|
||||
- **Stripe Connect désactivé** : Si Stripe Connect non configuré, RetryTransfer retourne 503 ; le worker ne démarre pas
|
||||
|
||||
## Prochaines étapes (v0.702)
|
||||
|
||||
- À définir selon V0_702_RELEASE_SCOPE.md
|
||||
- Pistes : reviews produits, factures PDF, remboursements
|
||||
|
|
@ -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_701_RELEASE_SCOPE.md](V0_701_RELEASE_SCOPE.md)
|
||||
**Version précédente** : [V0_603_RELEASE_SCOPE.md](archive/V0_603_RELEASE_SCOPE.md)
|
||||
**Référence active** : [V0_702_RELEASE_SCOPE.md](V0_702_RELEASE_SCOPE.md)
|
||||
**Version précédente** : [V0_701_RELEASE_SCOPE.md](archive/V0_701_RELEASE_SCOPE.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Règle d'or
|
||||
|
||||
> **Avant d'ajouter quoi que ce soit : vérifier si c'est dans le scope v0.701.**
|
||||
> **Avant d'ajouter quoi que ce soit : vérifier si c'est dans le scope v0.702.**
|
||||
> Si non → ne pas ajouter. Créer un ticket pour une version ultérieure.
|
||||
|
||||
---
|
||||
|
||||
## 2. Pendant la phase v0.701 (jusqu'au tag)
|
||||
## 2. Pendant la phase v0.702 (jusqu'au tag)
|
||||
|
||||
### 2.1 Autorisé
|
||||
|
||||
- **Corrections de bugs** sur les features IN SCOPE v0.701
|
||||
- **Corrections de bugs** sur les features IN SCOPE v0.702
|
||||
- **Stabilisation** : tests, refactoring sans changement de comportement
|
||||
- **Nettoyage** : suppression de code mort, consolidation
|
||||
- **Documentation** : mise à jour des docs existantes
|
||||
|
|
@ -26,20 +26,20 @@
|
|||
|
||||
### 2.2 Interdit
|
||||
|
||||
- **Nouvelles features** hors scope v0.701
|
||||
- **Nouvelles features** hors scope v0.702
|
||||
- **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.701
|
||||
- **"Améliorations"** non liées à un bug identifié ou une feature IN SCOPE v0.702
|
||||
|
||||
### 2.3 Cas limite
|
||||
|
||||
| Situation | Action |
|
||||
|-----------|--------|
|
||||
| Bug dans une feature HORS SCOPE | Corriger si blocant pour une feature IN SCOPE v0.701. Sinon : ticket pour plus tard. |
|
||||
| Bug dans une feature HORS SCOPE | Corriger si blocant pour une feature IN SCOPE v0.702. 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.702+. |
|
||||
| "Petite amélioration UX" | **Non.** Créer un ticket pour v0.703+. |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -47,13 +47,13 @@
|
|||
|
||||
### 3.1 Checklist pré-commit (dans la tête)
|
||||
|
||||
1. **Mon changement modifie-t-il une feature IN SCOPE v0.701 ?**
|
||||
1. **Mon changement modifie-t-il une feature IN SCOPE v0.702 ?**
|
||||
- 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_701_RELEASE_SCOPE. Si hors scope → **STOP.**
|
||||
- Correction, refactoring, test → OK si lié à une feature IN SCOPE v0.701.
|
||||
- Nouvelle route, nouveau composant, nouveau service → Vérifier V0_702_RELEASE_SCOPE. Si hors scope → **STOP.**
|
||||
- Correction, refactoring, test → OK si lié à une feature IN SCOPE v0.702.
|
||||
|
||||
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.701 (voir [V0_701_RELEASE_SCOPE.md](V0_701_RELEASE_SCOPE.md))
|
||||
- [ ] Le changement est dans le scope v0.702 (voir [V0_702_RELEASE_SCOPE.md](V0_702_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.701 (sauf correctif bug critique)
|
||||
- Elle modifie le comportement d'une feature HORS SCOPE v0.702 (sauf correctif bug critique)
|
||||
- Les tests échouent
|
||||
- Elle introduit une dépendance non justifiée
|
||||
|
||||
---
|
||||
|
||||
## 5. Proposer une feature pour APRÈS v0.603
|
||||
## 5. Proposer une feature pour APRÈS v0.701
|
||||
|
||||
### 5.1 Template
|
||||
|
||||
Utiliser le template [Feature request](.github/ISSUE_TEMPLATE/feature_request.md) avec :
|
||||
|
||||
- **Alignement scope** : cocher "Hors scope v0.701 — pour v0.702+"
|
||||
- **Alignement scope** : cocher "Hors scope v0.702 — pour v0.703+"
|
||||
- **Justification** : pourquoi cette feature est nécessaire
|
||||
- **Effort estimé** : S / M / L / XL
|
||||
- **Dépendances** : quelles features v0.701 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.701 n'est pas taguée
|
||||
3. Une fois v0.701 stable, prioriser les issues post-v0.701 dans le scope suivant
|
||||
2. **Ne pas implémenter** tant que v0.702 n'est pas taguée
|
||||
3. Une fois v0.702 stable, prioriser les issues post-v0.702 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.701 ou dépendance directe d'une feature IN SCOPE
|
||||
- La feature concernée doit être IN SCOPE v0.702 ou dépendance directe d'une feature IN SCOPE
|
||||
|
||||
### 6.3 Décision collégiale
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ Pour tout cas ambigu :
|
|||
|
||||
- Ouvrir une issue "Scope clarification"
|
||||
- Décision documentée dans l'issue
|
||||
- Mise à jour de V0_701_RELEASE_SCOPE.md si le scope est étendu (exception rare)
|
||||
- Mise à jour de V0_702_RELEASE_SCOPE.md si le scope est étendu (exception rare)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -173,12 +173,12 @@ Pour tout cas ambigu :
|
|||
- v0.602 : Phase 6+ — Payout, Dette Technique & Tests E2E — taguée
|
||||
- v0.603 : Phase 6+ — Transfer automatique Stripe Connect, Commission plateforme, Dette technique — taguée
|
||||
- v0.604 : Phase 6+ — absorbé par v0.701
|
||||
- v0.701 : Phase 7 — Retry Transfers, Admin Dashboard, Deep Health — en cours
|
||||
- v0.701 : Phase 7 — Retry Transfers, Admin Dashboard, Deep Health — taguée
|
||||
|
||||
---
|
||||
|
||||
## 8. Rappel pour les contributeurs
|
||||
|
||||
- **Cursor / IA** : Les règles dans `.cursorrules` rappellent de vérifier le scope avant toute modification.
|
||||
- **Humains** : Lire [V0_701_RELEASE_SCOPE.md](V0_701_RELEASE_SCOPE.md) avant de coder.
|
||||
- **Humains** : Lire [V0_702_RELEASE_SCOPE.md](V0_702_RELEASE_SCOPE.md) avant de coder.
|
||||
- **En doute ?** Ouvrir une issue "Scope clarification" plutôt que de coder.
|
||||
|
|
|
|||
18
docs/V0_702_RELEASE_SCOPE.md
Normal file
18
docs/V0_702_RELEASE_SCOPE.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Scope v0.702 — Placeholder
|
||||
|
||||
**Statut** : Placeholder — à définir
|
||||
|
||||
**Version précédente** : [V0_701_RELEASE_SCOPE.md](V0_701_RELEASE_SCOPE.md) (archivé après tag)
|
||||
|
||||
---
|
||||
|
||||
## Lots prévus (à valider)
|
||||
|
||||
- À définir selon priorisation produit
|
||||
- Pistes : reviews produits (R1), factures PDF (F1), remboursements (R2)
|
||||
|
||||
---
|
||||
|
||||
## Référence
|
||||
|
||||
Voir [SCOPE_CONTROL.md](SCOPE_CONTROL.md) pour les règles de scope.
|
||||
|
|
@ -90,6 +90,7 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) {
|
|||
var healthCheckHandler gin.HandlerFunc
|
||||
var livenessHandler gin.HandlerFunc
|
||||
var readinessHandler gin.HandlerFunc
|
||||
var deepHealthHandler gin.HandlerFunc
|
||||
|
||||
if r.db != nil && r.db.GormDB != nil {
|
||||
var redisClient interface{}
|
||||
|
|
@ -122,19 +123,35 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) {
|
|||
jobWorker,
|
||||
emailSender,
|
||||
)
|
||||
if r.config != nil {
|
||||
healthHandler.SetDeepHealthConfig(&handlers.DeepHealthConfig{
|
||||
JWTSecretSet: len(r.config.JWTSecret) >= 32,
|
||||
StripeConnectEnabled: r.config.StripeConnectEnabled,
|
||||
PlatformFeeRate: r.config.PlatformFeeRate,
|
||||
TransferRetryEnabled: r.config.TransferRetryEnabled,
|
||||
})
|
||||
}
|
||||
healthCheckHandler = healthHandler.Check
|
||||
livenessHandler = healthHandler.Liveness
|
||||
readinessHandler = healthHandler.Readiness
|
||||
deepHealthHandler = healthHandler.DeepHealth
|
||||
} else {
|
||||
healthCheckHandler = handlers.SimpleHealthCheck
|
||||
livenessHandler = handlers.SimpleHealthCheck
|
||||
readinessHandler = handlers.SimpleHealthCheck
|
||||
deepHealthHandler = func(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{"status": "unhealthy", "message": "Database not configured"},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deprecationMW := middleware.DeprecationWarning(r.logger)
|
||||
healthMonitoringMW := middleware.HealthCheckMonitoring(r.logger, r.monitoringService)
|
||||
|
||||
router.GET("/health", deprecationMW, healthMonitoringMW, healthCheckHandler)
|
||||
router.GET("/health/deep", deprecationMW, healthMonitoringMW, deepHealthHandler)
|
||||
router.GET("/healthz", deprecationMW, healthMonitoringMW, livenessHandler)
|
||||
router.GET("/readyz", deprecationMW, healthMonitoringMW, readinessHandler)
|
||||
router.GET("/metrics", deprecationMW, handlers.PrometheusMetrics())
|
||||
|
|
@ -146,6 +163,7 @@ func (r *APIRouter) setupCorePublicRoutes(router *gin.Engine) {
|
|||
v1Public := router.Group("/api/v1")
|
||||
{
|
||||
v1Public.GET("/health", healthCheckHandler)
|
||||
v1Public.GET("/health/deep", deepHealthHandler)
|
||||
v1Public.GET("/healthz", livenessHandler)
|
||||
v1Public.GET("/readyz", readinessHandler)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package handlers
|
|||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
|
@ -14,6 +15,14 @@ import (
|
|||
"veza-backend-api/internal/eventbus"
|
||||
)
|
||||
|
||||
// DeepHealthConfig holds config summary for /health/deep (v0.701)
|
||||
type DeepHealthConfig struct {
|
||||
JWTSecretSet bool
|
||||
StripeConnectEnabled bool
|
||||
PlatformFeeRate float64
|
||||
TransferRetryEnabled bool
|
||||
}
|
||||
|
||||
// HealthResponse représente la réponse du health check
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
|
|
@ -41,6 +50,7 @@ type HealthHandler struct {
|
|||
s3Service interface{} // BE-SVC-016: S3 storage service (optional)
|
||||
jobWorker interface{} // BE-SVC-016: Job worker (optional)
|
||||
emailSender interface{} // BE-SVC-016: Email sender (optional)
|
||||
deepHealthConfig *DeepHealthConfig // v0.701: config summary for /health/deep
|
||||
}
|
||||
|
||||
// NewHealthHandler crée un nouveau handler de health
|
||||
|
|
@ -82,6 +92,11 @@ func NewHealthHandlerWithServices(
|
|||
return h
|
||||
}
|
||||
|
||||
// SetDeepHealthConfig sets the config summary for DeepHealth (v0.701)
|
||||
func (h *HealthHandler) SetDeepHealthConfig(cfg *DeepHealthConfig) {
|
||||
h.deepHealthConfig = cfg
|
||||
}
|
||||
|
||||
// NewHealthHandlerSimple crée un nouveau handler de health simple (sans logger/redis)
|
||||
// Pour compatibilité avec la spécification T0012
|
||||
func NewHealthHandlerSimple(db *gorm.DB) *HealthHandler {
|
||||
|
|
@ -248,6 +263,96 @@ func (h *HealthHandler) Liveness(c *gin.Context) {
|
|||
})
|
||||
}
|
||||
|
||||
// DeepHealth check endpoint (/health/deep) — v0.701
|
||||
// Returns healthy, degraded (non-critical services down), or unhealthy (DB down).
|
||||
// HTTP: 200 for healthy/degraded, 503 for unhealthy.
|
||||
func (h *HealthHandler) DeepHealth(c *gin.Context) {
|
||||
response := HealthResponse{
|
||||
Status: "healthy",
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Checks: make(map[string]HealthCheck),
|
||||
}
|
||||
|
||||
// Critical: DB
|
||||
dbCheck := h.checkDatabase()
|
||||
response.Checks["database"] = dbCheck
|
||||
if dbCheck.Status == "error" {
|
||||
response.Status = "unhealthy"
|
||||
response.Message = "Database is down"
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"success": true, "data": response})
|
||||
return
|
||||
}
|
||||
|
||||
// Non-critical: Redis, RabbitMQ, S3, JobWorker, Email
|
||||
response.Checks["redis"] = h.checkRedis()
|
||||
response.Checks["rabbitmq"] = h.checkRabbitMQ()
|
||||
if h.s3Service != nil {
|
||||
response.Checks["s3_storage"] = h.checkS3()
|
||||
}
|
||||
if h.jobWorker != nil {
|
||||
response.Checks["job_worker"] = h.checkJobWorker()
|
||||
}
|
||||
if h.emailSender != nil {
|
||||
response.Checks["email_sender"] = h.checkEmailSender()
|
||||
}
|
||||
|
||||
// Disk space
|
||||
diskCheck := h.checkDiskSpace()
|
||||
response.Checks["disk"] = diskCheck
|
||||
|
||||
// Config summary (v0.701)
|
||||
configSummary := make(map[string]interface{})
|
||||
if h.deepHealthConfig != nil {
|
||||
configSummary["jwt_secret_set"] = h.deepHealthConfig.JWTSecretSet
|
||||
configSummary["stripe_connect_enabled"] = h.deepHealthConfig.StripeConnectEnabled
|
||||
configSummary["platform_fee_rate"] = h.deepHealthConfig.PlatformFeeRate
|
||||
configSummary["transfer_retry_enabled"] = h.deepHealthConfig.TransferRetryEnabled
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
for key, check := range response.Checks {
|
||||
if key == "database" {
|
||||
continue
|
||||
}
|
||||
if check.Status == "error" {
|
||||
response.Status = "degraded"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Build response with config field
|
||||
resp := gin.H{
|
||||
"status": response.Status,
|
||||
"timestamp": response.Timestamp,
|
||||
"checks": response.Checks,
|
||||
"config": configSummary,
|
||||
}
|
||||
if response.Message != "" {
|
||||
resp["message"] = response.Message
|
||||
}
|
||||
RespondSuccess(c, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// checkDiskSpace returns free disk space in GB (v0.701)
|
||||
func (h *HealthHandler) checkDiskSpace() HealthCheck {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs("/", &stat); err != nil {
|
||||
return HealthCheck{
|
||||
Status: "error",
|
||||
Message: err.Error(),
|
||||
}
|
||||
}
|
||||
// Block size * free blocks / (1024^3) = GB
|
||||
freeGB := float64(stat.Bfree) * float64(stat.Bsize) / (1024 * 1024 * 1024)
|
||||
return HealthCheck{
|
||||
Status: "ok",
|
||||
Message: "disk space available",
|
||||
Details: map[string]interface{}{
|
||||
"free_gb": freeGB,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SimpleHealthCheck est une fonction simple pour le health check endpoint public
|
||||
func SimpleHealthCheck(c *gin.Context) {
|
||||
RespondSuccess(c, http.StatusOK, gin.H{
|
||||
|
|
|
|||
131
veza-backend-api/internal/handlers/health_deep_test.go
Normal file
131
veza-backend-api/internal/handlers/health_deep_test.go
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap/zaptest"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestDeepHealth_AllHealthy(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
logger := zaptest.NewLogger(t)
|
||||
handler := NewHealthHandler(db, logger, nil, nil, "test")
|
||||
handler.SetDeepHealthConfig(&DeepHealthConfig{
|
||||
JWTSecretSet: true,
|
||||
StripeConnectEnabled: false,
|
||||
PlatformFeeRate: 0.10,
|
||||
TransferRetryEnabled: true,
|
||||
})
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/health/deep", handler.DeepHealth)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/health/deep", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var wrapper map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &wrapper))
|
||||
data, ok := wrapper["data"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Contains(t, []string{"healthy", "degraded"}, data["status"])
|
||||
assert.Contains(t, data["checks"], "database")
|
||||
assert.Contains(t, data["checks"], "disk")
|
||||
assert.Contains(t, data, "config")
|
||||
}
|
||||
|
||||
func TestDeepHealth_DBDown(t *testing.T) {
|
||||
logger := zaptest.NewLogger(t)
|
||||
handler := NewHealthHandler(nil, logger, nil, nil, "test")
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/health/deep", handler.DeepHealth)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/health/deep", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
|
||||
var wrapper map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &wrapper))
|
||||
data, ok := wrapper["data"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "unhealthy", data["status"])
|
||||
}
|
||||
|
||||
func TestDeepHealth_RedisDegraded(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
logger := zaptest.NewLogger(t)
|
||||
// Redis nil = "error" from checkRedis
|
||||
handler := NewHealthHandler(db, logger, nil, nil, "test")
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/health/deep", handler.DeepHealth)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/health/deep", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
// Degraded = 200 OK (load balancer continues)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var wrapper map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &wrapper))
|
||||
data, ok := wrapper["data"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "degraded", data["status"])
|
||||
}
|
||||
|
||||
func TestDeepHealth_ConfigSummary(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
logger := zaptest.NewLogger(t)
|
||||
handler := NewHealthHandler(db, logger, nil, nil, "test")
|
||||
handler.SetDeepHealthConfig(&DeepHealthConfig{
|
||||
JWTSecretSet: true,
|
||||
StripeConnectEnabled: true,
|
||||
PlatformFeeRate: 0.15,
|
||||
TransferRetryEnabled: false,
|
||||
})
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
router.GET("/health/deep", handler.DeepHealth)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/health/deep", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var wrapper map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &wrapper))
|
||||
data, ok := wrapper["data"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
|
||||
config, ok := data["config"].(map[string]interface{})
|
||||
require.True(t, ok, "Response should have 'config' field")
|
||||
assert.Equal(t, true, config["jwt_secret_set"])
|
||||
assert.Equal(t, true, config["stripe_connect_enabled"])
|
||||
assert.Equal(t, 0.15, config["platform_fee_rate"])
|
||||
assert.Equal(t, false, config["transfer_retry_enabled"])
|
||||
}
|
||||
Loading…
Reference in a new issue