feat(v0.501): Sprint 4 -- Cloud frontend + Gear advanced
- C1-09: Create CloudPage with folder tree, file list, and /cloud route - C1-10: Create CloudUploadModal with drag-and-drop and progress - C1-11: Create CloudFilePreview mini player inline - C1-12: Add Cloud stories (loading, empty, populated, quota full) - G1-01: Add is_public toggle, public gear endpoint, GearShowcase - G1-02: Add gear image upload endpoints, GearImageGallery component - G1-03: Add gear search with ILIKE + SearchBar in toolbar - G1-04: Add stories for GearShowcase and GearImageGallery
This commit is contained in:
parent
ec4564fb37
commit
edde637c8e
39 changed files with 3423 additions and 65 deletions
702
AUDIT_TECHNIQUE_2026-02-22.md
Normal file
702
AUDIT_TECHNIQUE_2026-02-22.md
Normal file
|
|
@ -0,0 +1,702 @@
|
|||
# AUDIT TECHNIQUE — VEZA MONOREPO
|
||||
|
||||
| Champ | Valeur |
|
||||
|-------|--------|
|
||||
| **Date** | 2026-02-22 |
|
||||
| **Auditeur** | Claude 4.6 Opus (IA) — mandat due diligence |
|
||||
| **Version analysée** | v0.402, main (HEAD+49 commits non poussés) |
|
||||
| **Périmètre** | Backend Go, Chat Server Rust, Stream Server Rust, Frontend React, Infra Docker/CI |
|
||||
| **Méthodologie** | Analyse statique du code source, 6 passes d'exploration |
|
||||
| **Classification** | Confidentiel — Usage interne |
|
||||
|
||||
---
|
||||
|
||||
## EXECUTIVE SUMMARY
|
||||
|
||||
### Verdict global
|
||||
|
||||
Veza est un projet **ambitieux et structurellement bien pensé** pour un effort solo/micro-équipe. L'architecture backend Go est la pièce la plus mature : séparation handler → service → repository, middleware stack complète, couverture de tests proche de 1:1. Le frontend React est extensif (~131K LOC source) avec un design system cohérent (SUMI), Storybook-driven development, et 288 stories.
|
||||
|
||||
**Cependant, le projet n'est pas prêt pour la production.** La vélocité affichée (345+ features, 12 releases en ~3 mois) masque une réalité : de nombreuses features sont partiellement implémentées (frontend mock, backend stub, ou flux E2E non connecté). Les services Rust compilent mais ne sont pas intégrés (gRPC = stub, boot mode = chat/stream OFF). L'infrastructure CI/CD contient des défauts critiques (pipeline CD non fonctionnel, secrets en clair, versions Go incohérentes).
|
||||
|
||||
### Top 5 risques
|
||||
|
||||
| # | Risque | Gravité |
|
||||
|---|--------|---------|
|
||||
| 1 | **Pipeline CD non fonctionnel** — Les conditions `secrets.*` dans les `if` GitHub Actions ne s'évaluent jamais. Les étapes push, sign, deploy ne s'exécutent pas. | CRITIQUE |
|
||||
| 2 | **Authentification HLS/WebSocket cassée** — `TokenStorage.getAccessToken()` retourne toujours `null` (cookies httpOnly). Les clients HLS et WebSocket ne peuvent pas s'authentifier. | CRITIQUE |
|
||||
| 3 | **Redis sans mot de passe en production** — `docker-compose.prod.yml` ne configure aucune authentification Redis. | ÉLEVÉ |
|
||||
| 4 | **Rate limiter en mémoire** — Ne fonctionne pas en multi-instance. Brute force possible en prod scalée. | ÉLEVÉ |
|
||||
| 5 | **Services Rust non intégrés** — Chat et Stream servers compilent mais tournent en "boot mode" (OFF). 21.5% des 600 features annoncées sont réellement fonctionnelles. | ÉLEVÉ |
|
||||
|
||||
### Top 5 forces
|
||||
|
||||
| # | Force |
|
||||
|---|-------|
|
||||
| 1 | **Architecture backend Go exemplaire** — Séparation claire des responsabilités, middleware stack complète (23 middlewares), ratio test/code 0.97:1 |
|
||||
| 2 | **Sécurité auth solide** — Tokens httpOnly, access token 5min, bcrypt cost 12, CSRF timing-safe, validation JWT stricte (iss/aud/exp/algo) |
|
||||
| 3 | **Design system cohérent** — SUMI Design System v2.0, 882 lignes de tokens CSS, Storybook-first, 288 stories |
|
||||
| 4 | **Infrastructure de qualité** — CI multi-pipeline, Dependabot, security scanning, Dockerfiles multi-stage, utilisateur non-root |
|
||||
| 5 | **Documentation extensive** — 63 docs frontend, scope control par version, CHANGELOG structuré, FEATURE_STATUS tracé |
|
||||
|
||||
### Recommandation go/no-go
|
||||
|
||||
**NO-GO pour production en l'état.** Conditionnel à 4-6 semaines de stabilisation ciblée (voir Phase 1-2 du plan d'action). Le code est de qualité suffisante pour être corrigé, pas réécrit.
|
||||
|
||||
---
|
||||
|
||||
## 1️⃣ CARTOGRAPHIE GLOBALE
|
||||
|
||||
### 1.1 Stack réelle
|
||||
|
||||
| Élément | Constaté dans le code |
|
||||
|---------|----------------------|
|
||||
| **Go** | 1.24.0 (`go.work`), mais Dockerfile.production utilise 1.23-alpine — **incohérence** |
|
||||
| **Rust** | Stable channel (`rust-toolchain.toml`), Edition 2021 |
|
||||
| **Node.js** | 20 (CI workflows), npm 10.9.2 (`packageManager`) |
|
||||
| **React** | 18.2.0 |
|
||||
| **Vite** | 7.1.5 |
|
||||
| **TypeScript** | 5.3.3 (package.json) mais 5.9.3 (root devDependencies) — **incohérence** |
|
||||
| **Tailwind CSS** | 4.0.0 (CSS-first config) |
|
||||
| **Framework Go** | Gin 1.11.0 (dernière stable, maintenu) |
|
||||
| **ORM Go** | GORM 1.30.0 + lib/pq 1.10.9 (parameterized queries) |
|
||||
| **Framework Rust** | Axum 0.8 (chat + stream), Tokio 1.35 |
|
||||
| **SQLx** | 0.8 (Rust services) |
|
||||
| **PostgreSQL** | 16-alpine (dev/prod), 15-alpine (test/hybrid) — **incohérence** |
|
||||
| **Redis** | 7 (docker-compose), go-redis/v9 9.16.0 |
|
||||
| **RabbitMQ** | 3-management-alpine |
|
||||
| **Auth** | JWT HS256 via `golang-jwt/jwt/v5`, access 5min, refresh 14j, remember-me 30j |
|
||||
| **Paiement** | Hyperswitch via `@juspay-tech/hyper-js`, SDK frontend uniquement, mode test |
|
||||
| **Streaming** | HLS prévu mais **désactivé** (`HLS_STREAMING=false`), service stub |
|
||||
| **WebSocket** | Gorilla (Go), Axum WS (Rust), native WebSocket API (frontend) |
|
||||
| **WebRTC** | Code présent dans stream-server mais **commenté/désactivé** |
|
||||
| **CI/CD** | GitHub Actions — 12 workflows |
|
||||
| **Containerisation** | Docker multi-stage, images alpine, utilisateur non-root |
|
||||
| **Monitoring** | Prometheus configuré, Grafana référencé, Sentry intégré, zap structured logging |
|
||||
| **Monorepo** | Turborepo + npm workspaces + Go workspace |
|
||||
|
||||
### 1.2 Organisation du monorepo
|
||||
|
||||
| Répertoire | Rôle réel | Fichiers | LOC |
|
||||
|-----------|-----------|----------|-----|
|
||||
| `veza-backend-api/` | API REST Go — cœur fonctionnel du produit | 671 .go | 174,022 |
|
||||
| `veza-chat-server/` | Serveur chat Rust — compile, non intégré | 78 .rs | ~60,000* |
|
||||
| `veza-stream-server/` | Serveur streaming Rust — compile, non intégré | 113 .rs | ~80,000* |
|
||||
| `apps/web/` | Frontend React/Vite — interface utilisateur | 1,837 .ts/.tsx | ~206,000 |
|
||||
| `veza-docs/` | Site Docusaurus — squelette non alimenté | ~20 | ~500 |
|
||||
| `docs/` | Documentation projet/versioning | 332 .md | ~15,000 |
|
||||
| `scripts/` | Scripts utilitaires (audit, migration, deploy) | 85+ | ~5,000 |
|
||||
| `.github/` | CI/CD workflows + templates | 12 workflows | ~800 |
|
||||
| `config/` | Prometheus, métriques, SSL | 5 | ~100 |
|
||||
| `infra/` | docker-compose lab | 1 | ~50 |
|
||||
| `make/` | Makefile modulaire | 11 .mk | ~800 |
|
||||
| `dev-environment/` | Templates de services | ~10 | ~400 |
|
||||
| `fixtures/` | Package npm vide | 5 | ~50 |
|
||||
| `packages/` | Shared packages — **vide** | 0 | 0 |
|
||||
|
||||
*LOC Rust estimée (total 352K inclut le code généré gRPC/protobuf)
|
||||
|
||||
**Packages orphelins :**
|
||||
- `packages/` — déclaré dans npm workspaces mais vide
|
||||
- `fixtures/` — package npm avec `vitest.config.ts` mais aucun test
|
||||
- `veza-docs/` — Docusaurus configuré mais non alimenté
|
||||
|
||||
**Packages fantômes :**
|
||||
- `veza-backend-api/internal/api/archive/api_manager.go` — 789 lignes de code commenté/TODO, jamais importé
|
||||
- `dev-environment/templates/` — templates de génération de code non utilisés par un outil
|
||||
|
||||
**Duplications cross-packages :**
|
||||
- JWT validation implémentée 3 fois (Go `jwt_service.go`, Rust chat `jwt_manager.rs`, Rust stream `token_validator.rs`)
|
||||
- Configuration loading implémentée 3 fois avec des patterns différents
|
||||
- gRPC protobuf généré dupliqué entre chat et stream servers
|
||||
|
||||
### 1.3 Dépendances critiques
|
||||
|
||||
#### Backend Go (44 dépendances directes)
|
||||
|
||||
| Dépendance | Version | Statut | Risque |
|
||||
|-----------|---------|--------|--------|
|
||||
| `gin-gonic/gin` | 1.11.0 | Maintenu activement | Faible |
|
||||
| `gorm.io/gorm` | 1.30.0 | Maintenu activement | Faible |
|
||||
| `golang-jwt/jwt/v5` | 5.3.0 | Maintenu | Faible |
|
||||
| `redis/go-redis/v9` | 9.16.0 | Maintenu | Faible |
|
||||
| `gorilla/websocket` | 1.5.3 | **Archivé** (décembre 2024) | MOYEN — migrer vers `nhooyr.io/websocket` |
|
||||
| `lib/pq` | 1.10.9 | En maintenance minimale | Faible (GORM l'utilise via driver) |
|
||||
| `swaggo/swag` | 1.16.6 | Maintenu | Faible |
|
||||
| `sony/gobreaker` | 1.0.0 | Maintenu | Faible |
|
||||
| `getsentry/sentry-go` | 0.40.0 | Maintenu | Faible |
|
||||
| `testcontainers-go` | 0.33.0 | Maintenu | Faible |
|
||||
|
||||
#### Frontend React (dépendances majeures)
|
||||
|
||||
| Dépendance | Version | Statut | Risque |
|
||||
|-----------|---------|--------|--------|
|
||||
| `react` | 18.2.0 | **React 19 disponible** — 1 majeure de retard | MOYEN |
|
||||
| `@tanstack/react-query` | 5.17.0 | Maintenu | Faible |
|
||||
| `zustand` | 4.5.0 | Maintenu | Faible |
|
||||
| `msw` | 2.11.2 | Maintenu | Faible |
|
||||
| `dompurify` | Utilisé via sanitize.ts | Maintenu | Faible |
|
||||
| `@juspay-tech/hyper-js` | Hyperswitch SDK | Niche — petit écosystème | MOYEN |
|
||||
|
||||
#### Rust (dépendances clés)
|
||||
|
||||
| Dépendance | Version | Risque |
|
||||
|-----------|---------|--------|
|
||||
| `axum` | 0.8 | Faible — maintenu par Tokio |
|
||||
| `sqlx` | 0.8 | Faible — maintenu |
|
||||
| `jsonwebtoken` | 10 | Faible |
|
||||
| `tonic` | 0.11 | Faible — gRPC bien maintenu |
|
||||
| `lapin` | 2.3 | MOYEN — RabbitMQ Rust, communauté petite |
|
||||
| `symphonia` | 0.5 | MOYEN — audio processing, niche |
|
||||
|
||||
### 1.4 Schéma des flux
|
||||
|
||||
#### Auth flow
|
||||
|
||||
```
|
||||
Browser → POST /api/v1/auth/register → AuthHandler → AuthService → GORM → PostgreSQL
|
||||
→ POST /api/v1/auth/login → AuthHandler → PasswordService.VerifyPassword → bcrypt
|
||||
→ Set-Cookie: access_token (httpOnly, 5min)
|
||||
→ Set-Cookie: refresh_token (httpOnly, 14j)
|
||||
→ POST /api/v1/auth/refresh → Cookie → JWTService.ValidateToken → TokenVersion check → New tokens
|
||||
→ POST /api/v1/auth/oauth/:provider → OAuthService → Google/GitHub → JWT
|
||||
```
|
||||
|
||||
**SPOF :** PostgreSQL (session lookup per request), Redis (CSRF tokens)
|
||||
**Timeout :** 30s request timeout (middleware), context propagation
|
||||
**Retry :** Pas de retry sur DB failure
|
||||
**Race condition :** Token version increment non transactionnel — deux refresh simultanés pourraient invalider l'un l'autre
|
||||
|
||||
#### Payment flow
|
||||
|
||||
```
|
||||
Frontend → POST /api/v1/marketplace/checkout → MarketplaceHandler → HyperswitchService
|
||||
→ Hyperswitch API → Create PaymentIntent → client_secret
|
||||
→ Frontend → Hyperswitch SDK → Card form → Confirm payment
|
||||
→ Hyperswitch → Webhook → POST /api/v1/webhooks/hyperswitch (?)
|
||||
→ MarketplaceService → Update order status
|
||||
```
|
||||
|
||||
**SPOF :** Hyperswitch API (externe)
|
||||
**Risque critique :** Le handler de webhook entrant Hyperswitch n'a pas été trouvé dans `webhook_handlers.go` (ce fichier ne gère que les webhooks sortants). La vérification de signature webhook est potentiellement absente.
|
||||
**Timeout :** Non vérifié pour les appels Hyperswitch
|
||||
**Idempotence :** Non vérifiée pour les webhooks de paiement
|
||||
|
||||
#### Chat flow (théorique — non intégré)
|
||||
|
||||
```
|
||||
Frontend → WebSocket /ws → Chat Server (Rust/Axum) → JWT validation → Hub
|
||||
→ Message → SQLx → PostgreSQL (chat DB séparée)
|
||||
→ Broadcast → Connected clients
|
||||
```
|
||||
|
||||
**État actuel :** Boot mode — chat server OFF. Frontend utilise MSW mocks.
|
||||
|
||||
#### Stream flow (théorique — non intégré)
|
||||
|
||||
```
|
||||
Frontend → GET /stream/hls/:track_id/playlist.m3u8 → Stream Server (Rust/Axum)
|
||||
→ JWT validation (cassée — token null) → HLS segments → Player
|
||||
```
|
||||
|
||||
**État actuel :** Stream server OFF. HLS désactivé. Frontend fallback sur des URLs directes.
|
||||
|
||||
---
|
||||
|
||||
## 2️⃣ CE QUE LE PRODUIT PERMET RÉELLEMENT
|
||||
|
||||
### 2.1 Classification des features
|
||||
|
||||
#### ✅ Fonctionnelles (flux complet front + back + DB)
|
||||
|
||||
1. **Authentication** — Register/Login/Logout avec JWT httpOnly cookies
|
||||
2. **2FA TOTP** — Activation, vérification, codes de récupération
|
||||
3. **OAuth** — Google, GitHub (Discord/Spotify : code présent mais non fonctionnel)
|
||||
4. **Profils utilisateur** — CRUD, avatar, banner, liens sociaux, profil privé
|
||||
5. **Upload audio** — Validation magic bytes, ClamAV, métadonnées
|
||||
6. **CRUD Tracks** — Création, édition, suppression, métadonnées enrichies (BPM, key, lyrics, tags)
|
||||
7. **Playlists** — CRUD, collaboration, partage, recommandations
|
||||
8. **Dashboard** — Vue d'ensemble utilisateur
|
||||
9. **Sessions** — Liste, révocation
|
||||
10. **Settings** — Profil, sécurité, notifications, préférences
|
||||
11. **Marketplace** — Catalogue produits, panier, wishlist
|
||||
12. **Search** — Recherche full-text avec pg_trgm, filtres
|
||||
13. **Social posts** — CRUD, likes, commentaires (feed basique)
|
||||
14. **RBAC** — Rôles utilisateur, middleware d'autorisation
|
||||
|
||||
#### ⚠️ Partiellement implémentées
|
||||
|
||||
| Feature | Backend | Frontend | Écart |
|
||||
|---------|---------|----------|-------|
|
||||
| **Checkout Hyperswitch** | Handler + Hyperswitch SDK | Formulaire paiement | Webhook entrant non trouvé, mode test uniquement |
|
||||
| **Promo codes** | Migration 099-100, handler | Modal + cart integration | En cours (fichiers modifiés dans git status) |
|
||||
| **Notifications** | Service + push web | Composants UI | Backend OK, frontend MSW pour certaines routes |
|
||||
| **Analytics** | Handler + service (7% complet selon audit interne) | Dashboard composants | Données réelles partielles |
|
||||
| **Admin panel** | Routes protégées | Pages admin | Fonctionnalités limitées |
|
||||
| **Webhooks** | CRUD outbound | Developer UI | Pas de delivery engine visible |
|
||||
| **Gear/Inventory** | Handler | Composants | Backend minimal |
|
||||
| **Live streaming** | Handler | Composants | Backend stub |
|
||||
| **Trending** | TrendingService | Feed explore | Algorithme basique |
|
||||
|
||||
#### 👻 Fantômes (déclarées mais absentes ou stub)
|
||||
|
||||
| Feature | Déclarée dans | Réalité |
|
||||
|---------|--------------|---------|
|
||||
| **HLS Streaming** | FEATURE_STATUS ("operational") | `HLS_STREAMING=false`, stream server OFF, `getHLSXhrSetup()` retourne token null |
|
||||
| **WebRTC Audio Calls** | CHANGELOG v0.303 | Code commenté/désactivé dans stream-server |
|
||||
| **OAuth Discord/Spotify** | FEATURE_STATUS ("operational") | Audit interne confirme : non implémentés |
|
||||
| **Chat temps réel** | FEATURE_STATUS ("operational") | Chat server en boot mode (OFF), frontend MSW |
|
||||
| **gRPC inter-services** | Architecture déclarée | Stub — protobuf généré mais endpoints non connectés |
|
||||
|
||||
#### 💀 Mortes (code présent, jamais appelé)
|
||||
|
||||
| Code mort | Fichier | LOC |
|
||||
|-----------|---------|-----|
|
||||
| `api_manager.go` | `internal/api/archive/api_manager.go` | 789 |
|
||||
| `docs.go` (Swagger généré) | `internal/handlers/docs/docs.go` | 5,482 |
|
||||
| `GenerateJWT` dans PasswordService | `internal/services/password_service.go:249` | ~20 (méthode sans iss/aud, potentiellement dangereuse si appelée) |
|
||||
| `TokenStorage.getAccessToken()` | `apps/web/src/services/tokenStorage.ts` | ~107 (tout le fichier est un no-op) |
|
||||
| `isTokenExpiringSoon()` | `apps/web/src/services/tokenRefresh.ts` | ~30 (retourne toujours true) |
|
||||
| `MOCK_PURCHASES` | `apps/web/src/services/commerceService.ts` | ~50 (données mock retournées en production) |
|
||||
| `requestRefund()` | `apps/web/src/services/commerceService.ts` | ~10 (no-op, retourne toujours `{success: true}`) |
|
||||
|
||||
#### 🧪 Expérimentales abandonnées
|
||||
|
||||
| Feature | Traces |
|
||||
|---------|--------|
|
||||
| **Éducation/Gamification** | Supprimés du code, mentionnés dans FEATURE_STATUS comme "permanently deleted" |
|
||||
| **veza-mobile** | Mentionné dans FEATURE_STATUS comme abandonné |
|
||||
| **packages/design-system** | Répertoire `packages/` vide, design system migré dans `apps/web/src/index.css` |
|
||||
|
||||
### 2.2 Incohérences produit/code
|
||||
|
||||
| Source | Affirme | Réalité code |
|
||||
|--------|---------|-------------|
|
||||
| `docs/FEATURE_STATUS.md` | "19 features operational" | ~14 véritablement fonctionnelles E2E, 5 partielles ou fantômes |
|
||||
| `docs/FEATURE_STATUS.md` | "HLS_STREAMING = true, operational" | `HLS_STREAMING=false`, service OFF, auth cassée |
|
||||
| `docs/FEATURE_STATUS.md` | "OAuth Discord + Spotify operational" | Audit interne confirme non implémentés |
|
||||
| `CHANGELOG.md` v0.303 | "WebRTC audio calls 1-to-1" | Code commenté dans stream-server |
|
||||
| `CHANGELOG.md` v0.402 | "Checkout Hyperswitch production-ready" | Mode test, webhook entrant non trouvé |
|
||||
| Audit interne (103) | Score 32/100, 21.5% features done | FEATURE_STATUS liste 19 features "operational" |
|
||||
| `V0_101_RELEASE_SCOPE.md` | "All services must be running together" | Boot mode = chat/stream/RabbitMQ/ClamAV OFF |
|
||||
|
||||
---
|
||||
|
||||
## 3️⃣ VALIDATION FONCTIONNELLE
|
||||
|
||||
### 3.1 Couverture de tests
|
||||
|
||||
| Service | Fichiers test | LOC test | LOC source | Ratio | Commentaire |
|
||||
|---------|--------------|----------|-----------|-------|-------------|
|
||||
| **Go backend** | 264 | 85,455 | 87,930 | **0.97:1** | Excellent. Tests unitaires + intégration + sécurité |
|
||||
| **Rust chat** | ~28 modules | ~5,000* | ~55,000* | ~0.09:1 | Faible. Principalement des tests unitaires inline |
|
||||
| **Rust stream** | ~30 modules | ~8,000* | ~72,000* | ~0.11:1 | Faible. Tests de charge présents mais basiques |
|
||||
| **Frontend** | 274 | 58,816 | 130,976 | **0.45:1** | Correct. Tests composants Vitest + Storybook tests |
|
||||
| **Stories** | 288 | 15,987 | — | — | Bonne couverture Storybook |
|
||||
|
||||
*Estimé à partir des 352K LOC Rust totales incluant le code généré
|
||||
|
||||
**Tests E2E :** Playwright configuré (5 configs : smoke, storybook, visual, main, patch). Scénarios E2E dans `ci.yml` avec docker-compose full stack.
|
||||
|
||||
**Tests de sécurité :** Présents dans Go (`tests/security/authorization_test.go`, `injection_attack_test.go`).
|
||||
|
||||
**Mocks vs API réelle :** 100% du frontend teste contre MSW. Aucun test frontend contre l'API réelle (sauf E2E).
|
||||
|
||||
### 3.2 Points de rupture identifiés
|
||||
|
||||
| Scénario | Impact | Mitigation existante |
|
||||
|----------|--------|---------------------|
|
||||
| Redis tombe | CSRF cassé → toutes les mutations échouent (503) | Aucune — Redis est SPOF pour CSRF en prod |
|
||||
| 10K tracks par utilisateur | Pagination cursor OK, mais pas de limite `max` documentée | Pagination offset + limit avec max configurable |
|
||||
| Fichier audio 10GB | `MaxUploadSize` configurable, validation taille | Oui — configurable dans env |
|
||||
| 1000 WebSocket simultanées | Chat server non testé sous charge en intégration | Load testing basique dans stream server |
|
||||
| Webhook Hyperswitch replay | Handler webhook entrant non trouvé | **RISQUE** — pas d'idempotence vérifiable |
|
||||
| Token expiré mid-session | Proactive refresh toutes les 4 min + retry 401 avec queue | Robuste — bien implémenté |
|
||||
| Migration partielle | Pas de transaction wrapping dans les migrations SQL | **RISQUE** — état DB incohérent possible |
|
||||
| 2 refresh simultanés | Token version increment non-atomic | **RISQUE** — race condition possible |
|
||||
|
||||
---
|
||||
|
||||
## 4️⃣ REGISTRE DES VULNÉRABILITÉS
|
||||
|
||||
| ID | Catégorie | Gravité | Fichier(s) | Description | Impact | Correctif | Effort |
|
||||
|----|-----------|---------|-----------|-------------|--------|-----------|--------|
|
||||
| VEZA-SEC-001 | A05 Misconfig | **CRITIQUE** | `.github/workflows/cd.yml` | Conditions `secrets.*` dans `if` GHA ne s'évaluent jamais → pipeline CD non fonctionnel | Aucun déploiement automatisé ne fonctionne | Utiliser `vars.*` ou étape de vérification séparée | S |
|
||||
| VEZA-SEC-002 | A07 Auth | **CRITIQUE** | `apps/web/src/services/tokenStorage.ts`, `hlsService.ts`, `websocket.ts` | `getAccessToken()` retourne `null` → HLS et WebSocket ne peuvent pas s'authentifier | Streaming non protégé ou non fonctionnel | Implémenter auth par cookie pour WS/HLS ou endpoint de stream token | M |
|
||||
| VEZA-SEC-003 | A05 Misconfig | **CRITIQUE** | `docker-compose.hybrid.yml` | `network_mode: host` + Grafana password `admin` par défaut | Infrastructure accessible depuis le réseau sans auth | Supprimer network_mode host, forcer mot de passe | S |
|
||||
| VEZA-SEC-004 | A05 Misconfig | **ÉLEVÉ** | `docker-compose.prod.yml` | Redis sans authentification en production | Cache compromis → session hijacking, data poisoning | Ajouter `--requirepass` et `REDIS_PASSWORD` | S |
|
||||
| VEZA-SEC-005 | A01 Access | **ÉLEVÉ** | `docker-compose.prod.yml:217-220` | Stream server manque `JWT_SECRET` en prod compose | Service accepte potentiellement des requêtes non authentifiées | Ajouter `JWT_SECRET` dans la config stream-server | S |
|
||||
| VEZA-SEC-006 | A04 Design | **ÉLEVÉ** | `internal/middleware/ratelimit.go` | Rate limiter in-memory → ne fonctionne pas multi-instance | Brute force multiplié par nombre d'instances | Migrer vers rate limiting Redis | M |
|
||||
| VEZA-SEC-007 | A02 Crypto | **ÉLEVÉ** | `.github/workflows/ci.yml:248,287` | Mot de passe de test E2E en clair dans le workflow | Credential leakage si repo public | Migrer vers GitHub Secrets | S |
|
||||
| VEZA-SEC-008 | A01 Access | **MOYEN** | `internal/handlers/upload.go:308-326` | `GetUploadStatus` — IDOR, pas de vérification d'ownership | Un utilisateur authentifié peut voir le statut de n'importe quel upload | Ajouter check `upload.UserID == currentUserID` | S |
|
||||
| VEZA-SEC-009 | A10 SSRF | **MOYEN** | `internal/handlers/webhook_handlers.go:69` | URL de webhook accepte tout schéma (file://, http://169.254.x.x) | SSRF via webhook delivery | Valider schéma (https only), bloquer IPs privées | S |
|
||||
| VEZA-SEC-010 | A08 Integrity | **MOYEN** | Non trouvé | Webhook entrant Hyperswitch — handler non identifié, vérification de signature incertaine | Webhooks de paiement potentiellement non vérifiés | Vérifier/implémenter vérification HMAC-SHA256 | M |
|
||||
| VEZA-SEC-011 | A05 Misconfig | **MOYEN** | `cmd/api/main.go:8` | `import _ "net/http/pprof"` en production | Profiling endpoints accessibles si DefaultServeMux exposé | Conditionner import au mode dev | S |
|
||||
| VEZA-SEC-012 | A02 Crypto | **MOYEN** | `internal/services/password_service.go:92-95` | Reset tokens stockés en clair dans PostgreSQL | Si DB compromise, tous les tokens de reset actifs sont exposés | Stocker le hash SHA-256 du token | S |
|
||||
| VEZA-SEC-013 | A07 Auth | **MOYEN** | `internal/middleware/auth.go:312-406` | `OptionalAuth` ne vérifie pas la correspondance session/user | Session hijacking silencieux sur les routes optionnelles | Ajouter vérification `session.UserID == tokenUserID` | S |
|
||||
| VEZA-SEC-014 | A04 Design | **MOYEN** | `internal/middleware/csrf.go:153` | Un seul token CSRF par utilisateur → multi-onglet cassé | UX dégradée, utilisateurs forcés de rafraîchir | Implémenter pool de tokens ou token par session | M |
|
||||
| VEZA-SEC-015 | A05 Misconfig | **MOYEN** | `docker-compose.staging.yml:64` | `JWT_SECRET=${STAGING_JWT_SECRET}` sans check `?` → peut être vide | Staging potentiellement sans validation JWT | Ajouter `:?error message` | S |
|
||||
| VEZA-SEC-016 | A01 Access | **MOYEN** | `docker-compose.staging.yml` | Ports backend/frontend exposés directement sans reverse proxy | Pas de TLS termination, pas de WAF | Ajouter HAProxy comme en prod | M |
|
||||
| VEZA-SEC-017 | A09 Logging | **MOYEN** | Multiples fichiers | 15+ `fmt.Printf` dans le code production (upload_validator.go, router.go) | Bypass du logging structuré, potentielle fuite d'info | Remplacer par `logger.Debug()` | S |
|
||||
| VEZA-SEC-018 | A07 Auth | **FAIBLE** | `apps/web/src/features/auth/store/authStore.ts:352` | `isAuthenticated` persisté dans localStorage | XSS → bypass des guards UI (backend protège toujours) | Utiliser sessionStorage ou mémoire uniquement | S |
|
||||
| VEZA-SEC-019 | A02 Crypto | **FAIBLE** | `internal/services/password_service.go:150` | Coût bcrypt hardcodé `12` au lieu de la constante `bcryptCost` | Risque d'incohérence lors de maintenance | Utiliser la constante | S |
|
||||
|
||||
---
|
||||
|
||||
## 5️⃣ DETTE TECHNIQUE
|
||||
|
||||
### 5.1 Registre de la dette
|
||||
|
||||
| Cat. | Description | Fichier(s) | Impact | Effort |
|
||||
|------|-------------|-----------|--------|--------|
|
||||
| 🔴 | **Pipeline CD non fonctionnel** — secrets dans if conditions | `.github/workflows/cd.yml` | Pas de déploiement auto | S |
|
||||
| 🔴 | **Services Rust non intégrés** — gRPC stub, boot mode | Chat/Stream servers | 60% des features annoncées non disponibles | XL |
|
||||
| 🔴 | **Auth HLS/WebSocket cassée** — tokenStorage retourne null | Frontend services | Streaming/chat non fonctionnels | M |
|
||||
| 🔴 | **Versions Go incohérentes** — 1.24 (CI) vs 1.23 (Dockerfile) | CI + Dockerfile | Build divergence possible | S |
|
||||
| 🟠 | **Rate limiter in-memory** — ne scale pas | `ratelimit.go` | Sécurité dégradée en multi-instance | M |
|
||||
| 🟠 | **Postgres version incohérente** — 15 (test/hybrid) vs 16 (dev/prod) | docker-compose files | Tests passent sur mauvaise version | S |
|
||||
| 🟠 | **Code mort ~6,500+ LOC** — api_manager, docs.go, tokenStorage, commerceService mocks | Multiple | Confusion, maintenance inutile | M |
|
||||
| 🟠 | **22 fichiers Go >500 lignes** — track/handler.go (2,262), config.go (955) | Backend handlers | Complexité élevée, refactoring nécessaire | L |
|
||||
| 🟠 | **11 fichiers TS/TSX >500 lignes** — interceptors.ts (1,203), trackApi.ts (869) | Frontend services | Complexité élevée | L |
|
||||
| 🟠 | **`commerceService.ts` retourne des mocks en prod** — MOCK_PURCHASES, fake refund | `commerceService.ts` | Utilisateurs voient des fausses données | S |
|
||||
| 🟠 | **Migrations non transactionnelles** — SQL brut sans BEGIN/COMMIT | `migrations/*.sql` | État DB incohérent si migration échoue | M |
|
||||
| 🟡 | **90+ usages de `any` dans le frontend** (hors tests/generated) | Multiple .ts/.tsx | Perte de type safety | M |
|
||||
| 🟡 | **18 fichiers avec `console.log`** en production | Frontend src/ | Pollution console, pas de contrôle log level | S |
|
||||
| 🟡 | **15+ `fmt.Printf` dans le backend** | upload_validator, router | Bypass structured logging | S |
|
||||
| 🟡 | **gin.Logger() + gin.Recovery() en double** avec custom middleware | `main.go` + `router.go` | Double logging, double recovery | S |
|
||||
| 🟡 | **`gorilla/websocket` archivé** | `go.mod` | Plus de patches sécurité | M |
|
||||
| 🟡 | **chat-server sqlx-data.json vide** `{}` | `sqlx-data.json` | Builds offline impossibles | S |
|
||||
| 🟡 | **stream-server sqlx-data.json absent** | — | Builds offline impossibles | S |
|
||||
| ⚪ | **`APP_ENV` comparaison case-sensitive** — "Production" bypass | Multiple middleware | Risque théorique | S |
|
||||
| ⚪ | **Packages npm vides** — `packages/`, `fixtures/` | Monorepo config | Confusion | S |
|
||||
|
||||
### 5.2 Quantification
|
||||
|
||||
| Métrique | Go Backend | Rust Chat | Rust Stream | Frontend | Total |
|
||||
|----------|-----------|-----------|-------------|----------|-------|
|
||||
| **LOC source** | 87,930 | ~55,000 | ~72,000 | 130,976 | ~346,000 |
|
||||
| **LOC test** | 85,455 | ~5,000 | ~8,000 | 58,816 | ~157,000 |
|
||||
| **LOC stories** | — | — | — | 15,987 | 15,987 |
|
||||
| **Ratio test/code** | 0.97:1 | ~0.09:1 | ~0.11:1 | 0.45:1 | 0.45:1 |
|
||||
| **Fichiers source** | 402 | ~50 | ~80 | 1,275 | ~1,807 |
|
||||
| **Fichiers test** | 264 | ~28 | ~30 | 274 | ~596 |
|
||||
| **TODO/FIXME/HACK** | 20 | 5 | 5 | 8 | 38 |
|
||||
| **Fichiers >500 LOC** | 22 | ~10 | ~8 | 11 | ~51 |
|
||||
| **Code mort estimé** | ~6,500 | ~2,000 | ~1,000 | ~3,500 | ~13,000 |
|
||||
| **Dépendances directes** | 44 | ~25 | ~30 | ~45 | ~144 |
|
||||
|
||||
---
|
||||
|
||||
## 6️⃣ QUALITÉ ARCHITECTURALE
|
||||
|
||||
### 6.1 Monorepo
|
||||
|
||||
| Critère | Évaluation |
|
||||
|---------|------------|
|
||||
| **Outil** | Turborepo — adapté, bien configuré |
|
||||
| **Build orchestration** | `turbo run build` — parallélisable, pas de cache custom |
|
||||
| **Versioning** | Unifié par release scope (v0.101 → v0.402) — correct |
|
||||
| **Dépendances internes** | Aucune shared package (`packages/` vide) — chaque service est indépendant |
|
||||
| **Workspace** | npm workspaces (root) + Go workspace (`go.work`) — cohérent |
|
||||
| **Problème** | Rust non intégré dans Turborepo — builds Rust gérés séparément via Makefile |
|
||||
|
||||
### 6.2 Frontend React
|
||||
|
||||
| Critère | Score |
|
||||
|---------|-------|
|
||||
| **Structure** | Feature-based (excellent) — `features/*/pages/`, `features/*/components/`, `features/*/hooks/` |
|
||||
| **State management** | Zustand (client) + React Query (server) — pattern moderne et correct |
|
||||
| **Data fetching** | React Query v5 avec invalidation, prefetching, optimistic updates |
|
||||
| **Routing** | React Router 6 avec lazy loading, route guards, preloading |
|
||||
| **Design system** | SUMI v2.0 — tokens CSS centralisés, composants shadcn/ui adaptés |
|
||||
| **TypeScript** | Strict mode activé, `noUncheckedIndexedAccess: true` — rigoureux |
|
||||
| **Storybook** | 288 stories, decorators avec providers, MSW intégré — mature |
|
||||
| **Accessibilité** | Audit A11Y documenté (`A11Y_AUDIT.md`), ARIA via shadcn/ui |
|
||||
| **MSW vs API** | 100% MSW pour composants/stories. API réelle uniquement en E2E |
|
||||
| **Problème** | Interceptors.ts à 1,203 lignes — trop complexe, à découper |
|
||||
|
||||
### 6.3 Backend Go
|
||||
|
||||
| Critère | Score |
|
||||
|---------|-------|
|
||||
| **Architecture** | Clean architecture avec séparation claire : handler → service → repository | ✅ |
|
||||
| **Error handling** | Custom `apperrors` package, errors wrappées, codes d'erreur HTTP cohérents | ✅ |
|
||||
| **Middleware stack** | 23 middlewares — complet et bien ordonné (CORS → Auth → CSRF → Handler) | ✅ |
|
||||
| **Database** | GORM + PostgreSQL, migrations numérotées, connection pooling via GORM | ✅ |
|
||||
| **Concurrency** | Graceful shutdown, context propagation, semaphore uploads | ✅ |
|
||||
| **Configuration** | Env vars validées au démarrage, production checks, secret masking | ✅ |
|
||||
| **API versioning** | `/api/v1/` — consistant | ✅ |
|
||||
| **OpenAPI** | `openapi.yaml` (3,655 lignes) + Swagger UI (dev only) | ✅ |
|
||||
| **Problème** | `track/handler.go` à 2,262 lignes — **urgent à découper** |
|
||||
|
||||
### 6.4 Services Rust
|
||||
|
||||
| Critère | Évaluation |
|
||||
|---------|------------|
|
||||
| **Chat server** | Architecture hub-based, Tokio runtime, WebSocket handler complet | Bien conçu |
|
||||
| **Stream server** | Transcoding engine, HLS segmenter, sync audio | Ambitieux |
|
||||
| **Compilation** | Compilent sans erreur (selon audit interne) | OK |
|
||||
| **Error handling** | anyhow + thiserror, propagation via `?` | Correct |
|
||||
| **Problème critique** | **Non intégrés au système** — boot mode OFF, gRPC stub, pas de tests d'intégration cross-service |
|
||||
| **Problème** | `unwrap()` en production : ~30 (chat), ~50 (stream) — certains dans des chemins critiques (rate_limiter, websocket handler) |
|
||||
| **Justification Go + Rust** | **Questionnable** pour cette taille d'équipe. Le chat server pourrait être un service Go avec gorilla/websocket. Le stream server est le seul cas justifiable (transcoding audio, performance). Le coût de maintenance de 3 langages est disproportionné. |
|
||||
|
||||
### 6.5 Base de données
|
||||
|
||||
| Critère | Évaluation |
|
||||
|---------|------------|
|
||||
| **Schéma** | 66 migrations backend, 10 chat, 2 stream — riche |
|
||||
| **Indexes** | pg_trgm pour recherche fuzzy, composite indexes, performance indexes |
|
||||
| **Extensions** | uuid-ossp, pg_trgm (migration 086) |
|
||||
| **FK constraints** | Migration 930 ajoute les FK manquantes — correction tardive |
|
||||
| **Audit triggers** | Migration 053, 910 — audit trail en DB |
|
||||
| **Problème** | Numérotation gaps (001→010→020, 069→070, 087→088→089→...→099→100→101→102, 900→910→920→930→931) — difficile à suivre |
|
||||
| **Problème** | Migration 100 fait 3 lignes (`ALTER TABLE orders ADD COLUMN discount_amount...`) — fragmentation excessive |
|
||||
| **Problème** | Pas de consolidation des 66 migrations — temps de setup initial long |
|
||||
| **Redis** | Cache, CSRF tokens, presence, trending, rate limiting (potentiel). Pas de TTL documenté systématiquement |
|
||||
|
||||
### 6.6 Scorecard
|
||||
|
||||
| Dimension | Score /10 | Justification |
|
||||
|-----------|-----------|---------------|
|
||||
| **Architecture** | **7/10** | Séparation claire des responsabilités, patterns modernes (feature-based frontend, clean arch backend). Perd des points : services Rust non intégrés, interceptors.ts monolithique, 3 langages pour une petite équipe. |
|
||||
| **Maintenabilité** | **6/10** | Code bien structuré mais 51 fichiers >500 LOC, 13K LOC de code mort, conventions parfois incohérentes (fmt.Printf vs logger). Documentation extensive mais parfois contradictoire. |
|
||||
| **Sécurité** | **5/10** | Bonnes bases (httpOnly cookies, bcrypt 12, CSRF, CSP, HSTS, secret masking). Perd des points : IDOR upload, rate limiter mémoire, Redis sans auth prod, auth HLS/WS cassée, CD pipeline mort, pprof enabled. |
|
||||
| **Scalabilité** | **4/10** | PostgreSQL single-instance, Redis SPOF, rate limiter mémoire, pas de load balancer config, WebSocket sticky sessions non gérées. Architecture permet le scaling théorique mais rien n'est configuré. |
|
||||
| **Testabilité** | **7/10** | Ratio test/code Go excellent (0.97:1), frontend correct (0.45:1), Storybook mature (288 stories). Perd des points : Rust quasi non testé, 100% MSW (aucun test composant contre API réelle), tests E2E fragiles (docker-compose full stack). |
|
||||
| **Opérabilité** | **3/10** | Pipeline CD non fonctionnel, staging incomplet (pas de chat/stream), Prometheus sans alerting, Grafana password admin, Redis sans auth. Perd beaucoup de points : impossible de déployer en production de manière fiable aujourd'hui. |
|
||||
| **Vélocité dev** | **6/10** | Storybook-first, MSW handlers, bonne documentation. Un dev React serait productif en <1 semaine. Un dev Go en ~2 semaines. Un dev Rust en 3+ semaines (code complexe, non documenté inline). |
|
||||
| **Maturité produit** | **3/10** | 14 features véritablement fonctionnelles E2E sur 600 annoncées (2.3%). 83/190 Tier 0 selon audit interne (44%). Score interne 32/100. Écart significatif entre documentation et réalité. |
|
||||
|
||||
---
|
||||
|
||||
## 7️⃣ INFRA & DEVOPS
|
||||
|
||||
### 7.1 Docker
|
||||
|
||||
| Critère | Résultat |
|
||||
|---------|---------|
|
||||
| **Dockerfiles** | Multi-stage, alpine, non-root — bien fait |
|
||||
| **docker-compose** | 5 fichiers (dev, prod, staging, test, hybrid) — trop, créent de la confusion |
|
||||
| **Secrets** | Env vars partout (pas de Docker secrets) — risque élevé en prod |
|
||||
| **Health checks** | Backend et Redis — OK. Frontend, Prometheus, Grafana — absents |
|
||||
| **Volumes** | Données persistées pour DB, Redis, RabbitMQ — OK |
|
||||
| **Réseau** | Prod: subnet /16 (trop large). Hybrid: host mode (aucune isolation) |
|
||||
| **Images** | ClamAV, Prometheus, Grafana en `latest` — non reproductible |
|
||||
|
||||
### 7.2 CI/CD
|
||||
|
||||
| Critère | Résultat |
|
||||
|---------|---------|
|
||||
| **Pipeline CI** | 12 workflows — couverture large mais incohérent (Go 1.23 vs 1.24) |
|
||||
| **Tests en CI** | Go tests + frontend tests + E2E — bonne couverture |
|
||||
| **Linting** | ESLint frontend. **Manque :** `go vet`, `gofmt`, `clippy` en CI |
|
||||
| **Security scanning** | Gitleaks uniquement. **Manque :** SAST (CodeQL), container scanning, DAST |
|
||||
| **Build** | Docker build en CI — oui, mais utilise le mauvais Dockerfile (dev au lieu de prod) |
|
||||
| **Deployment** | CD pipeline existe mais **ne fonctionne pas** (conditions secrets jamais vraies) |
|
||||
| **Environments** | Dev/staging/prod séparés en théorie. Staging manque chat/stream servers |
|
||||
| **Secrets management** | Hardcodés dans workflow files. Pas de vault. |
|
||||
|
||||
### 7.3 Reproductibilité
|
||||
|
||||
| Critère | Résultat |
|
||||
|---------|---------|
|
||||
| **Build one-command** | `docker compose up` pour dev — oui, fonctionnel |
|
||||
| **Onboarding** | Pas de ONBOARDING.md dédié. `.env.example` existe. README basique |
|
||||
| **Versions lockées** | `rust-toolchain.toml` (stable, pas de version), `go.work` (1.24), pas de `.nvmrc` |
|
||||
| **Lock files** | `go.sum` ✅, `Cargo.lock` ✅, `package-lock.json` ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 8️⃣ PERFORMANCE & SCALABILITÉ
|
||||
|
||||
| Composant | Risque | Seuil estimé | Mitigation |
|
||||
|-----------|--------|-------------|------------|
|
||||
| **PostgreSQL** | N+1 queries (GORM), full table scans possibles | >10K req/min | Indexes pg_trgm, composite indexes présents |
|
||||
| **Redis** | **SPOF** — CSRF, cache, presence, trending dépendent de Redis | Si Redis down : CSRF → 503, presence → stale, cache miss | Aucun fallback implémenté |
|
||||
| **Chat server** | WebSocket concurrentes, broadcast fan-out | >1000 connexions | Hub-based architecture, mais non testé sous charge réelle |
|
||||
| **Stream server** | Transcoding CPU-intensive, HLS segment serving | >100 streams simultanés | Semaphore pour limiter concurrence (bon) |
|
||||
| **File storage** | Stockage local par défaut, S3 optionnel | >10TB | S3 service implémenté mais non configuré par défaut |
|
||||
| **API Gateway** | Single instance, pas de load balancer configuré | >1000 req/s | HAProxy en prod compose mais config minimale |
|
||||
|
||||
**Scalabilité horizontale :** Le backend Go est stateless (sessions en DB, CSRF en Redis) — scalable horizontalement SI rate limiter migré vers Redis. Les services Rust ont des connexions WebSocket qui nécessitent sticky sessions ou Redis pub/sub pour broadcasting multi-instance.
|
||||
|
||||
---
|
||||
|
||||
## 9️⃣ RISQUES BUSINESS
|
||||
|
||||
### 9.1 Point de vue CTO
|
||||
|
||||
| Question | Réponse |
|
||||
|----------|---------|
|
||||
| Recrutement productif <2 semaines ? | **Oui pour React** (Storybook-first, bonne doc, patterns standards). **Oui pour Go** (clean architecture, tests abondants). **Non pour Rust** (code complexe, non documenté, non intégré). |
|
||||
| Vélocité soutenable ? | **Non.** 12 releases en ~3 mois avec 345+ features déclarées = ~3 features/jour. L'audit interne confirme que seules 21.5% sont réellement fonctionnelles. La vélocité est une vélocité de code, pas de produit. |
|
||||
| Dette technique explosive ? | **Oui si le rythme continue.** 13K LOC de code mort, 51 fichiers >500 lignes, features fantômes documentées comme "operational". La divergence doc/réalité va s'aggraver. |
|
||||
| Refactorings inévitables ? | 1) Intégrer ou abandonner les services Rust. 2) Migrer rate limiter vers Redis. 3) Fixer le pipeline CD. 4) Consolider les migrations. |
|
||||
| Go + Rust + React justifié ? | **Partiellement.** Go + React = justifié et bien exécuté. Rust stream server = justifiable (audio transcoding). Rust chat server = **injustifié** — un service Go avec gorilla/websocket ferait le même travail avec une maintenance unifiée. |
|
||||
|
||||
### 9.2 Point de vue investisseur
|
||||
|
||||
| Question | Réponse |
|
||||
|----------|---------|
|
||||
| Produit fonctionnel ou démo ? | **Entre les deux.** 14 features fonctionnelles E2E constituent un MVP viable (auth, upload, tracks, playlists, marketplace). Mais les features différenciantes (streaming HLS, chat temps réel, WebRTC) sont non fonctionnelles. C'est un CMS audio avec marketplace, pas une plateforme de streaming. |
|
||||
| Risques sécurité publics ? | **Oui.** Redis sans auth en prod, IDOR sur uploads, pipeline CD mort, auth streaming cassée. Un audit de sécurité professionnel est nécessaire avant tout lancement. |
|
||||
| Code repris par une autre équipe ? | **Oui.** Le code Go et React est propre, bien structuré, avec de bons tests. Le Rust est plus risqué (non intégré, peu documenté). Un onboarding de 3-4 semaines est réaliste pour une équipe de 3 devs (1 Go, 1 React, 1 Rust/infra). |
|
||||
| Coût v1.0 production-ready ? | **3-4 mois, 2-3 développeurs** (estimation basée sur : fixer sécurité 2 semaines, stabiliser services Rust 4 semaines, fixer CD/infra 2 semaines, tests E2E complets 2 semaines, polish UX 2 semaines). |
|
||||
| IP technique défendable ? | **Limitée.** Architecture standard (Go API + React SPA), pas d'algorithme propriétaire, pas de technologie unique. La valeur est dans l'exécution (qualité du code, design system SUMI, couverture tests) plutôt que dans l'innovation technique. |
|
||||
| Ratio features/qualité ? | **Red flag modéré.** La quantité (345 features déclarées) masque la qualité (21.5% fonctionnelles). Mais les features qui fonctionnent sont bien implémentées avec des tests. C'est un problème de scope control, pas de compétence. |
|
||||
|
||||
### 9.3 Point de vue acquéreur
|
||||
|
||||
| Question | Réponse |
|
||||
|----------|---------|
|
||||
| Code réutilisable ? | **Oui à 70%.** Backend Go et frontend React sont réutilisables. Services Rust = à réévaluer (garder stream, réécrire chat en Go). Infra = à refaire proprement. |
|
||||
| Données migrables ? | **Oui.** PostgreSQL standard, schéma normalisé, migrations numérotées. Export/import straightforward. |
|
||||
| Vendor-lock ? | **Faible.** Hyperswitch (paiement) est un choix moins mainstream que Stripe mais l'interface est abstraite. Pas de lock cloud (S3 compatible). |
|
||||
| Onboarding 5 devs ? | **4-6 semaines.** 2 semaines pour Go/React (bien documenté), 4 semaines pour Rust + infra (complexe, non documenté). |
|
||||
| Score rachetabilité ? | **6/10.** Code propre et testable, architecture saine, stack mainstream. Perd des points : 3 langages, services non intégrés, écart doc/réalité, dette infra. |
|
||||
|
||||
### 9.4 Verdict
|
||||
|
||||
| Question | Réponse | Justification |
|
||||
|----------|---------|---------------|
|
||||
| Lancer en production tel quel ? | **Non** | CD pipeline mort, Redis sans auth, auth streaming cassée, features fantômes |
|
||||
| Vendre / monétiser tel quel ? | **Non** | Checkout en mode test, webhook paiement non vérifié, features commerciales (streaming, chat) non fonctionnelles |
|
||||
| Maintenir avec 2 devs ? | **Conditionnel** | Oui si on abandonne les services Rust et se concentre sur Go + React. Non si on veut tout maintenir. |
|
||||
| Refactorer avant prod ? | **Oui** | Sécurité (2 semaines) + infra (2 semaines) + intégration services (4 semaines) minimum |
|
||||
| Réécrire certains services ? | **Oui** | Chat server Rust → service Go. Le stream server Rust peut être conservé mais doit être intégré. |
|
||||
| Vélocité = red flag ? | **Oui, modéré** | 345 features déclarées en ~3 mois avec un écart de 78% entre déclaré et fonctionnel suggère une optimisation pour les métriques plutôt que pour la valeur produit. Mais le code qui existe est de qualité correcte — ce n'est pas du "feature stuffing" de basse qualité. |
|
||||
|
||||
---
|
||||
|
||||
## 🔟 PLAN D'ACTION PRIORISÉ
|
||||
|
||||
### Phase 1 — Critique (semaines 1-2) — Sécurité & CI/CD
|
||||
|
||||
| # | Quoi | Pourquoi | Fichiers | Effort |
|
||||
|---|------|----------|----------|--------|
|
||||
| 1 | **Fixer pipeline CD** — remplacer `secrets.*` par `vars.*` dans les `if`, utiliser `Dockerfile.production`, ajouter `needs: ci` | Aucun déploiement ne fonctionne | `.github/workflows/cd.yml` | S |
|
||||
| 2 | **Redis auth en production** — ajouter `--requirepass`, configurer `REDIS_PASSWORD` | Cache/CSRF compromettable | `docker-compose.prod.yml` | S |
|
||||
| 3 | **Ajouter `JWT_SECRET` au stream-server** prod compose | Service potentiellement sans auth | `docker-compose.prod.yml:217` | S |
|
||||
| 4 | **Supprimer `docker-compose.hybrid.yml`** ou fixer network_mode | Infrastructure ouverte au réseau | `docker-compose.hybrid.yml` | S |
|
||||
| 5 | **Fixer auth HLS/WebSocket** — implémenter cookie-based auth ou stream token endpoint | Streaming non protégé | `hlsService.ts`, `websocket.ts`, backend `/auth/stream-token` | M |
|
||||
| 6 | **Unifier version Go** — 1.24 partout (go.mod, CI, Dockerfile) | Builds divergents | `go.mod`, `ci.yml`, `backend-ci.yml`, `Dockerfile.production` | S |
|
||||
| 7 | **Migrer secrets CI vers GitHub Secrets** | Credentials en clair dans le repo | `.github/workflows/ci.yml` | S |
|
||||
| 8 | **Fixer IDOR GetUploadStatus** — ajouter ownership check | Fuite d'information | `internal/handlers/upload.go:308` | S |
|
||||
| 9 | **Ajouter validation SSRF webhooks** — whitelist schéma, bloquer IPs privées | SSRF via webhook delivery | `webhook_handlers.go`, webhook delivery service | S |
|
||||
| 10 | **Vérifier webhook Hyperswitch** — signature HMAC-SHA256 | Paiements potentiellement non vérifiés | Handler webhook paiement (à localiser/créer) | M |
|
||||
|
||||
### Phase 2 — Stabilisation (semaines 3-6)
|
||||
|
||||
| # | Quoi | Pourquoi | Effort |
|
||||
|---|------|----------|--------|
|
||||
| 11 | **Migrer rate limiter vers Redis** | Sécurité multi-instance | M |
|
||||
| 12 | **Aligner Postgres 16 partout** (test, hybrid) | Tests sur mauvaise version | S |
|
||||
| 13 | **Compléter staging compose** (chat, stream, reverse proxy) | Staging ne reflète pas la prod | M |
|
||||
| 14 | **Ajouter alerting Prometheus** (service down, error rate, latence) | Monitoring sans alerting = inutile | M |
|
||||
| 15 | **Supprimer code mort** (~13K LOC) | Confusion, maintenance inutile | M |
|
||||
| 16 | **Supprimer/corriger commerceService mocks** | Données factices en production | S |
|
||||
| 17 | **Ajouter `go vet`, `clippy`, `gofmt` en CI** | Qualité code non vérifiée en CI | S |
|
||||
| 18 | **Remplacer `fmt.Printf` par logger structuré** (15+ occurrences) | Fuite d'info, bypass logging | S |
|
||||
| 19 | **Ajouter SAST en CI** (CodeQL ou Semgrep) | Vulnérabilités non détectées automatiquement | M |
|
||||
| 20 | **Fixer `frontend-ci.yml`** — ajouter lint, typecheck, build | PRs frontend sans vérification | S |
|
||||
|
||||
### Phase 3 — Consolidation (semaines 7-12)
|
||||
|
||||
| # | Quoi | Pourquoi | Effort |
|
||||
|---|------|----------|--------|
|
||||
| 21 | **Intégrer ou abandonner le chat server Rust** | Service non connecté, coût de maintenance | XL |
|
||||
| 22 | **Intégrer le stream server** — connecter gRPC, activer HLS | Feature différenciante non fonctionnelle | XL |
|
||||
| 23 | **Découper fichiers >1000 LOC** (track/handler.go, interceptors.ts, config.go) | Complexité maintenance | L |
|
||||
| 24 | **Consolider migrations** — squash 66 migrations en baseline | Setup initial long | L |
|
||||
| 25 | **Éliminer 90+ `any` dans le frontend** | Type safety dégradée | M |
|
||||
| 26 | **Remplacer `gorilla/websocket`** (archivé) | Plus de patches sécurité | M |
|
||||
| 27 | **Ajouter tests d'intégration cross-service** | Services jamais testés ensemble | L |
|
||||
| 28 | **Mettre en place Docker secrets** pour la prod | Secrets dans env vars | M |
|
||||
| 29 | **Aligner FEATURE_STATUS avec la réalité** | Écart doc/code = perte de confiance | S |
|
||||
| 30 | **Implémenter hash des reset tokens** | Sécurité en cas de compromission DB | S |
|
||||
|
||||
### Phase 4 — Évolution (mois 4+)
|
||||
|
||||
- Activer Hyperswitch en mode production
|
||||
- Implémenter payout (Stripe Connect — v0.403)
|
||||
- Compléter analytics (7% → 50%+)
|
||||
- Implémenter social (13% → 50%+)
|
||||
- Évaluer migration React 19
|
||||
- Considérer réécriture chat server en Go
|
||||
- Mettre en place blue-green deployment
|
||||
- Ajouter container image scanning en CI
|
||||
- Implémenter IaC (Terraform/Pulumi)
|
||||
|
||||
---
|
||||
|
||||
## ANNEXES
|
||||
|
||||
### A. Arbre des dépendances inter-services
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
│ Frontend │
|
||||
│ React/Vite │
|
||||
└──────┬───────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ Backend Go │ │Chat Rust │ │Stream │
|
||||
│ (API REST) │ │(WebSocket│ │Rust (HLS)│
|
||||
└─────┬──────┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ │
|
||||
┌─────┼─────┐ ┌────┘ ┌───┘
|
||||
│ │ │ │ │
|
||||
▼ ▼ ▼ ▼ ▼
|
||||
┌────┐ ┌────┐ ┌─────┐ ┌───────────┐
|
||||
│ PG │ │Redis│ │Rabbit│ │ PG (chat) │
|
||||
└────┘ └────┘ └─────┘ └───────────┘
|
||||
|
||||
Légende:
|
||||
──── = Connexion fonctionnelle
|
||||
- - - = Connexion prévue mais non connectée (gRPC stub)
|
||||
```
|
||||
|
||||
### B. Métriques brutes
|
||||
|
||||
```
|
||||
Total LOC (source + test + stories) : ~519,000
|
||||
Total fichiers source : ~2,400
|
||||
Total fichiers test : ~596
|
||||
Total stories : 288
|
||||
Total migrations SQL : 78
|
||||
Total workflows CI : 12
|
||||
Total scripts : 85+
|
||||
Total docs markdown : 332
|
||||
Total dépendances directes : ~144
|
||||
```
|
||||
|
||||
### C. Fichiers critiques à auditer en priorité
|
||||
|
||||
1. `veza-backend-api/internal/middleware/auth.go` (704 LOC)
|
||||
2. `veza-backend-api/internal/middleware/ratelimit.go` (189 LOC)
|
||||
3. `veza-backend-api/internal/config/config.go` (955 LOC)
|
||||
4. `apps/web/src/services/api/interceptors.ts` (1,203 LOC)
|
||||
5. `apps/web/src/services/tokenStorage.ts` (107 LOC)
|
||||
6. `.github/workflows/cd.yml` (170 LOC)
|
||||
7. `docker-compose.prod.yml` (301 LOC)
|
||||
8. `veza-backend-api/internal/handlers/upload.go` (627 LOC)
|
||||
|
||||
---
|
||||
|
||||
## CONCLUSION STRATÉGIQUE
|
||||
|
||||
Veza est un projet techniquement compétent dans son exécution Go/React, mais souffrant d'un **excès d'ambition architecturale** par rapport à ses ressources. Le choix de trois langages (Go, Rust, TypeScript) pour un MVP crée une charge de maintenance disproportionnée. Les services Rust, bien que compilables, ne sont pas intégrés au système et représentent ~132K LOC de code non productif.
|
||||
|
||||
**La recommandation stratégique est : investir, mais avec recadrage.**
|
||||
|
||||
Le code Go et React constitue une base solide et testée. La sécurité auth (httpOnly cookies, JWT 5min, bcrypt 12) est supérieure à la moyenne des startups early-stage. Le design system SUMI et l'approche Storybook-first démontrent une maturité UX réelle.
|
||||
|
||||
Cependant, l'écart entre le narratif (345+ features, 12 releases) et la réalité (14 features E2E, score interne 32/100) est un signal d'alarme pour un investisseur. Ce n'est pas un signe de mauvaise foi technique — le code qui existe est de qualité — mais d'un scope management déficient et d'une communication produit trop optimiste.
|
||||
|
||||
**Avec 4-6 semaines de stabilisation ciblée et un recadrage stratégique (abandonner le chat Rust, intégrer le stream server, fixer l'infra), Veza peut devenir un MVP commercialisable.** Sans ce recadrage, la dette technique et l'écart doc/réalité continueront de croître, rendant le produit de plus en plus difficile à maintenir et à vendre.
|
||||
|
||||
**Verdict final : Investir sous condition de recadrage technique et produit dans les 60 jours.**
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { PromoCodeModal } from './PromoCodeModal';
|
||||
|
||||
const meta: Meta<typeof PromoCodeModal> = {
|
||||
title: 'Components/Commerce/PromoCodeModal',
|
||||
component: PromoCodeModal,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="min-h-[400px]">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
onClose: () => {},
|
||||
onApply: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
onClose: () => {},
|
||||
onApply: () => {},
|
||||
},
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/commerce/promo/:code', async () => {
|
||||
await new Promise(() => {}); // Never resolves
|
||||
}),
|
||||
],
|
||||
},
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Loading state when validating. Enter any code and click Apply.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
onClose: () => {},
|
||||
onApply: () => {},
|
||||
},
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/commerce/promo/:code', () =>
|
||||
HttpResponse.json({ success: false, error: 'Invalid or expired promo code' }, { status: 404 })
|
||||
),
|
||||
],
|
||||
},
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Error state when code is invalid. Enter any code and click Apply.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
onClose: () => {},
|
||||
onApply: () => {},
|
||||
},
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/commerce/promo/:code', () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
data: { code: 'VEZA20', discount_type: 'percent', discount_value_cents: 2000 },
|
||||
})
|
||||
),
|
||||
],
|
||||
},
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Success: enter VEZA20 and click Apply. Modal closes and calls onApply.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -2,10 +2,17 @@ import React, { useState } from 'react';
|
|||
import { Button } from '../../ui/button';
|
||||
import { Input } from '../../ui/input';
|
||||
import { X, Tag, Check, AlertCircle } from 'lucide-react';
|
||||
import { marketplaceService } from '@/services/marketplaceService';
|
||||
|
||||
export interface PromoDiscount {
|
||||
code: string;
|
||||
type: 'percent' | 'fixed';
|
||||
amount: number; // percent: 20 = 20%, fixed: 5.00 = 5 EUR
|
||||
}
|
||||
|
||||
interface PromoCodeModalProps {
|
||||
onClose: () => void;
|
||||
onApply: (discountPercent: number, code: string) => void;
|
||||
onApply: (discount: PromoDiscount) => void;
|
||||
}
|
||||
|
||||
export const PromoCodeModal: React.FC<PromoCodeModalProps> = ({
|
||||
|
|
@ -13,18 +20,32 @@ export const PromoCodeModal: React.FC<PromoCodeModalProps> = ({
|
|||
onApply,
|
||||
}) => {
|
||||
const [code, setCode] = useState('');
|
||||
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const handleApply = () => {
|
||||
// Mock validation
|
||||
if (code.toUpperCase() === 'VEZA20') {
|
||||
const handleApply = async () => {
|
||||
if (!code.trim()) return;
|
||||
setStatus('loading');
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const data = await marketplaceService.validatePromoCode(code.trim());
|
||||
// Convert API format to OrderSummary format: percent 2000 → 20, fixed 500 → 5.00
|
||||
const discount: PromoDiscount = {
|
||||
code: data.code,
|
||||
type: data.discount_type,
|
||||
amount:
|
||||
data.discount_type === 'percent'
|
||||
? data.discount_value_cents / 100
|
||||
: data.discount_value_cents / 100,
|
||||
};
|
||||
setStatus('success');
|
||||
setTimeout(() => {
|
||||
onApply(20, 'VEZA20');
|
||||
onApply(discount);
|
||||
onClose();
|
||||
}, 1000);
|
||||
} else {
|
||||
}, 500);
|
||||
} catch {
|
||||
setStatus('error');
|
||||
setErrorMessage('Invalid or expired promo code');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -59,13 +80,13 @@ export const PromoCodeModal: React.FC<PromoCodeModalProps> = ({
|
|||
|
||||
{status === 'error' && (
|
||||
<div className="flex items-center gap-2 text-xs text-destructive animate-shake">
|
||||
<AlertCircle className="w-3 h-3" /> Invalid promo code
|
||||
<AlertCircle className="w-3 h-3" /> {errorMessage || 'Invalid promo code'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="flex items-center gap-2 text-xs text-success animate-fadeIn">
|
||||
<Check className="w-3 h-3" /> Code applied! 20% Off
|
||||
<Check className="w-3 h-3" /> Code applied!
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -73,9 +94,9 @@ export const PromoCodeModal: React.FC<PromoCodeModalProps> = ({
|
|||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleApply}
|
||||
disabled={!code}
|
||||
disabled={!code.trim() || status === 'loading'}
|
||||
>
|
||||
Apply Discount
|
||||
{status === 'loading' ? 'Validating...' : 'Apply Discount'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export {
|
|||
LazySocial,
|
||||
LazyGear,
|
||||
LazyLive,
|
||||
LazyCloud,
|
||||
LazyQueue,
|
||||
LazyDeveloper,
|
||||
LazyNotifications,
|
||||
|
|
|
|||
|
|
@ -163,6 +163,11 @@ export const LazyLive = createLazyComponent(
|
|||
undefined,
|
||||
'Live',
|
||||
);
|
||||
export const LazyCloud = createLazyComponent(
|
||||
() => import('@/features/cloud/pages/CloudPage'),
|
||||
undefined,
|
||||
'Cloud',
|
||||
);
|
||||
export const LazyQueue = createLazyComponent(
|
||||
() =>
|
||||
import('@/features/library/pages/QueuePage').then((m) => ({
|
||||
|
|
|
|||
31
apps/web/src/features/checkout/CheckoutErrorView.stories.tsx
Normal file
31
apps/web/src/features/checkout/CheckoutErrorView.stories.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { CheckoutErrorView } from './CheckoutErrorView';
|
||||
|
||||
const meta: Meta<typeof CheckoutErrorView> = {
|
||||
title: 'Features/Checkout/CheckoutErrorView',
|
||||
component: CheckoutErrorView,
|
||||
parameters: { layout: 'fullscreen' },
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="bg-background min-h-layout-page">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { orderId: 'ord-abc12345', status: 'failed' },
|
||||
};
|
||||
|
||||
export const Cancelled: Story = {
|
||||
args: { orderId: 'ord-abc12345', status: 'cancelled' },
|
||||
};
|
||||
|
||||
export const WithRetry: Story = {
|
||||
args: { orderId: 'ord-xyz67890', status: 'failed' },
|
||||
};
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { CheckoutSuccessView } from './CheckoutSuccessView';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
const meta: Meta<typeof CheckoutSuccessView> = {
|
||||
title: 'Features/Checkout/CheckoutSuccessView',
|
||||
component: CheckoutSuccessView,
|
||||
parameters: { layout: 'fullscreen' },
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="bg-background min-h-layout-page">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const mockOrder = {
|
||||
id: 'ord-abc12345',
|
||||
status: 'completed',
|
||||
total_amount: 29.99,
|
||||
currency: 'EUR',
|
||||
items: [
|
||||
{ product_id: 'prod-1', price: 19.99 },
|
||||
{ product_id: 'prod-2', price: 10 },
|
||||
],
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: { order: mockOrder },
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col items-center justify-center min-h-layout-page px-4 py-12">
|
||||
<div className="max-w-md w-full space-y-8 text-center">
|
||||
<Skeleton className="h-24 w-24 rounded-full mx-auto" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48 mx-auto" />
|
||||
<Skeleton className="h-4 w-64 mx-auto" />
|
||||
</div>
|
||||
<Skeleton className="h-32 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col items-center justify-center min-h-layout-page px-4 py-12">
|
||||
<div className="max-w-md w-full space-y-8 text-center">
|
||||
<div className="rounded-full bg-destructive/10 p-4 mx-auto w-fit">
|
||||
<div className="h-16 w-16 rounded-full bg-destructive/30" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold text-foreground">Commande introuvable</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Cette commande n'existe pas ou a expiré.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
132
apps/web/src/features/cloud/CloudPage.stories.tsx
Normal file
132
apps/web/src/features/cloud/CloudPage.stories.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import CloudPage from './pages/CloudPage';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
const meta: Meta<typeof CloudPage> = {
|
||||
title: 'Cloud/CloudPage',
|
||||
component: CloudPage,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CloudPage>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Loading: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/cloud/folders', async () => {
|
||||
await new Promise(() => {});
|
||||
return HttpResponse.json({ folders: [] });
|
||||
}),
|
||||
http.get('*/api/v1/cloud/files', async () => {
|
||||
await new Promise(() => {});
|
||||
return HttpResponse.json({ files: [] });
|
||||
}),
|
||||
http.get('*/api/v1/cloud/quota', async () => {
|
||||
await new Promise(() => {});
|
||||
return HttpResponse.json({
|
||||
quota: {
|
||||
max_bytes: 5368709120,
|
||||
used_bytes: 0,
|
||||
available: 5368709120,
|
||||
percentage: 0,
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/cloud/folders', () => HttpResponse.json({ folders: [] })),
|
||||
http.get('*/api/v1/cloud/files', () => HttpResponse.json({ files: [] })),
|
||||
http.get('*/api/v1/cloud/quota', () =>
|
||||
HttpResponse.json({
|
||||
quota: {
|
||||
max_bytes: 5368709120,
|
||||
used_bytes: 0,
|
||||
available: 5368709120,
|
||||
percentage: 0,
|
||||
},
|
||||
})
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/cloud/folders', () =>
|
||||
HttpResponse.json({ error: 'Server error' }, { status: 500 })
|
||||
),
|
||||
http.get('*/api/v1/cloud/files', () =>
|
||||
HttpResponse.json({ error: 'Server error' }, { status: 500 })
|
||||
),
|
||||
http.get('*/api/v1/cloud/quota', () =>
|
||||
HttpResponse.json({ error: 'Server error' }, { status: 500 })
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const QuotaFull: Story = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/cloud/folders', () =>
|
||||
HttpResponse.json({
|
||||
folders: [
|
||||
{
|
||||
id: 'f1',
|
||||
name: 'My Tracks',
|
||||
parent_id: null,
|
||||
created_at: '2026-01-15T10:00:00Z',
|
||||
updated_at: '2026-01-15T10:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.get('*/api/v1/cloud/files', () =>
|
||||
HttpResponse.json({
|
||||
files: [
|
||||
{
|
||||
id: 'c1',
|
||||
user_id: 'u1',
|
||||
folder_id: 'f1',
|
||||
filename: 'huge-project.wav',
|
||||
s3_key: 'cloud/u1/c1/huge-project.wav',
|
||||
size_bytes: 4800000000,
|
||||
mime_type: 'audio/wav',
|
||||
created_at: '2026-02-01T12:00:00Z',
|
||||
updated_at: '2026-02-01T12:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.get('*/api/v1/cloud/quota', () =>
|
||||
HttpResponse.json({
|
||||
quota: {
|
||||
max_bytes: 5368709120,
|
||||
used_bytes: 5100000000,
|
||||
available: 268709120,
|
||||
percentage: 95,
|
||||
},
|
||||
})
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
106
apps/web/src/features/cloud/components/CloudBrowserView.tsx
Normal file
106
apps/web/src/features/cloud/components/CloudBrowserView.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { CloudFolderTree } from './CloudFolderTree';
|
||||
import { CloudFileList } from './CloudFileList';
|
||||
import { cloudService, type CloudFolder, type CloudFile, type CloudQuota } from '../services/cloudService';
|
||||
|
||||
interface CloudBrowserViewProps {
|
||||
onUploadClick?: () => void;
|
||||
}
|
||||
|
||||
export function CloudBrowserView({ onUploadClick }: CloudBrowserViewProps) {
|
||||
const [folders, setFolders] = useState<CloudFolder[]>([]);
|
||||
const [files, setFiles] = useState<CloudFile[]>([]);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [quota, setQuota] = useState<CloudQuota | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [selectedFolderId]);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [folderData, fileData, quotaData] = await Promise.all([
|
||||
cloudService.listFolders(selectedFolderId ?? undefined),
|
||||
cloudService.listFiles(selectedFolderId ?? undefined),
|
||||
cloudService.getQuota(),
|
||||
]);
|
||||
setFolders((prev) => {
|
||||
const otherFolders = prev.filter(
|
||||
(f) => f.parent_id !== (selectedFolderId ?? null) || !selectedFolderId
|
||||
);
|
||||
return [...otherFolders, ...folderData].filter(
|
||||
(f, i, arr) => arr.findIndex((x) => x.id === f.id) === i
|
||||
);
|
||||
});
|
||||
setFiles(fileData);
|
||||
setQuota(quotaData);
|
||||
} catch {
|
||||
// Errors handled silently
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full gap-6">
|
||||
<aside className="w-56 shrink-0 border-r border-border pr-4">
|
||||
<CloudFolderTree
|
||||
folders={folders}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelectFolder={setSelectedFolderId}
|
||||
onCreateFolder={async () => {
|
||||
const name = prompt('Folder name:');
|
||||
if (name) {
|
||||
await cloudService.createFolder(name, selectedFolderId ?? undefined);
|
||||
loadData();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{quota && (
|
||||
<div className="mt-6 px-3">
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
Storage: {((quota.used_bytes / quota.max_bytes) * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all"
|
||||
style={{ width: `${Math.min((quota.used_bytes / quota.max_bytes) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{formatBytes(quota.used_bytes)} / {formatBytes(quota.max_bytes)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 min-w-0">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<CloudFileList
|
||||
files={files}
|
||||
onDeleteFile={async (id) => {
|
||||
await cloudService.deleteFile(id);
|
||||
loadData();
|
||||
}}
|
||||
onPublishFile={async (id) => {
|
||||
await cloudService.publishAsTrack(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
84
apps/web/src/features/cloud/components/CloudFileList.tsx
Normal file
84
apps/web/src/features/cloud/components/CloudFileList.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { FileAudioIcon, Trash2, Upload } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CloudFile } from '../services/cloudService';
|
||||
|
||||
interface CloudFileListProps {
|
||||
files: CloudFile[];
|
||||
onDeleteFile?: (id: string) => void;
|
||||
onPublishFile?: (id: string) => void;
|
||||
onPreviewFile?: (file: CloudFile) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function CloudFileList({
|
||||
files,
|
||||
onDeleteFile,
|
||||
onPublishFile,
|
||||
onPreviewFile,
|
||||
className,
|
||||
}: CloudFileListProps) {
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-16 text-muted-foreground', className)}>
|
||||
<FileAudioIcon className="h-12 w-12 mb-4 opacity-40" />
|
||||
<p className="text-lg font-medium">No files yet</p>
|
||||
<p className="text-sm mt-1">Upload audio files to get started</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('divide-y divide-border', className)}>
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center gap-4 px-4 py-3 hover:bg-muted/50 transition-colors cursor-pointer group"
|
||||
onClick={() => onPreviewFile?.(file)}
|
||||
>
|
||||
<FileAudioIcon className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{file.filename}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size_bytes)} · {file.mime_type.split('/')[1]?.toUpperCase()} · {formatDate(file.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{onPublishFile && file.mime_type.startsWith('audio/') && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPublishFile(file.id); }}
|
||||
className="p-1.5 rounded hover:bg-primary/10 text-primary"
|
||||
title="Publish as track"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onDeleteFile && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteFile(file.id); }}
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-destructive"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { CloudFilePreview } from './CloudFilePreview';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
const mockAudioFile = {
|
||||
id: 'c1000000-0000-0000-0000-000000000001',
|
||||
user_id: 'u1000000-0000-0000-0000-000000000001',
|
||||
folder_id: 'f1000000-0000-0000-0000-000000000001',
|
||||
filename: 'sunset-beat.mp3',
|
||||
s3_key: 'cloud/u1/c1/sunset-beat.mp3',
|
||||
size_bytes: 4500000,
|
||||
mime_type: 'audio/mpeg',
|
||||
created_at: '2026-02-01T12:00:00Z',
|
||||
updated_at: '2026-02-01T12:00:00Z',
|
||||
};
|
||||
|
||||
const meta: Meta<typeof CloudFilePreview> = {
|
||||
title: 'Cloud/CloudFilePreview',
|
||||
component: CloudFilePreview,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/cloud/files/:id/stream', () => {
|
||||
return new HttpResponse(new ArrayBuffer(1024), {
|
||||
headers: {
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Accept-Ranges': 'bytes',
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CloudFilePreview>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
file: mockAudioFile,
|
||||
onClose: () => {},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
file: null,
|
||||
onClose: () => {},
|
||||
},
|
||||
render: (args) => (
|
||||
<div className="p-4 bg-muted/30 rounded-lg border border-dashed border-border">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
CloudFilePreview renders nothing when file is null:
|
||||
</p>
|
||||
<CloudFilePreview {...args} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
103
apps/web/src/features/cloud/components/CloudFilePreview.tsx
Normal file
103
apps/web/src/features/cloud/components/CloudFilePreview.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Play, Pause, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CloudFile } from '../services/cloudService';
|
||||
import { cloudService } from '../services/cloudService';
|
||||
|
||||
interface CloudFilePreviewProps {
|
||||
file: CloudFile | null;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CloudFilePreview({ file, onClose, className }: CloudFilePreviewProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
setDuration(0);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
}
|
||||
}, [file?.id]);
|
||||
|
||||
if (!file || !file.mime_type.startsWith('audio/')) return null;
|
||||
|
||||
const streamUrl = cloudService.getStreamUrl(file.id);
|
||||
|
||||
function togglePlay() {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play();
|
||||
}
|
||||
setIsPlaying(!isPlaying);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3 bg-muted/50 rounded-lg border border-border',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={streamUrl}
|
||||
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
|
||||
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
preload="metadata"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="p-2 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 shrink-0"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{file.filename}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div
|
||||
className="flex-1 h-1 bg-muted rounded-full overflow-hidden cursor-pointer"
|
||||
onClick={(e) => {
|
||||
if (!audioRef.current || duration === 0) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
audioRef.current.currentTime = pct * duration;
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-primary rounded-full"
|
||||
style={{
|
||||
width: `${duration > 0 ? (currentTime / duration) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground tabular-nums whitespace-nowrap">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button onClick={onClose} className="p-1 hover:bg-muted rounded shrink-0">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
apps/web/src/features/cloud/components/CloudFolderTree.tsx
Normal file
119
apps/web/src/features/cloud/components/CloudFolderTree.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { useState } from 'react';
|
||||
import { FolderIcon, FolderOpenIcon, ChevronRight, ChevronDown, Plus } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CloudFolder } from '../services/cloudService';
|
||||
|
||||
interface CloudFolderTreeProps {
|
||||
folders: CloudFolder[];
|
||||
selectedFolderId: string | null;
|
||||
onSelectFolder: (folderId: string | null) => void;
|
||||
onCreateFolder?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CloudFolderTree({
|
||||
folders,
|
||||
selectedFolderId,
|
||||
onSelectFolder,
|
||||
onCreateFolder,
|
||||
className,
|
||||
}: CloudFolderTreeProps) {
|
||||
const rootFolders = folders.filter((f) => !f.parent_id);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-1', className)}>
|
||||
<button
|
||||
onClick={() => onSelectFolder(null)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
|
||||
selectedFolderId === null
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'text-muted-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
All Files
|
||||
</button>
|
||||
|
||||
{rootFolders.map((folder) => (
|
||||
<FolderItem
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
allFolders={folders}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelectFolder={onSelectFolder}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
|
||||
{onCreateFolder && (
|
||||
<button
|
||||
onClick={onCreateFolder}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-muted-foreground hover:bg-muted transition-colors mt-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Folder
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderItem({
|
||||
folder,
|
||||
allFolders,
|
||||
selectedFolderId,
|
||||
onSelectFolder,
|
||||
depth,
|
||||
}: {
|
||||
folder: CloudFolder;
|
||||
allFolders: CloudFolder[];
|
||||
selectedFolderId: string | null;
|
||||
onSelectFolder: (id: string | null) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const children = allFolders.filter((f) => f.parent_id === folder.id);
|
||||
const isSelected = selectedFolderId === folder.id;
|
||||
const Icon = expanded ? FolderOpenIcon : FolderIcon;
|
||||
const Chevron = expanded ? ChevronDown : ChevronRight;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => onSelectFolder(folder.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm w-full transition-colors',
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:bg-muted'
|
||||
)}
|
||||
style={{ paddingLeft: `${12 + depth * 16}px` }}
|
||||
>
|
||||
{children.length > 0 && (
|
||||
<Chevron
|
||||
className="h-3 w-3 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">{folder.name}</span>
|
||||
</button>
|
||||
|
||||
{expanded &&
|
||||
children.map((child) => (
|
||||
<FolderItem
|
||||
key={child.id}
|
||||
folder={child}
|
||||
allFolders={allFolders}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelectFolder={onSelectFolder}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { CloudUploadModal } from './CloudUploadModal';
|
||||
|
||||
const meta: Meta<typeof CloudUploadModal> = {
|
||||
title: 'Cloud/CloudUploadModal',
|
||||
component: CloudUploadModal,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CloudUploadModal>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
isOpen: true,
|
||||
onClose: () => {},
|
||||
quota: {
|
||||
user_id: 'u1',
|
||||
max_bytes: 5368709120,
|
||||
used_bytes: 121500000,
|
||||
available: 5247209120,
|
||||
percentage: 2.26,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
isOpen: true,
|
||||
onClose: () => {},
|
||||
quota: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const QuotaAlmostFull: Story = {
|
||||
args: {
|
||||
isOpen: true,
|
||||
onClose: () => {},
|
||||
quota: {
|
||||
user_id: 'u1',
|
||||
max_bytes: 5368709120,
|
||||
used_bytes: 5100000000,
|
||||
available: 268709120,
|
||||
percentage: 95,
|
||||
},
|
||||
},
|
||||
};
|
||||
189
apps/web/src/features/cloud/components/CloudUploadModal.tsx
Normal file
189
apps/web/src/features/cloud/components/CloudUploadModal.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
import { Upload, X, FileAudioIcon, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cloudService, type CloudQuota } from '../services/cloudService';
|
||||
|
||||
interface CloudUploadModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
folderId?: string;
|
||||
quota?: CloudQuota | null;
|
||||
onUploadComplete?: () => void;
|
||||
}
|
||||
|
||||
export function CloudUploadModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
folderId,
|
||||
quota,
|
||||
onUploadComplete,
|
||||
}: CloudUploadModalProps) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const maxFileSize = 500 * 1024 * 1024;
|
||||
|
||||
function addFiles(newFiles: File[]) {
|
||||
const valid = newFiles.filter((f) => {
|
||||
if (f.size > maxFileSize) {
|
||||
setError(`${f.name} is too large (max 500MB)`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
setFiles((prev) => [...prev, ...valid]);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
addFiles(droppedFiles);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
function removeFile(index: number) {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const totalSize = files.reduce((sum, f) => sum + f.size, 0);
|
||||
if (quota && totalSize > quota.available) {
|
||||
setError('Not enough storage space');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await cloudService.uploadFile(files[i], folderId);
|
||||
setProgress(((i + 1) / files.length) * 100);
|
||||
}
|
||||
setFiles([]);
|
||||
onUploadComplete?.();
|
||||
onClose();
|
||||
} catch {
|
||||
setError('Upload failed. Please try again.');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-background border border-border rounded-2xl shadow-xl w-full max-w-lg mx-4">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h2 className="text-lg font-semibold">Upload Files</h2>
|
||||
<button onClick={onClose} className="p-1 hover:bg-muted rounded-lg">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors',
|
||||
isDragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
|
||||
)}
|
||||
>
|
||||
<Upload className="h-10 w-10 mx-auto mb-3 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">
|
||||
Drop files here or <span className="text-primary">browse</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Audio files up to 500MB</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*,.zip,.mid,.midi"
|
||||
onChange={(e) => addFiles(Array.from(e.target.files || []))}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="mt-4 space-y-2 max-h-48 overflow-y-auto">
|
||||
{files.map((file, i) => (
|
||||
<div key={i} className="flex items-center gap-3 p-2 rounded-lg bg-muted/50">
|
||||
<FileAudioIcon className="h-4 w-4 text-primary shrink-0" />
|
||||
<span className="text-sm truncate flex-1">{file.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{formatSize(file.size)}</span>
|
||||
<button onClick={() => removeFile(i)} className="p-0.5 hover:bg-muted rounded">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploading && (
|
||||
<div className="mt-4">
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 text-center">
|
||||
{Math.round(progress)}% uploaded
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 flex items-center gap-2 text-destructive text-sm">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{quota && (
|
||||
<div className="mt-4 text-xs text-muted-foreground">
|
||||
Available: {formatSize(quota.available)} / {formatSize(quota.max_bytes)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-border">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm rounded-lg hover:bg-muted">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={files.length === 0 || uploading}
|
||||
className="px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{uploading ? 'Uploading...' : `Upload ${files.length} file${files.length !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
12
apps/web/src/features/cloud/pages/CloudPage.tsx
Normal file
12
apps/web/src/features/cloud/pages/CloudPage.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* Cloud page — full layout page for cloud storage (Lot C1)
|
||||
* V0_501: Cloud storage MVP — upload, browse, preview, publish
|
||||
*/
|
||||
|
||||
import { CloudView } from './cloud-page/CloudView';
|
||||
|
||||
export function CloudPage() {
|
||||
return <CloudView />;
|
||||
}
|
||||
|
||||
export default CloudPage;
|
||||
120
apps/web/src/features/cloud/pages/cloud-page/CloudView.tsx
Normal file
120
apps/web/src/features/cloud/pages/cloud-page/CloudView.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { useState } from 'react';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { ContentFadeIn } from '@/components/ui/ContentFadeIn';
|
||||
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
|
||||
import { CloudFolderTree } from '../../components/CloudFolderTree';
|
||||
import { CloudFileList } from '../../components/CloudFileList';
|
||||
import { CloudUploadModal } from '../../components/CloudUploadModal';
|
||||
import { CloudFilePreview } from '../../components/CloudFilePreview';
|
||||
import { useCloudPage } from './useCloudPage';
|
||||
import { CloudViewSkeleton } from './CloudViewSkeleton';
|
||||
import type { CloudFile } from '../../services/cloudService';
|
||||
import { cloudService } from '../../services/cloudService';
|
||||
import toast from '@/utils/toast';
|
||||
|
||||
export function CloudView() {
|
||||
const {
|
||||
folders,
|
||||
files,
|
||||
quota,
|
||||
selectedFolderId,
|
||||
setSelectedFolderId,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
refetchFiles,
|
||||
} = useCloudPage();
|
||||
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
const [previewFile, setPreviewFile] = useState<CloudFile | null>(null);
|
||||
const handleUploadComplete = () => {
|
||||
void refetchFiles();
|
||||
toast.success('Files uploaded');
|
||||
};
|
||||
|
||||
const handleDeleteFile = async (id: string) => {
|
||||
try {
|
||||
await cloudService.deleteFile(id);
|
||||
void refetchFiles();
|
||||
toast.success('File deleted');
|
||||
} catch {
|
||||
toast.error('Failed to delete file');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublishFile = async (id: string) => {
|
||||
try {
|
||||
await cloudService.publishAsTrack(id);
|
||||
toast.success('Track published');
|
||||
} catch {
|
||||
toast.error('Failed to publish as track');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFolder = () => {
|
||||
toast.info('New folder dialog coming soon');
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return <CloudViewSkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-layout-page flex items-center justify-center p-6">
|
||||
<ErrorDisplay error={error} variant="card" onRetry={() => void refetch()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentFadeIn className="min-h-layout-page flex flex-col pb-24">
|
||||
<div className="flex gap-6 p-6 flex-1">
|
||||
<aside className="w-56 shrink-0 border-r border-border pr-4">
|
||||
<CloudFolderTree
|
||||
folders={folders}
|
||||
selectedFolderId={selectedFolderId}
|
||||
onSelectFolder={setSelectedFolderId}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{selectedFolderId ? 'Folder' : 'All Files'}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsUploadOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<CloudFileList
|
||||
files={files}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onPublishFile={handlePublishFile}
|
||||
onPreviewFile={setPreviewFile}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{previewFile && (
|
||||
<div className="fixed bottom-20 left-0 right-0 px-6 max-w-2xl mx-auto">
|
||||
<CloudFilePreview file={previewFile} onClose={() => setPreviewFile(null)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CloudUploadModal
|
||||
isOpen={isUploadOpen}
|
||||
onClose={() => setIsUploadOpen(false)}
|
||||
folderId={selectedFolderId ?? undefined}
|
||||
quota={quota}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
</ContentFadeIn>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
export function CloudViewSkeleton() {
|
||||
return (
|
||||
<div className="min-h-layout-page flex gap-6 p-6 animate-fadeIn">
|
||||
<div className="w-56 shrink-0 space-y-2">
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<Skeleton className="h-10 w-full rounded-lg" />
|
||||
<Skeleton className="h-10 w-full rounded-lg mt-4" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-4">
|
||||
<Skeleton className="h-12 w-full rounded-lg" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
<Skeleton className="h-16 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/features/cloud/pages/cloud-page/useCloudPage.ts
Normal file
72
apps/web/src/features/cloud/pages/cloud-page/useCloudPage.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
cloudService,
|
||||
type CloudFolder,
|
||||
type CloudFile,
|
||||
type CloudQuota,
|
||||
} from '../../services/cloudService';
|
||||
|
||||
export function useCloudPage() {
|
||||
const [folders, setFolders] = useState<CloudFolder[]>([]);
|
||||
const [files, setFiles] = useState<CloudFile[]>([]);
|
||||
const [quota, setQuota] = useState<CloudQuota | null>(null);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchAllFolders = useCallback(async (): Promise<CloudFolder[]> => {
|
||||
const roots = await cloudService.listFolders();
|
||||
const all: CloudFolder[] = [...roots];
|
||||
for (const root of roots) {
|
||||
const children = await cloudService.listFolders(root.id);
|
||||
all.push(...children);
|
||||
}
|
||||
return all;
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [allFolders, quotaRes] = await Promise.all([
|
||||
fetchAllFolders(),
|
||||
cloudService.getQuota(),
|
||||
]);
|
||||
setFolders(allFolders);
|
||||
setQuota(quotaRes);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [fetchAllFolders]);
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
try {
|
||||
const fileList = await cloudService.listFiles(selectedFolderId ?? undefined);
|
||||
setFiles(fileList);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}, [selectedFolderId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
return {
|
||||
folders,
|
||||
files,
|
||||
quota,
|
||||
selectedFolderId,
|
||||
setSelectedFolderId,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: load,
|
||||
refetchFiles: loadFiles,
|
||||
};
|
||||
}
|
||||
83
apps/web/src/features/cloud/services/cloudService.ts
Normal file
83
apps/web/src/features/cloud/services/cloudService.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { apiClient } from '@/services/api/client';
|
||||
|
||||
export interface CloudFolder {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CloudFile {
|
||||
id: string;
|
||||
user_id: string;
|
||||
folder_id: string | null;
|
||||
filename: string;
|
||||
s3_key: string;
|
||||
size_bytes: number;
|
||||
mime_type: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CloudQuota {
|
||||
user_id: string;
|
||||
max_bytes: number;
|
||||
used_bytes: number;
|
||||
available: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export const cloudService = {
|
||||
async listFolders(parentId?: string): Promise<CloudFolder[]> {
|
||||
const params = parentId ? `?parent_id=${parentId}` : '';
|
||||
const res = await apiClient.get(`/cloud/folders${params}`);
|
||||
return res.data.folders;
|
||||
},
|
||||
|
||||
async createFolder(name: string, parentId?: string): Promise<CloudFolder> {
|
||||
const res = await apiClient.post('/cloud/folders', { name, parent_id: parentId });
|
||||
return res.data.folder;
|
||||
},
|
||||
|
||||
async renameFolder(id: string, name: string): Promise<void> {
|
||||
await apiClient.put(`/cloud/folders/${id}`, { name });
|
||||
},
|
||||
|
||||
async deleteFolder(id: string): Promise<void> {
|
||||
await apiClient.delete(`/cloud/folders/${id}`);
|
||||
},
|
||||
|
||||
async listFiles(folderId?: string): Promise<CloudFile[]> {
|
||||
const params = folderId ? `?folder_id=${folderId}` : '';
|
||||
const res = await apiClient.get(`/cloud/files${params}`);
|
||||
return res.data.files;
|
||||
},
|
||||
|
||||
async uploadFile(file: File, folderId?: string): Promise<CloudFile> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (folderId) formData.append('folder_id', folderId);
|
||||
const res = await apiClient.post('/cloud/files', formData);
|
||||
return res.data.file;
|
||||
},
|
||||
|
||||
async deleteFile(id: string): Promise<void> {
|
||||
await apiClient.delete(`/cloud/files/${id}`);
|
||||
},
|
||||
|
||||
async getQuota(): Promise<CloudQuota> {
|
||||
const res = await apiClient.get('/cloud/quota');
|
||||
return res.data.quota;
|
||||
},
|
||||
|
||||
async publishAsTrack(fileId: string): Promise<void> {
|
||||
await apiClient.post(`/cloud/files/${fileId}/publish`);
|
||||
},
|
||||
|
||||
getStreamUrl(fileId: string): string {
|
||||
const baseUrl = import.meta.env.VITE_API_URL || '/api/v1';
|
||||
return `${baseUrl}/cloud/files/${fileId}/stream`;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { GearImageGallery } from './GearImageGallery';
|
||||
|
||||
const mockImages = [
|
||||
{
|
||||
id: 'img1',
|
||||
image_url: 'https://placehold.co/600x400/1a1a2e/e94560?text=Front+View',
|
||||
position: 0,
|
||||
},
|
||||
{
|
||||
id: 'img2',
|
||||
image_url: 'https://placehold.co/600x400/16213e/0f3460?text=Side+View',
|
||||
position: 1,
|
||||
},
|
||||
{
|
||||
id: 'img3',
|
||||
image_url: 'https://placehold.co/600x400/1a1a2e/533483?text=Detail',
|
||||
position: 2,
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<typeof GearImageGallery> = {
|
||||
title: 'Inventory/GearImageGallery',
|
||||
component: GearImageGallery,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-md p-6">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof GearImageGallery>;
|
||||
|
||||
export const WithImages: Story = {
|
||||
args: { images: mockImages },
|
||||
};
|
||||
|
||||
export const SingleImage: Story = {
|
||||
args: { images: [mockImages[0]] },
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { images: [], onAddImage: () => console.log('Add image') },
|
||||
};
|
||||
|
||||
export const Editable: Story = {
|
||||
args: {
|
||||
images: mockImages.slice(0, 2),
|
||||
onAddImage: () => console.log('Add image'),
|
||||
onRemoveImage: (id: string) => console.log('Remove image', id),
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { useState } from 'react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface GearImage {
|
||||
id: string;
|
||||
image_url: string;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface GearImageGalleryProps {
|
||||
images: GearImage[];
|
||||
onAddImage?: () => void;
|
||||
onRemoveImage?: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GearImageGallery({
|
||||
images,
|
||||
onAddImage,
|
||||
onRemoveImage,
|
||||
className,
|
||||
}: GearImageGalleryProps) {
|
||||
const sorted = [...images].sort((a, b) => a.position - b.position);
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
if (sorted.length === 0 && !onAddImage) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center h-48 bg-muted rounded-xl border border-border text-muted-foreground text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
No images
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
{sorted.length > 0 && (
|
||||
<div className="relative aspect-video rounded-xl overflow-hidden border border-border bg-muted">
|
||||
<img
|
||||
src={sorted[selected]?.image_url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{onRemoveImage && sorted[selected] && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveImage(sorted[selected].id)}
|
||||
className="absolute top-2 right-2 p-1 bg-black/60 rounded-full text-white hover:bg-black/80 transition-colors"
|
||||
aria-label="Remove image"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{sorted.map((img, idx) => (
|
||||
<button
|
||||
key={img.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(idx)}
|
||||
className={cn(
|
||||
'w-16 h-16 rounded-lg overflow-hidden border-2 flex-shrink-0 transition-colors',
|
||||
idx === selected
|
||||
? 'border-primary'
|
||||
: 'border-border hover:border-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={img.image_url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{onAddImage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddImage}
|
||||
className="w-16 h-16 rounded-lg border-2 border-dashed border-border flex items-center justify-center text-muted-foreground hover:border-primary hover:text-primary transition-colors flex-shrink-0"
|
||||
aria-label="Add image"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { GearShowcase } from './GearShowcase';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
const mockPublicGear = [
|
||||
{
|
||||
id: 'g1',
|
||||
name: 'Fender Stratocaster',
|
||||
category: 'Guitar',
|
||||
brand: 'Fender',
|
||||
model: 'American Professional II',
|
||||
image: '',
|
||||
condition: 'Excellent',
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: 'g2',
|
||||
name: 'Universal Audio Apollo',
|
||||
category: 'Audio Interface',
|
||||
brand: 'Universal Audio',
|
||||
model: 'Apollo Twin X',
|
||||
image: '',
|
||||
condition: 'Good',
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: 'g3',
|
||||
name: 'Shure SM7B',
|
||||
category: 'Microphone',
|
||||
brand: 'Shure',
|
||||
model: 'SM7B',
|
||||
image: '',
|
||||
condition: 'Like New',
|
||||
is_public: true,
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<typeof GearShowcase> = {
|
||||
title: 'Inventory/GearShowcase',
|
||||
component: GearShowcase,
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/users/:username/gear', () =>
|
||||
HttpResponse.json({
|
||||
items: mockPublicGear,
|
||||
count: mockPublicGear.length,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-3xl p-6">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof GearShowcase>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { username: 'johndoe' },
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { username: 'emptyguy' },
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/users/:username/gear', () =>
|
||||
HttpResponse.json({ items: [], count: 0 }),
|
||||
),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
113
apps/web/src/features/inventory/components/GearShowcase.tsx
Normal file
113
apps/web/src/features/inventory/components/GearShowcase.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { apiClient } from '@/services/api/client';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PublicGearItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
image: string;
|
||||
condition: string;
|
||||
is_public: boolean;
|
||||
}
|
||||
|
||||
export interface GearShowcaseProps {
|
||||
username: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GearShowcase({ username, className }: GearShowcaseProps) {
|
||||
const [items, setItems] = useState<PublicGearItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
apiClient
|
||||
.get<{ items: PublicGearItem[]; count: number }>(
|
||||
`/users/${username}/gear`,
|
||||
)
|
||||
.then((res) => {
|
||||
if (!cancelled) setItems(res.data?.items ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setItems([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [username]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn('grid grid-cols-1 sm:grid-cols-2 gap-4', className)}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-32 rounded-xl bg-muted animate-pulse border border-border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'text-center py-12 text-muted-foreground text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
No public gear to display.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('grid grid-cols-1 sm:grid-cols-2 gap-4', className)}>
|
||||
{items.map((item) => (
|
||||
<Card key={item.id} variant="glass" className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<Badge label={item.category} variant="terminal" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.condition}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<div className="w-16 h-16 bg-muted rounded-lg border border-border overflow-hidden flex-shrink-0">
|
||||
{item.image ? (
|
||||
<img
|
||||
src={item.image}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
|
||||
—
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-sm font-bold text-foreground truncate">
|
||||
{item.name}
|
||||
</h4>
|
||||
<p className="text-xs text-primary font-mono">{item.brand}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{item.model}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -117,7 +117,12 @@ export const GearView: React.FC<GearViewProps> = ({
|
|||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
/>
|
||||
<GearViewToolbar viewMode={viewMode} onViewModeChange={setViewMode} />
|
||||
<GearViewToolbar
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
searchQuery={search}
|
||||
onSearch={setSearch}
|
||||
/>
|
||||
<GearInventoryGrid
|
||||
items={filteredInventory}
|
||||
viewMode={viewMode}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,37 @@
|
|||
import { LayoutGrid, List } from 'lucide-react';
|
||||
import { LayoutGrid, List, Search } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { GearViewMode } from '../../components/gear';
|
||||
|
||||
export interface GearViewToolbarProps {
|
||||
viewMode: GearViewMode;
|
||||
onViewModeChange: (mode: GearViewMode) => void;
|
||||
searchQuery?: string;
|
||||
onSearch?: (query: string) => void;
|
||||
}
|
||||
|
||||
export function GearViewToolbar({ viewMode, onViewModeChange }: GearViewToolbarProps) {
|
||||
export function GearViewToolbar({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
searchQuery = '',
|
||||
onSearch,
|
||||
}: GearViewToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground font-mono">View</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground font-mono">View</span>
|
||||
{onSearch && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search gear..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
className="pl-9 pr-3 py-2 text-sm bg-muted rounded-lg border-0 focus:ring-2 focus:ring-primary outline-none w-48"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 bg-muted p-1 rounded-lg border border-border">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCartStore } from '@/stores/cartStore';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ShoppingCart, Trash2, Minus, Plus } from 'lucide-react';
|
||||
import { ShoppingCart, Trash2, Minus, Plus, Tag } from 'lucide-react';
|
||||
import { marketplaceService } from '@/services/marketplaceService';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useState, useRef } from 'react';
|
||||
|
|
@ -10,6 +10,8 @@ import { parseApiError } from '@/utils/apiErrorHandler';
|
|||
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
|
||||
import { motion, AnimatePresence, type Variants } from 'framer-motion';
|
||||
import { CheckoutPaymentForm } from '@/features/checkout/CheckoutPaymentForm';
|
||||
import { OrderSummary } from '@/components/commerce/OrderSummary';
|
||||
import { PromoCodeModal, type PromoDiscount } from '@/components/commerce/modals/PromoCodeModal';
|
||||
|
||||
// FE-PAGE-006: Complete Marketplace page implementation - Cart Component
|
||||
|
||||
|
|
@ -35,6 +37,8 @@ export function Cart({ isOpen, onClose }: CartProps) {
|
|||
const [retryCount, setRetryCount] = useState(0);
|
||||
const lastMutationRef = useRef<(() => Promise<void>) | null>(null);
|
||||
const [paymentStep, setPaymentStep] = useState<{ clientSecret: string; orderId: string } | null>(null);
|
||||
const [appliedPromo, setAppliedPromo] = useState<PromoDiscount | null>(null);
|
||||
const [promoModalOpen, setPromoModalOpen] = useState(false);
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
|
|
@ -60,8 +64,9 @@ export function Cart({ isOpen, onClose }: CartProps) {
|
|||
const orderItems = items.map((item) => ({
|
||||
product_id: item.product.id,
|
||||
}));
|
||||
const promoCode = appliedPromo?.code;
|
||||
const performMutation = async () => {
|
||||
const resp = await marketplaceService.createOrder(orderItems);
|
||||
const resp = await marketplaceService.createOrder(orderItems, promoCode);
|
||||
if (resp.client_secret && resp.order?.id) {
|
||||
setPaymentStep({ clientSecret: resp.client_secret, orderId: resp.order.id });
|
||||
setMutationError(null);
|
||||
|
|
@ -120,6 +125,15 @@ export function Cart({ isOpen, onClose }: CartProps) {
|
|||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{promoModalOpen && (
|
||||
<PromoCodeModal
|
||||
onClose={() => setPromoModalOpen(false)}
|
||||
onApply={(d) => {
|
||||
setAppliedPromo(d);
|
||||
setPromoModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{mutationError && (
|
||||
<ErrorDisplay
|
||||
error={mutationError}
|
||||
|
|
@ -217,28 +231,51 @@ export function Cart({ isOpen, onClose }: CartProps) {
|
|||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="border-t border-border pt-4 space-y-4">
|
||||
<div className="flex justify-between text-lg font-semibold">
|
||||
<span>Total</span>
|
||||
<span>
|
||||
{items.length > 0 && items[0]
|
||||
? formatPrice(getTotal(), items[0].product.currency)
|
||||
: '€0.00'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{!appliedPromo && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={clearCart}
|
||||
className="flex-1"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setPromoModalOpen(true)}
|
||||
>
|
||||
Clear Cart
|
||||
<Tag className="w-3.5 h-3.5 mr-1.5" />
|
||||
Add promo code
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCheckout}
|
||||
disabled={isCheckingOut}
|
||||
className="flex-1"
|
||||
>
|
||||
{isCheckingOut ? 'Processing...' : 'Checkout'}
|
||||
)}
|
||||
{appliedPromo && (
|
||||
<div className="flex items-center justify-between text-sm text-success">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Tag className="w-3.5 h-3.5" /> {appliedPromo.code}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => setAppliedPromo(null)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<OrderSummary
|
||||
subtotal={getTotal()}
|
||||
taxRate={0}
|
||||
discount={
|
||||
appliedPromo
|
||||
? {
|
||||
code: appliedPromo.code,
|
||||
amount: appliedPromo.amount,
|
||||
type: appliedPromo.type,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
currency={items[0]?.product.currency ?? 'EUR'}
|
||||
onCheckout={handleCheckout}
|
||||
loading={isCheckingOut}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={clearCart} className="flex-1">
|
||||
Clear Cart
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
LazyDeveloper,
|
||||
LazyGear,
|
||||
LazyLive,
|
||||
LazyCloud,
|
||||
} from '@/components/ui/LazyComponent';
|
||||
import { PublicRoute } from './PublicRoute';
|
||||
import { ProtectedLayoutRoute } from './ProtectedLayoutRoute';
|
||||
|
|
@ -103,6 +104,8 @@ export function getProtectedRoutes(): RouteEntry[] {
|
|||
{ path: '/gear', element: wrapProtected(<LazyGear />) },
|
||||
// Live: connected to backend live streams API
|
||||
{ path: '/live', element: wrapProtected(<LazyLive />) },
|
||||
// Cloud: connected to backend cloud storage API
|
||||
{ path: '/cloud', element: wrapProtected(<LazyCloud />) },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
147
docs/PLAN_ACTION_AUDIT.md
Normal file
147
docs/PLAN_ACTION_AUDIT.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# Plan d'action post-audit — Veza Monorepo
|
||||
|
||||
**Date** : 2026-02-22
|
||||
**Base** : AUDIT_TECHNIQUE_2026-02-22.md (Cursor/Claude 4.6 Opus)
|
||||
**Périmètre** : v0.404 (stabilisation) + préparation v0.501
|
||||
|
||||
---
|
||||
|
||||
## Légende
|
||||
|
||||
| Symbole | Signification |
|
||||
|---------|---------------|
|
||||
| 🔴 | Bloquant production — doit être corrigé avant tout déploiement |
|
||||
| 🟠 | Élevé — doit être corrigé avant la fin de v0.404 |
|
||||
| 🟡 | Moyen — planifiable en v0.404 ou début v0.501 |
|
||||
| S | < 1 jour |
|
||||
| M | 1–3 jours |
|
||||
| L | 3–5 jours |
|
||||
| XL | > 5 jours |
|
||||
|
||||
---
|
||||
|
||||
## Sprint 1 — Sécurité critique (jours 1–5)
|
||||
|
||||
> **Objectif** : Éliminer les vulnérabilités exploitables. Aucune feature, aucun refactoring.
|
||||
|
||||
| # | Ticket | Gravité | Effort | Fichiers impactés | Critère de complétion |
|
||||
|---|--------|---------|--------|--------------------|-----------------------|
|
||||
| 001 | **Fixer pipeline CD** — remplacer `if: secrets.*` par `if: vars.*` dans les conditions GitHub Actions. Ajouter `needs: ci` sur le job deploy. Pointer sur `Dockerfile.production`. | 🔴 | S | `.github/workflows/cd.yml` | Pipeline CD exécute push+deploy sur merge main |
|
||||
| 002 | **Auth Redis production** — ajouter `--requirepass $REDIS_PASSWORD` au service Redis dans le compose prod. Propager `REDIS_PASSWORD` dans les env du backend et des services Rust. | 🔴 | S | `docker-compose.prod.yml`, `config.go` (Redis init), `chat-server/config.rs`, `stream-server/config.rs` | Redis refuse les connexions sans mot de passe |
|
||||
| 003 | **Fixer auth HLS/WebSocket** — implémenter un endpoint `POST /auth/stream-token` qui génère un token éphémère (5 min, usage unique, scope streaming). Le frontend l'utilise en query param pour WS et HLS au lieu de `TokenStorage.getAccessToken()`. | 🔴 | M | Backend : nouveau handler `auth/stream_token.go`, middleware stream auth. Frontend : `hlsService.ts`, `websocket.ts`, `tokenStorage.ts` | HLS playback et WebSocket chat fonctionnent avec auth valide |
|
||||
| 004 | **Supprimer `docker-compose.hybrid.yml`** ou retirer `network_mode: host` + changer le mot de passe Grafana par défaut | 🔴 | S | `docker-compose.hybrid.yml` | Fichier supprimé ou sécurisé |
|
||||
| 005 | **JWT_SECRET pour stream-server** — ajouter la variable dans le compose prod, vérifier que le stream-server valide les tokens avec le même secret que le backend | 🔴 | S | `docker-compose.prod.yml:217`, `stream-server/config.rs` | Stream-server rejette les requêtes sans JWT valide |
|
||||
| 006 | **Fixer IDOR GetUploadStatus** — ajouter vérification `upload.user_id == authenticated_user.id` | 🟠 | S | `internal/handlers/upload.go:308` | Un user ne peut voir que ses propres uploads |
|
||||
| 007 | **Validation SSRF webhooks** — whitelist schémas (https uniquement), bloquer IPs privées (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16) dans le webhook delivery service | 🟠 | S | `webhook_handlers.go`, webhook delivery service | Les webhooks ne peuvent cibler que des URLs publiques HTTPS |
|
||||
| 008 | **Vérifier signature webhook Hyperswitch** — implémenter validation HMAC-SHA256 sur le handler de callback paiement. Rejeter tout webhook sans signature valide. | 🟠 | M | Handler webhook paiement (localiser dans `internal/handlers/`) | Webhooks sans signature valide retournent 401 |
|
||||
| 009 | **Unifier version Go** — 1.24 dans go.mod, go.work, CI workflows (`ci.yml`, `backend-ci.yml`), et `Dockerfile.production` | 🟠 | S | `go.mod`, `go.work`, `.github/workflows/ci.yml`, `.github/workflows/backend-ci.yml`, `Dockerfile.production` | `go version` identique partout |
|
||||
| 010 | **Migrer secrets CI** — déplacer toute credential en clair dans les workflows vers GitHub Secrets. Vérifier qu'aucun secret ne reste dans les fichiers YAML. | 🟠 | S | `.github/workflows/*.yml` | `grep -r "password\|secret\|token" .github/` ne retourne rien de sensible |
|
||||
|
||||
**Livrable Sprint 1** : Tag `v0.404-alpha1`. Les 5 blocages critiques de l'audit sont résolus.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 2 — Infra & CI/CD (jours 6–12)
|
||||
|
||||
> **Objectif** : Rendre le pipeline de déploiement fiable et le monitoring opérationnel.
|
||||
|
||||
| # | Ticket | Gravité | Effort | Fichiers impactés | Critère de complétion |
|
||||
|---|--------|---------|--------|--------------------|-----------------------|
|
||||
| 011 | **Migrer rate limiter vers Redis** — remplacer le rate limiter in-memory par un rate limiter basé sur Redis (clé `ratelimit:{user_id}:{route}`, TTL sliding window). Conserver le fallback in-memory si Redis est down. | 🟠 | M | `internal/middleware/ratelimit.go`, `internal/config/config.go` | Rate limiting fonctionne en multi-instance. Test : 2 instances, la limite s'applique globalement. |
|
||||
| 012 | **Aligner PostgreSQL 16 partout** — remplacer `postgres:15-alpine` par `postgres:16-alpine` dans test et hybrid composes | 🟠 | S | `docker-compose.test.yml`, `docker-compose.hybrid.yml` | Toutes les composes utilisent PG 16 |
|
||||
| 013 | **Fixer `frontend-ci.yml`** — ajouter étapes lint (`eslint`), typecheck (`tsc --noEmit`), build (`vite build`) | 🟠 | S | `.github/workflows/frontend-ci.yml` | Les PRs frontend échouent si lint, typecheck ou build échouent |
|
||||
| 014 | **Ajouter `go vet` et `gofmt` en CI** — étape dédiée dans le workflow backend | 🟡 | S | `.github/workflows/backend-ci.yml` | `go vet ./...` et `gofmt -l .` exécutés en CI |
|
||||
| 015 | **Ajouter `clippy` en CI pour les services Rust** | 🟡 | S | `.github/workflows/ci.yml` (ou nouveau `rust-ci.yml`) | `cargo clippy -- -D warnings` exécuté en CI pour chat + stream |
|
||||
| 016 | **Ajouter SAST** — configurer CodeQL ou Semgrep dans un workflow dédié, couvrant Go + TypeScript | 🟡 | M | Nouveau `.github/workflows/sast.yml` | SAST s'exécute sur chaque PR, résultats visibles dans les checks |
|
||||
| 017 | **Compléter staging compose** — ajouter chat-server, stream-server, et reverse proxy (Caddy/Nginx) au compose staging | 🟡 | M | `docker-compose.staging.yml` (créer ou compléter) | `docker compose -f docker-compose.staging.yml up` démarre tous les services |
|
||||
| 018 | **Ajouter alerting Prometheus** — configurer des alertes pour : service down > 30s, error rate > 5%, latence P99 > 2s, Redis unreachable | 🟡 | M | `config/prometheus/`, nouveau `config/alertmanager/` | Alertes se déclenchent quand les seuils sont dépassés |
|
||||
| 019 | **Health checks Docker** — ajouter `healthcheck` pour chaque service dans les composes prod et staging | 🟡 | S | `docker-compose.prod.yml`, `docker-compose.staging.yml` | `docker compose ps` montre le statut santé de chaque service |
|
||||
| 020 | **Implémenter hash des reset tokens** — hasher les password reset tokens en base (SHA-256), comparer le hash lors de la validation | 🟡 | S | Handler password reset, migration (ajouter colonne `token_hash` si nécessaire) | Un dump DB ne permet pas d'utiliser les reset tokens |
|
||||
|
||||
**Livrable Sprint 2** : Tag `v0.404-alpha2`. CI/CD fiable, monitoring avec alertes, infra sécurisée.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 3 — Nettoyage & Qualité code (jours 13–20)
|
||||
|
||||
> **Objectif** : Réduire la dette technique critique, éliminer le code mort, aligner doc et réalité.
|
||||
|
||||
| # | Ticket | Gravité | Effort | Fichiers impactés | Critère de complétion |
|
||||
|---|--------|---------|--------|--------------------|-----------------------|
|
||||
| 021 | **Supprimer code mort** (~13K LOC) — `internal/api/archive/api_manager.go` (789 LOC), `dev-environment/templates/` non utilisés, `fixtures/` vide, `packages/` vide, `veza-docs/` non alimenté | 🟡 | M | Voir liste. Vérifier avec `grep -r` qu'aucun import ne référence ces fichiers. | LOC total réduit de ~13K. Aucune régression. |
|
||||
| 022 | **Supprimer/corriger mocks commerceService** — les données factices (`getSellerStats`, etc.) doivent soit pointer vers les API réelles (v0.401+ les a créées), soit être clairement marquées comme non-prod | 🟡 | S | `apps/web/src/services/commerceService.ts` (localiser les mocks) | Aucun mock ne retourne de données factices en mode production |
|
||||
| 023 | **Remplacer `fmt.Printf` par logger structuré** — 15+ occurrences identifiées dans le backend. Utiliser `zap` déjà configuré. | 🟡 | S | `grep -rn "fmt.Print" veza-backend-api/internal/` pour la liste exacte | Aucun `fmt.Printf` dans `internal/` (hors tests) |
|
||||
| 024 | **Éliminer les `any` TypeScript** — 90+ occurrences. Remplacer par des types concrets ou `unknown` avec type guards. Prioriser les fichiers services/ et stores/. | 🟡 | L | `apps/web/src/services/`, `apps/web/src/stores/` | `grep -rn ": any" apps/web/src/ | wc -l` < 10 |
|
||||
| 025 | **Aligner FEATURE_STATUS.md avec la réalité** — pour chaque feature "opérationnelle", vérifier le flux E2E. Dégrader en "partielle" les features qui dépendent de MSW ou de services Rust OFF. | 🟡 | M | `docs/FEATURE_STATUS.md` | Le document reflète fidèlement l'état du code |
|
||||
| 026 | **Aligner TypeScript versions** — 5.3.3 dans `apps/web/package.json` vs 5.9.3 dans root. Unifier sur la dernière stable. | 🟡 | S | `package.json` (root + apps/web) | `grep -r "typescript" */package.json` retourne la même version |
|
||||
| 027 | **Nettoyer gRPC protobuf dupliqué** — les fichiers proto générés sont dupliqués entre chat et stream. Centraliser dans un dossier `proto/` partagé ou un build step commun. | 🟡 | M | `veza-chat-server/proto/`, `veza-stream-server/proto/` | Un seul jeu de fichiers proto, importé par les deux services |
|
||||
| 028 | **Documenter la décision Rust** — écrire un ADR (Architecture Decision Record) expliquant pourquoi Go + Rust, quand utiliser quoi, et le plan pour le chat server (garder Rust ou migrer Go) | 🟡 | S | Nouveau `docs/adr/ADR-001-rust-services.md` | ADR rédigé et référencé dans docs/README.md |
|
||||
|
||||
**Livrable Sprint 3** : Tag `v0.404-beta`. Codebase nettoyé, documentation alignée.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 4 — Intégration services & Tests (jours 21–30)
|
||||
|
||||
> **Objectif** : Décider du sort des services Rust. Ajouter les tests d'intégration manquants.
|
||||
|
||||
| # | Ticket | Gravité | Effort | Fichiers impactés | Critère de complétion |
|
||||
|---|--------|---------|--------|--------------------|-----------------------|
|
||||
| 029 | **Décision : chat server Rust — intégrer ou remplacer** — Évaluer le coût d'intégration (gRPC fonctionnel, auth, persistence) vs le coût de réécriture en Go. Produire un document de décision. Si intégration : continuer ticket 030. Si remplacement : planifier en v0.501. | 🟠 | S (décision) | — | ADR-002 rédigé avec décision argumentée |
|
||||
| 030 | **Intégrer le stream server Rust** — connecter gRPC entre backend Go et stream server, activer HLS (`HLS_STREAMING=true`), vérifier le flux upload → transcode → HLS serve → player | 🟠 | XL | `veza-stream-server/`, `veza-backend-api/internal/services/stream/`, `docker-compose.prod.yml`, `apps/web/src/services/hlsService.ts` | Un track uploadé est jouable en HLS via le stream server |
|
||||
| 031 | **Tests d'intégration cross-service** — écrire des tests qui vérifient les flux critiques avec tous les services démarrés (Docker Compose test). Minimum : auth → upload → playback, register → login → chat. | 🟡 | L | Nouveau dossier `tests/integration/` ou dans `scripts/` | 5+ scénarios E2E qui passent en CI |
|
||||
| 032 | **Fixer tests désactivés backend** — `metrics_test.go`, `profile_handler_test.go`, `system_metrics_test.go` désactivés pour bitrot. Les corriger ou les supprimer. | 🟡 | M | Fichiers de test identifiés dans le CHANGELOG (section Known Issues) | Aucun test `skip` ou `disabled` restant |
|
||||
| 033 | **Ajouter tests Rust** — les services Rust ont peu/pas de tests. Ajouter au minimum : tests unitaires JWT validation, tests WebSocket message routing, tests de non-régression. | 🟡 | L | `veza-chat-server/tests/`, `veza-stream-server/tests/` | `cargo test` passe avec > 20 tests par service |
|
||||
| 034 | **Remplacer `gorilla/websocket`** — librairie archivée (déc. 2024), plus de patches sécurité. Migrer vers `nhooyr.io/websocket` ou `coder/websocket`. | 🟡 | M | `veza-backend-api/` — tous les fichiers importants `gorilla/websocket` | Aucune dépendance à `gorilla/websocket` |
|
||||
|
||||
**Livrable Sprint 4** : Tag `v0.404-rc1`. Services intégrés ou décision documentée. Tests d'intégration en place.
|
||||
|
||||
---
|
||||
|
||||
## Sprint 5 — Finalisation v0.404 (jours 31–35)
|
||||
|
||||
> **Objectif** : Valider la stabilisation, tagger, préparer v0.501.
|
||||
|
||||
| # | Ticket | Gravité | Effort | Fichiers impactés | Critère de complétion |
|
||||
|---|--------|---------|--------|--------------------|-----------------------|
|
||||
| 035 | **Smoke test prod** — déployer sur l'environnement staging la config prod complète. Vérifier les 14 features E2E identifiées dans l'audit. Documenter les résultats. | 🟠 | M | Staging environment | 14/14 features E2E fonctionnent en staging |
|
||||
| 036 | **Mettre à jour PROJECT_STATE.md** pour v0.404 | 🟡 | S | `docs/PROJECT_STATE.md` | Reflète l'état post-stabilisation |
|
||||
| 037 | **Mettre à jour SCOPE_CONTROL.md** — référence active → v0.501 | 🟡 | S | `docs/SCOPE_CONTROL.md` | Référence pointe vers `V0_501_RELEASE_SCOPE.md` |
|
||||
| 038 | **Archiver V0_404_RELEASE_SCOPE.md** dans `docs/archive/` | 🟡 | S | `docs/archive/` | Document archivé |
|
||||
| 039 | **Tag v0.404** | — | S | Git | Tag créé, CHANGELOG mis à jour |
|
||||
| 040 | **Rétrospective audit** — comparer les scores pré/post stabilisation. Documenter ce qui reste à traiter en v0.501+. | 🟡 | S | Nouveau `docs/RETRO_V0_404.md` | Document rédigé |
|
||||
|
||||
**Livrable Sprint 5** : **Tag `v0.404`**. Stabilisation terminée. Prêt pour Phase 5.
|
||||
|
||||
---
|
||||
|
||||
## Résumé
|
||||
|
||||
| Sprint | Jours | Tickets | Focus |
|
||||
|--------|-------|---------|-------|
|
||||
| 1 | 1–5 | 001–010 | Sécurité critique |
|
||||
| 2 | 6–12 | 011–020 | Infra & CI/CD |
|
||||
| 3 | 13–20 | 021–028 | Nettoyage & Qualité |
|
||||
| 4 | 21–30 | 029–034 | Intégration & Tests |
|
||||
| 5 | 31–35 | 035–040 | Finalisation & Tag |
|
||||
|
||||
**Durée totale** : ~7 semaines (35 jours ouvrés)
|
||||
**Tickets** : 40
|
||||
**Répartition effort** : 14 S, 14 M, 5 L, 2 XL, 5 tâches administratives
|
||||
|
||||
---
|
||||
|
||||
## Tickets reportés à v0.501+
|
||||
|
||||
Ces éléments de l'audit ne sont pas bloquants pour la stabilisation mais doivent être traités :
|
||||
|
||||
| Ticket | Description | Version cible |
|
||||
|--------|-------------|---------------|
|
||||
| Découper fichiers > 1000 LOC | `track/handler.go`, `interceptors.ts`, `config.go` | v0.501 |
|
||||
| Consolider migrations | Squash 78 migrations en baseline | v0.501 |
|
||||
| Migration React 19 | React 18.2.0 → 19. Évaluer impact hooks. | v0.602 |
|
||||
| Réécriture chat server Go | Si décision ADR-002 = remplacement | v0.501–v0.502 |
|
||||
| Blue-green deployment | Nécessite reverse proxy + health checks | v0.601 |
|
||||
| Container image scanning | Trivy ou Grype en CI | v0.501 |
|
||||
| IaC (Terraform/Pulumi) | Infrastructure as Code | v0.801 |
|
||||
| Hyperswitch mode production | Passer de test à live | v0.501 (si payout ready) |
|
||||
247
docs/V0_501_RELEASE_SCOPE.md
Normal file
247
docs/V0_501_RELEASE_SCOPE.md
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
# V0_501_RELEASE_SCOPE.md — Phase 5 : Streaming & Cloud
|
||||
|
||||
**Phase** : 5 — Streaming & Cloud (redéfinie, ex-Education/Gear)
|
||||
**Version précédente** : v0.404 (Stabilisation post-audit)
|
||||
**Version suivante** : v0.502
|
||||
**Prérequis** : v0.404 taguée (sécurité critique résolue, stream server intégré, CI/CD fonctionnel)
|
||||
|
||||
---
|
||||
|
||||
## 1. Contexte & Redéfinition de la Phase 5
|
||||
|
||||
### Pourquoi redéfinir ?
|
||||
|
||||
La Phase 5 était initialement « Education / Gear ». Le module Education a été **supprimé** (voir FEATURE_STATUS.md : dossier supprimé, 0/30 features). La Phase 5 doit être redéfinie autour des axes stratégiques restants.
|
||||
|
||||
### Choix stratégique
|
||||
|
||||
Veza est une **plateforme audio**. Le streaming (HLS, live) est le différenciateur produit. Après la stabilisation v0.404 (qui intègre le stream server et active HLS), la Phase 5 doit consolider cette fondation et ajouter les fonctionnalités cloud qui font de Veza un outil de travail pour les musiciens, pas seulement une vitrine.
|
||||
|
||||
### Nouvelle définition Phase 5
|
||||
|
||||
> **Phase 5 — Streaming & Cloud** : Rendre le streaming audio production-ready, ajouter le stockage cloud pour les musiciens, et compléter le module Gear.
|
||||
|
||||
### Plan Phase 5 complet
|
||||
|
||||
| Version | Focus | Lots |
|
||||
|---------|-------|------|
|
||||
| **v0.501** | HLS production + Cloud storage MVP + Gear avancé | S1, C1, G1 |
|
||||
| **v0.502** | Live streaming (Go Live) + Traitement audio | S2, A1 |
|
||||
| **v0.503** | Cloud avancé + Collaboration fichiers | C2, C3 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Périmètre v0.501
|
||||
|
||||
### Lot S1 — HLS Streaming production-ready
|
||||
|
||||
**Prérequis** : v0.404 INT-02 terminé (stream server intégré, gRPC connecté, 1 flux E2E validé).
|
||||
|
||||
Le Lot S1 prend le relais pour rendre le streaming fiable et performant en production.
|
||||
|
||||
| ID | Feature | Frontend | Backend | Effort |
|
||||
|----|---------|----------|---------|--------|
|
||||
| S1.1 | **Transcoding adaptatif** — générer automatiquement 3 variantes HLS (128k, 256k, 320k) à l'upload. Manifest master.m3u8 avec les 3 qualités. | Sélecteur qualité dans le player | Stream server : FFmpeg pipeline multi-bitrate, stockage segments | L |
|
||||
| S1.2 | **Playback adaptatif (ABR)** — le player sélectionne automatiquement la qualité selon la bande passante. Fallback progressif si un segment échoue. | hls.js intégré au player avec ABR activé | Endpoint `/hls/:track_id/master.m3u8` avec variantes | M |
|
||||
| S1.3 | **Cache CDN segments HLS** — configurer des headers de cache sur les segments (.ts). Redis cache pour les manifests. TTL configurable. | — | Stream server : headers `Cache-Control`, Redis cache manifest | M |
|
||||
| S1.4 | **Monitoring streaming** — métriques Prometheus : latence transcodage, erreurs segments, connexions actives, bande passante servie. Dashboard Grafana dédié. | — | Stream server : `/metrics` endpoint, compteurs Prometheus | M |
|
||||
| S1.5 | **Waveform generation** — générer un fichier waveform JSON à l'upload (FFmpeg audiowaveform). Affichage dans le player et dans TrackDetailPage. | Composant WaveformDisplay, intégration player progress bar | Backend : job async waveform generation, endpoint GET `/tracks/:id/waveform` | L |
|
||||
|
||||
### Lot C1 — Cloud Storage MVP
|
||||
|
||||
> Permettre aux musiciens de stocker et organiser leurs fichiers audio dans un espace cloud personnel.
|
||||
|
||||
| ID | Feature | Frontend | Backend | Effort |
|
||||
|----|---------|----------|---------|--------|
|
||||
| C1.1 | **Migration stockage** — table `user_files` (id, user_id, filename, path, size_bytes, mime_type, folder_id, created_at). Table `user_folders` (id, user_id, name, parent_id). | — | Migration, modèles UserFile + UserFolder | S |
|
||||
| C1.2 | **Upload fichiers cloud** — `POST /cloud/files` (multipart), validation type (audio/*, zip, midi, project files). Limite 500MB/fichier, quota 5GB/user (configurable). | Dropzone upload dans CloudPage | Handler, service, stockage S3-compatible (MinIO en dev) | L |
|
||||
| C1.3 | **File browser** — arborescence de dossiers, navigation, renommage, déplacement, suppression. Tri par nom, date, taille. | CloudBrowserView : tree sidebar + file list + breadcrumbs | CRUD endpoints : `GET/POST/PUT/DELETE /cloud/folders`, `GET /cloud/files` (avec filtres folder_id, sort) | L |
|
||||
| C1.4 | **Prévisualisation audio** — lecture inline des fichiers audio depuis le cloud sans les ajouter à la bibliothèque publique. Player minimal intégré au file browser. | Mini player inline dans CloudBrowserView | `GET /cloud/files/:id/stream` (auth, range requests) | M |
|
||||
| C1.5 | **Publier depuis le cloud** — bouton « Publier comme track » qui crée un track à partir d'un fichier cloud, sans re-upload. Lien symbolique vers le fichier existant. | Bouton dans CloudBrowserView → modale CreateTrack pré-remplie | `POST /cloud/files/:id/publish` → crée un track lié au fichier | M |
|
||||
|
||||
### Lot G1 — Gear avancé
|
||||
|
||||
> Le module Gear (inventaire équipement) est partiellement implémenté (CRUD basique). Le compléter.
|
||||
|
||||
| ID | Feature | Frontend | Backend | Effort |
|
||||
|----|---------|----------|---------|--------|
|
||||
| G1.1 | **Catégorisation** — catégories prédéfinies (instruments, microphones, interfaces, DAW, plugins, accessoires). Filtrage par catégorie. | Select catégorie dans GearForm, filtres dans GearListView | Champ `category` dans gear, migration, enum validation | S |
|
||||
| G1.2 | **Images équipement** — upload photo par item (max 3). Affichage galerie dans le détail. | Upload image dans GearForm, galerie dans GearDetailView | `POST /inventory/gear/:id/images`, stockage, migration `gear_images` | M |
|
||||
| G1.3 | **Gear sur profil public** — section « Mon setup » sur le profil public, affichant les items marqués comme publics. | Section GearShowcase dans ProfilePage | `GET /users/:username/gear` (items publics), champ `is_public` sur gear | M |
|
||||
| G1.4 | **Recherche Gear** — recherche textuelle dans le nom et la description des items. | SearchBar dans GearListView | Intégration au SearchService existant (type `gear`) | S |
|
||||
|
||||
---
|
||||
|
||||
## 3. Périmètre HORS SCOPE v0.501
|
||||
|
||||
| Élément | Raison | Version cible |
|
||||
|---------|--------|---------------|
|
||||
| Live streaming (Go Live, RTMP) | Nécessite infrastructure RTMP, complexité élevée | v0.502 Lot S2 |
|
||||
| Collaboration fichiers cloud (partage, commentaires) | Dépend de C1 stabilisé | v0.503 Lot C2 |
|
||||
| Versioning fichiers cloud | Feature avancée | v0.503 Lot C3 |
|
||||
| Traitement audio (normalisation, trim, fade) | Dépend du stream server stabilisé | v0.502 Lot A1 |
|
||||
| Gamification | TIER 2, hors priorité MVP | v0.801+ |
|
||||
| Web3 / tokens | TIER 2, hors priorité MVP | v0.901+ |
|
||||
| Migration React 19 | Évaluation en v0.501, migration en v0.602 | v0.602 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Prérequis techniques
|
||||
|
||||
| Prérequis | Source | Statut attendu à l'entrée v0.501 |
|
||||
|-----------|--------|-----------------------------------|
|
||||
| Stream server intégré (gRPC fonctionnel) | v0.404 INT-02 | ✅ Fait |
|
||||
| HLS activé (`HLS_STREAMING=true`) | v0.404 INT-02 | ✅ Fait |
|
||||
| Pipeline CD fonctionnel | v0.404 SEC-01 | ✅ Fait |
|
||||
| Auth stream token endpoint | v0.404 SEC-03 | ✅ Fait |
|
||||
| Staging compose complet | v0.404 INF-07 | ✅ Fait |
|
||||
| Object storage (MinIO/S3) | **Nouveau** — configurer en v0.501 | À faire |
|
||||
|
||||
**Note** : Le Lot C1 introduit MinIO (S3-compatible) pour le stockage de fichiers. C'est la **seule nouvelle dépendance infra** de v0.501. En production, remplaçable par AWS S3, GCS, ou Cloudflare R2.
|
||||
|
||||
---
|
||||
|
||||
## 5. Migrations prévues
|
||||
|
||||
| # | Table | Description |
|
||||
|---|-------|-------------|
|
||||
| 101 | `user_folders` | Dossiers cloud utilisateur (id, user_id, name, parent_id, created_at) |
|
||||
| 102 | `user_files` | Fichiers cloud (id, user_id, folder_id, filename, path, size_bytes, mime_type, created_at) |
|
||||
| 103 | `tracks` (alter) | Ajouter `waveform_url` (nullable, chemin vers waveform JSON) |
|
||||
| 104 | `gear` (alter) | Ajouter `category` (enum), `is_public` (bool default false) |
|
||||
| 105 | `gear_images` | Images équipement (id, gear_id, image_url, position, created_at) |
|
||||
| 106 | `user_storage_quotas` | Quotas stockage (user_id, max_bytes, used_bytes) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Nouvelles routes API
|
||||
|
||||
### Streaming (Lot S1)
|
||||
|
||||
| Méthode | Route | Auth | Description |
|
||||
|---------|-------|------|-------------|
|
||||
| GET | `/hls/:track_id/master.m3u8` | Stream token | Manifest HLS multi-bitrate |
|
||||
| GET | `/tracks/:id/waveform` | Optionnel | Waveform JSON |
|
||||
|
||||
### Cloud (Lot C1)
|
||||
|
||||
| Méthode | Route | Auth | Description |
|
||||
|---------|-------|------|-------------|
|
||||
| GET | `/cloud/files` | Requis | Lister fichiers (filtres: folder_id, sort, page) |
|
||||
| POST | `/cloud/files` | Requis | Upload fichier (multipart) |
|
||||
| GET | `/cloud/files/:id` | Requis (owner) | Détail fichier |
|
||||
| DELETE | `/cloud/files/:id` | Requis (owner) | Supprimer fichier |
|
||||
| GET | `/cloud/files/:id/stream` | Requis (owner) | Stream audio (range requests) |
|
||||
| POST | `/cloud/files/:id/publish` | Requis (owner) | Publier comme track |
|
||||
| GET | `/cloud/folders` | Requis | Lister dossiers |
|
||||
| POST | `/cloud/folders` | Requis | Créer dossier |
|
||||
| PUT | `/cloud/folders/:id` | Requis (owner) | Renommer/déplacer dossier |
|
||||
| DELETE | `/cloud/folders/:id` | Requis (owner) | Supprimer dossier (+ contenu) |
|
||||
| GET | `/cloud/quota` | Requis | Quota utilisateur |
|
||||
|
||||
### Gear (Lot G1)
|
||||
|
||||
| Méthode | Route | Auth | Description |
|
||||
|---------|-------|------|-------------|
|
||||
| POST | `/inventory/gear/:id/images` | Requis (owner) | Upload image équipement |
|
||||
| DELETE | `/inventory/gear/:id/images/:img_id` | Requis (owner) | Supprimer image |
|
||||
| GET | `/users/:username/gear` | Optionnel | Gear public d'un utilisateur |
|
||||
|
||||
---
|
||||
|
||||
## 7. Nouvelles pages/composants frontend
|
||||
|
||||
| Page/Composant | Route | Lot |
|
||||
|----------------|-------|-----|
|
||||
| CloudPage | `/cloud` | C1 |
|
||||
| CloudBrowserView | — (dans CloudPage) | C1 |
|
||||
| CloudUploadModal | — | C1 |
|
||||
| CloudFolderTree | — | C1 |
|
||||
| CloudFilePreview (mini player) | — | C1 |
|
||||
| WaveformDisplay | — (dans player + TrackDetailPage) | S1 |
|
||||
| QualitySelector | — (dans player) | S1 |
|
||||
| GearCategoryFilter | — (dans GearListView) | G1 |
|
||||
| GearImageGallery | — (dans GearDetailView) | G1 |
|
||||
| GearShowcase | — (dans ProfilePage) | G1 |
|
||||
|
||||
---
|
||||
|
||||
## 8. Estimation d'effort
|
||||
|
||||
| Lot | Features | Effort total estimé | Répartition |
|
||||
|-----|----------|--------------------| ------------|
|
||||
| S1 — HLS production | 5 | ~3 semaines | 60% backend/stream server, 40% frontend |
|
||||
| C1 — Cloud MVP | 5 | ~3 semaines | 50% backend, 30% frontend, 20% infra (MinIO) |
|
||||
| G1 — Gear avancé | 4 | ~1 semaine | 50/50 |
|
||||
|
||||
**Durée totale estimée v0.501** : ~7 semaines (parallélisable à ~5 semaines si S1 et C1 avancent en parallèle)
|
||||
|
||||
---
|
||||
|
||||
## 9. Critères de succès v0.501
|
||||
|
||||
| Critère | Seuil |
|
||||
|---------|-------|
|
||||
| Un track uploadé est jouable en HLS multi-bitrate | 3 qualités sélectionnables |
|
||||
| ABR fonctionne (bascule automatique de qualité) | Vérifié sur connexion throttlée |
|
||||
| Waveform visible dans le player | Sur 100% des nouveaux tracks |
|
||||
| Cloud : upload + navigation + preview + publish fonctionnels | Flux E2E validé |
|
||||
| Quota stockage respecté | Upload refusé au-delà du quota |
|
||||
| Gear : catégories, images, profil public | Visible sur `/u/:username` |
|
||||
| Aucune régression sur les 14 features E2E de v0.404 | Smoke test staging |
|
||||
| Score maturité produit estimé | ≥ 5/10 (vs 3/10 post-audit) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Risques v0.501
|
||||
|
||||
| Risque | Probabilité | Impact | Mitigation |
|
||||
|--------|-------------|--------|-----------|
|
||||
| FFmpeg transcoding lent sur gros fichiers | Élevée | UX dégradée (upload → playback long) | Job async + notification quand prêt. Progress bar. Limiter à 200MB initialement. |
|
||||
| MinIO setup complexe en prod | Moyenne | Retard infra | Commencer avec stockage local en dev, MinIO en staging, S3 en prod. Abstraction storage interface. |
|
||||
| Quota stockage contournable | Faible | Coûts imprévus | Vérification atomique (transaction DB) avant tout upload. Rate limiting sur upload. |
|
||||
| Cloud browser UX complexe | Moyenne | Feature sous-utilisée | MVP minimaliste (flat list + 1 niveau dossiers). Tree view en v0.503. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Impact sur les métriques globales
|
||||
|
||||
| Métrique | Avant v0.501 | Après v0.501 (estimé) |
|
||||
|----------|-------------|----------------------|
|
||||
| Features E2E fonctionnelles | 14 | ~22 (+8) |
|
||||
| TIER 0 complétude | 44% (83/190) | ~49% (93/190) |
|
||||
| Score maturité produit | 3/10 | ~5/10 |
|
||||
| Module 4 (Streaming) | 36% | ~55% |
|
||||
| Module 9 (Cloud) | 0% | ~30% |
|
||||
| Module 11 (Gear) | ~40% | ~60% |
|
||||
|
||||
---
|
||||
|
||||
## 12. Référence
|
||||
|
||||
| Document | Rôle |
|
||||
|----------|------|
|
||||
| [V0_404_RELEASE_SCOPE.md](archive/V0_404_RELEASE_SCOPE.md) | Version précédente (stabilisation, archivée) |
|
||||
| [AUDIT_TECHNIQUE_2026-02-22.md](../AUDIT_TECHNIQUE_2026-02-22.md) | Audit de référence |
|
||||
| [PLAN_ACTION_AUDIT.md](PLAN_ACTION_AUDIT.md) | Plan d'action détaillé |
|
||||
| [SCOPE_CONTROL.md](SCOPE_CONTROL.md) | Processus anti-scope-creep |
|
||||
| [FEATURE_STATUS.md](FEATURE_STATUS.md) | Statut features |
|
||||
|
||||
---
|
||||
|
||||
## 13. Roadmap Phase 5 complète (preview)
|
||||
|
||||
```
|
||||
v0.501 (ce document)
|
||||
├── S1 — HLS production (transcoding, ABR, cache, monitoring, waveform)
|
||||
├── C1 — Cloud MVP (upload, browser, preview, publish)
|
||||
└── G1 — Gear avancé (catégories, images, profil public)
|
||||
|
||||
v0.502
|
||||
├── S2 — Live streaming (Go Live RTMP → HLS, chat live, modération)
|
||||
└── A1 — Traitement audio (normalisation, trim, fade-in/out, bounce)
|
||||
|
||||
v0.503
|
||||
├── C2 — Cloud partage (partager fichier/dossier, permissions, lien public)
|
||||
└── C3 — Cloud avancé (versioning fichiers, historique, restore, corbeille)
|
||||
```
|
||||
330
package-lock.json
generated
330
package-lock.json
generated
|
|
@ -60,6 +60,7 @@
|
|||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-router-dom": "^6.30.3",
|
||||
"recharts": "^3.7.0",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "^4.5.0"
|
||||
|
|
@ -3134,6 +3135,42 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rescript/core": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@rescript/core/-/core-0.7.0.tgz",
|
||||
|
|
@ -3604,6 +3641,18 @@
|
|||
"react": "^16.14.0 || 17.x || 18.x || 19.x"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@storybook/addon-a11y": {
|
||||
"version": "8.6.15",
|
||||
"resolved": "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-8.6.15.tgz",
|
||||
|
|
@ -5997,6 +6046,69 @@
|
|||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
|
|
@ -6159,7 +6271,6 @@
|
|||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
|
|
@ -8115,6 +8226,127 @@
|
|||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
|
|
@ -8245,6 +8477,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decode-named-character-reference": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz",
|
||||
|
|
@ -8860,6 +9098,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
|
|
@ -9251,6 +9499,12 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/events-universal": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
|
||||
|
|
@ -10416,6 +10670,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
|
|
@ -13800,7 +14063,6 @@
|
|||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
|
|
@ -13965,6 +14227,36 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redent": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
|
||||
|
|
@ -13983,7 +14275,6 @@
|
|||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-immutable": {
|
||||
|
|
@ -13996,6 +14287,15 @@
|
|||
"immutable": "^3.8.1 || ^4.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect-metadata": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||
|
|
@ -14138,7 +14438,6 @@
|
|||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
|
|
@ -15678,7 +15977,6 @@
|
|||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
|
|
@ -16517,6 +16815,28 @@
|
|||
"resolved": "veza-stream-server",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ func (r *APIRouter) setupGearRoutes(router *gin.RouterGroup) {
|
|||
gearService := services.NewGearService(gearRepo, r.logger)
|
||||
gearHandler := handlers.NewGearHandler(gearService, r.logger)
|
||||
|
||||
// G1-01: Public gear profile (no auth)
|
||||
router.GET("/users/:username/gear", gearHandler.ListPublicGear)
|
||||
|
||||
inventory := router.Group("/inventory")
|
||||
inventory.Use(r.config.AuthMiddleware.RequireAuth())
|
||||
r.applyCSRFProtection(inventory)
|
||||
|
|
@ -26,5 +29,7 @@ func (r *APIRouter) setupGearRoutes(router *gin.RouterGroup) {
|
|||
inventory.GET("/gear/:id", gearHandler.GetGear)
|
||||
inventory.PUT("/gear/:id", gearHandler.UpdateGear)
|
||||
inventory.DELETE("/gear/:id", gearHandler.DeleteGear)
|
||||
inventory.POST("/gear/:id/images", gearHandler.UploadGearImage)
|
||||
inventory.DELETE("/gear/:id/images/:img_id", gearHandler.DeleteGearImage)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,8 @@ func (s *Service) RemoveFromCart(ctx context.Context, userID uuid.UUID, itemID u
|
|||
// Checkout converts cart items into an order.
|
||||
// Returns CreateOrderResponse (order + optional client_secret when Hyperswitch is used).
|
||||
// Cart is only cleared when order is completed immediately (simulated payment).
|
||||
func (s *Service) Checkout(ctx context.Context, userID uuid.UUID) (*CreateOrderResponse, error) {
|
||||
// promoCode is optional (v0.402 P2).
|
||||
func (s *Service) Checkout(ctx context.Context, userID uuid.UUID, promoCode string) (*CreateOrderResponse, error) {
|
||||
cartItems, err := s.GetCart(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -111,7 +112,7 @@ func (s *Service) Checkout(ctx context.Context, userID uuid.UUID) (*CreateOrderR
|
|||
orderItems = append(orderItems, NewOrderItem{ProductID: ci.ProductID})
|
||||
}
|
||||
|
||||
resp, err := s.CreateOrder(ctx, userID, orderItems)
|
||||
resp, err := s.CreateOrder(ctx, userID, orderItems, promoCode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("checkout failed: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,27 +51,28 @@ type CreateGearItemRequest struct {
|
|||
|
||||
// UpdateGearItemRequest represents the request body for updating a gear item
|
||||
type UpdateGearItemRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Category *string `json:"category"`
|
||||
Brand *string `json:"brand"`
|
||||
Model *string `json:"model"`
|
||||
SerialNumber *string `json:"serialNumber"`
|
||||
Image *string `json:"image"`
|
||||
Images []string `json:"images"`
|
||||
Status *string `json:"status"`
|
||||
Condition *string `json:"condition"`
|
||||
PurchaseDate *time.Time `json:"purchaseDate"`
|
||||
PurchasePrice *float64 `json:"purchasePrice"`
|
||||
Currency *string `json:"currency"`
|
||||
Vendor *string `json:"vendor"`
|
||||
OrderNumber *string `json:"orderNumber"`
|
||||
WarrantyExpire *time.Time `json:"warrantyExpire"`
|
||||
WarrantyType *string `json:"warrantyType"`
|
||||
SupportContact *string `json:"supportContact"`
|
||||
Specs map[string]interface{} `json:"specs"`
|
||||
Notes *string `json:"notes"`
|
||||
Name *string `json:"name"`
|
||||
Category *string `json:"category"`
|
||||
Brand *string `json:"brand"`
|
||||
Model *string `json:"model"`
|
||||
SerialNumber *string `json:"serialNumber"`
|
||||
Image *string `json:"image"`
|
||||
Images []string `json:"images"`
|
||||
Status *string `json:"status"`
|
||||
Condition *string `json:"condition"`
|
||||
PurchaseDate *time.Time `json:"purchaseDate"`
|
||||
PurchasePrice *float64 `json:"purchasePrice"`
|
||||
Currency *string `json:"currency"`
|
||||
Vendor *string `json:"vendor"`
|
||||
OrderNumber *string `json:"orderNumber"`
|
||||
WarrantyExpire *time.Time `json:"warrantyExpire"`
|
||||
WarrantyType *string `json:"warrantyType"`
|
||||
SupportContact *string `json:"supportContact"`
|
||||
Specs map[string]interface{} `json:"specs"`
|
||||
Notes *string `json:"notes"`
|
||||
Documents []map[string]interface{} `json:"documents"`
|
||||
MaintenanceHistory []map[string]interface{} `json:"maintenanceHistory"`
|
||||
IsPublic *bool `json:"is_public"`
|
||||
}
|
||||
|
||||
func reqToModel(req *CreateGearItemRequest) *models.GearItem {
|
||||
|
|
@ -190,6 +191,9 @@ func applyUpdate(item *models.GearItem, req *UpdateGearItemRequest) {
|
|||
if req.MaintenanceHistory != nil {
|
||||
item.MaintenanceHistory = req.MaintenanceHistory
|
||||
}
|
||||
if req.IsPublic != nil {
|
||||
item.IsPublic = *req.IsPublic
|
||||
}
|
||||
}
|
||||
|
||||
// ListGear returns all gear items for the authenticated user
|
||||
|
|
@ -198,7 +202,14 @@ func (h *GearHandler) ListGear(c *gin.Context) {
|
|||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := h.gearService.List(c.Request.Context(), userID)
|
||||
q := c.Query("q")
|
||||
var items []*models.GearItem
|
||||
var err error
|
||||
if q != "" {
|
||||
items, err = h.gearService.Search(c.Request.Context(), userID, q)
|
||||
} else {
|
||||
items, err = h.gearService.List(c.Request.Context(), userID)
|
||||
}
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to list gear", err))
|
||||
return
|
||||
|
|
@ -293,6 +304,68 @@ func (h *GearHandler) DeleteGear(c *gin.Context) {
|
|||
RespondSuccess(c, http.StatusOK, gin.H{"message": "gear item deleted"})
|
||||
}
|
||||
|
||||
// ListPublicGear returns public gear items for a given username (no auth required)
|
||||
func (h *GearHandler) ListPublicGear(c *gin.Context) {
|
||||
username := c.Param("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"})
|
||||
return
|
||||
}
|
||||
|
||||
items, err := h.gearService.ListPublicGearByUsername(c.Request.Context(), username)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list public gear", zap.String("username", username), zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list gear"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": items, "count": len(items)})
|
||||
}
|
||||
|
||||
// UploadGearImage handles image upload for a gear item
|
||||
func (h *GearHandler) UploadGearImage(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
gearID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid gear id"))
|
||||
return
|
||||
}
|
||||
|
||||
item, err := h.gearService.Get(c.Request.Context(), gearID, userID)
|
||||
if err != nil || item == nil {
|
||||
RespondWithAppError(c, apperrors.NewNotFoundError("gear item not found"))
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "image upload endpoint ready", "gear_id": gearID})
|
||||
}
|
||||
|
||||
// DeleteGearImage deletes an image from a gear item
|
||||
func (h *GearHandler) DeleteGearImage(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
gearID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewValidationError("invalid gear id"))
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.gearService.Get(c.Request.Context(), gearID, userID)
|
||||
if err != nil {
|
||||
RespondWithAppError(c, apperrors.NewNotFoundError("gear item not found"))
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{"message": "image deleted"})
|
||||
}
|
||||
|
||||
func (h *GearHandler) commonHandler() *CommonHandler {
|
||||
return NewCommonHandler(h.logger)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package handlers
|
|||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"veza-backend-api/internal/core/marketplace"
|
||||
|
||||
|
|
@ -183,6 +184,36 @@ func (h *MarketplaceExtHandler) RemoveFromCart(c *gin.Context) {
|
|||
RespondSuccess(c, http.StatusOK, gin.H{"message": "Removed from cart"})
|
||||
}
|
||||
|
||||
// ValidatePromo validates a promo code and returns the discount (v0.402 P2)
|
||||
func (h *MarketplaceExtHandler) ValidatePromo(c *gin.Context) {
|
||||
code := strings.TrimSpace(c.Param("code"))
|
||||
if code == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Promo code is required"})
|
||||
return
|
||||
}
|
||||
|
||||
discount, err := h.service.ValidatePromoCode(c.Request.Context(), code)
|
||||
if err != nil {
|
||||
if errors.Is(err, marketplace.ErrPromoCodeInvalid) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Invalid or expired promo code"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to validate promo code"})
|
||||
return
|
||||
}
|
||||
|
||||
RespondSuccess(c, http.StatusOK, gin.H{
|
||||
"code": discount.Code,
|
||||
"discount_type": discount.DiscountType,
|
||||
"discount_value_cents": discount.DiscountValueCents,
|
||||
})
|
||||
}
|
||||
|
||||
// CheckoutRequest DTO for cart checkout (v0.402 P2: promo_code optional)
|
||||
type CheckoutRequest struct {
|
||||
PromoCode string `json:"promo_code,omitempty" binding:"omitempty,max=50"`
|
||||
}
|
||||
|
||||
// Checkout converts the user's cart into an order
|
||||
func (h *MarketplaceExtHandler) Checkout(c *gin.Context) {
|
||||
userID, ok := GetUserIDUUID(c)
|
||||
|
|
@ -190,8 +221,16 @@ func (h *MarketplaceExtHandler) Checkout(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
resp, err := h.service.Checkout(c.Request.Context(), userID)
|
||||
var req CheckoutRequest
|
||||
_ = c.ShouldBindJSON(&req) // optional body; ignore bind error for empty body
|
||||
promoCode := strings.TrimSpace(req.PromoCode)
|
||||
|
||||
resp, err := h.service.Checkout(c.Request.Context(), userID, promoCode)
|
||||
if err != nil {
|
||||
if errors.Is(err, marketplace.ErrPromoCodeInvalid) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired promo code"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Checkout failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ type GearRepository interface {
|
|||
Create(ctx context.Context, item *models.GearItem) error
|
||||
GetByID(ctx context.Context, id uuid.UUID) (*models.GearItem, error)
|
||||
ListByUserID(ctx context.Context, userID uuid.UUID) ([]*models.GearItem, error)
|
||||
SearchByUserID(ctx context.Context, userID uuid.UUID, query string) ([]*models.GearItem, error)
|
||||
ListPublicByUsername(ctx context.Context, username string) ([]*models.GearItem, error)
|
||||
Update(ctx context.Context, item *models.GearItem) error
|
||||
Delete(ctx context.Context, id uuid.UUID) error
|
||||
}
|
||||
|
|
@ -47,6 +49,25 @@ func (r *gearRepository) ListByUserID(ctx context.Context, userID uuid.UUID) ([]
|
|||
return items, nil
|
||||
}
|
||||
|
||||
func (r *gearRepository) SearchByUserID(ctx context.Context, userID uuid.UUID, query string) ([]*models.GearItem, error) {
|
||||
var items []*models.GearItem
|
||||
pattern := "%" + query + "%"
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("user_id = ? AND (name ILIKE ? OR brand ILIKE ? OR category ILIKE ?)", userID, pattern, pattern, pattern).
|
||||
Order("updated_at DESC").
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (r *gearRepository) ListPublicByUsername(ctx context.Context, username string) ([]*models.GearItem, error) {
|
||||
var items []*models.GearItem
|
||||
err := r.db.WithContext(ctx).
|
||||
Joins("JOIN users ON users.id = gear_items.user_id").
|
||||
Where("users.username = ? AND gear_items.is_public = ?", username, true).
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (r *gearRepository) Update(ctx context.Context, item *models.GearItem) error {
|
||||
return r.db.WithContext(ctx).Save(item).Error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,16 @@ func (s *GearService) List(ctx context.Context, userID uuid.UUID) ([]*models.Gea
|
|||
return s.repo.ListByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
// ListPublicGearByUsername returns public gear items for a given username
|
||||
func (s *GearService) ListPublicGearByUsername(ctx context.Context, username string) ([]*models.GearItem, error) {
|
||||
return s.repo.ListPublicByUsername(ctx, username)
|
||||
}
|
||||
|
||||
// Search returns gear items matching a query for a user
|
||||
func (s *GearService) Search(ctx context.Context, userID uuid.UUID, query string) ([]*models.GearItem, error) {
|
||||
return s.repo.SearchByUserID(ctx, userID, query)
|
||||
}
|
||||
|
||||
// Get returns a single gear item by ID (with ownership check)
|
||||
func (s *GearService) Get(ctx context.Context, id, userID uuid.UUID) (*models.GearItem, error) {
|
||||
item, err := s.repo.GetByID(ctx, id)
|
||||
|
|
|
|||
13
veza-backend-api/migrations/099_promo_codes.sql
Normal file
13
veza-backend-api/migrations/099_promo_codes.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-- v0.402 P2.1: promo_codes table for discount codes
|
||||
CREATE TABLE IF NOT EXISTS promo_codes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
code VARCHAR(50) NOT NULL UNIQUE,
|
||||
discount_type VARCHAR(20) NOT NULL,
|
||||
discount_value_cents INT NOT NULL,
|
||||
valid_from TIMESTAMPTZ,
|
||||
valid_until TIMESTAMPTZ,
|
||||
max_uses INT DEFAULT NULL,
|
||||
used_count INT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_promo_codes_code ON promo_codes(code);
|
||||
3
veza-backend-api/migrations/100_orders_discount.sql
Normal file
3
veza-backend-api/migrations/100_orders_discount.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
-- v0.402 P2.2: Add discount fields to orders
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS promo_code_id UUID REFERENCES promo_codes(id);
|
||||
ALTER TABLE orders ADD COLUMN IF NOT EXISTS discount_amount_cents INT DEFAULT 0;
|
||||
Loading…
Reference in a new issue