veza/docs/PLAN_V0_703_IMPLEMENTATION.md

715 lines
24 KiB
Markdown
Raw Normal View History

# Plan d'implémentation v0.703 — Go Live & Streaming Complet
## État des lieux
Le module Live Streaming est **partiellement implémenté** :
| Feature | Backend | Frontend | Route | MSW | Tests |
|---------|---------|----------|-------|-----|-------|
| List streams | ✅ GET /live/streams | ✅ LiveView | ✅ /live | ❌ | ❌ |
| Get stream | ✅ GET /live/streams/:id | ✅ LiveViewPlayer | ✅ | ❌ | ❌ |
| Create stream | ✅ POST /live/streams | ❌ | ❌ | ❌ | ❌ |
| Stream key | ❌ field exists, never generated | ❌ | ❌ | ❌ | ❌ |
| Update stream | ❌ | ❌ | ❌ | ❌ | ❌ |
| Go Live page | ❌ | ❌ | ❌ | ❌ | ❌ |
| Live chat | ❌ | ⚠️ mock data | ❌ | ❌ | ❌ |
| VOD replays | ❌ | ❌ | ❌ | ❌ | ❌ |
| Playback speed | ❌ | ❌ | — | — | ❌ |
| Media Session | ❌ | ❌ | — | — | ❌ |
**Ce plan ajoute le Go Live complet, le chat live connecté, et les améliorations player.**
---
## Fichiers existants clés
- Model : [`live_stream.go`](veza-backend-api/internal/models/live_stream.go) (LiveStream struct, StreamKey field L18)
- Service : [`live_stream_service.go`](veza-backend-api/internal/services/live_stream_service.go) (Create L35, SetIsLive L79, UpdateViewerCount L97)
- Handler : [`live_stream_handler.go`](veza-backend-api/internal/handlers/live_stream_handler.go) (ListLiveStreams L38, GetLiveStream L55, CreateLiveStream L70)
- Repository : [`live_stream_repository.go`](veza-backend-api/internal/repositories/live_stream_repository.go)
- Routes : [`routes_live.go`](veza-backend-api/internal/api/routes_live.go) (GET /streams L19, GET /streams/:id L20, POST /streams L26)
- Stream events : [`stream_events_handler.go`](veza-backend-api/internal/handlers/stream_events_handler.go)
- LiveView : [`LiveView.tsx`](apps/web/src/features/live/pages/live-page/LiveView.tsx) (limitation A6 L4)
- LiveViewChat : [`LiveViewChat.tsx`](apps/web/src/features/live/pages/live-page/LiveViewChat.tsx) (mock data)
- LiveViewPlayer : [`LiveViewPlayer.tsx`](apps/web/src/features/live/pages/live-page/LiveViewPlayer.tsx)
- Navbar : [`Navbar.tsx`](apps/web/src/components/layout/Navbar.tsx) (Go Live toast L223)
- Lazy exports : [`lazyExports.ts`](apps/web/src/components/ui/lazy-component/lazyExports.ts)
- Routes : [`routeConfig.tsx`](apps/web/src/router/routeConfig.tsx)
- Live service : [`liveService.ts`](apps/web/src/services/liveService.ts)
- Player hooks : [`apps/web/src/features/player/hooks/`](apps/web/src/features/player/hooks/)
- Player audio : [`apps/web/src/features/player/components/audio-player/`](apps/web/src/features/player/components/audio-player/)
---
## Step 1 : Migration + Model update (GL1-01, GL1-02)
**Fichier** : `veza-backend-api/migrations/117_live_streams_go_live.sql` (nouveau)
```sql
ALTER TABLE live_streams
ADD COLUMN IF NOT EXISTS scheduled_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS stream_url TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS is_vod BOOLEAN NOT NULL DEFAULT false;
UPDATE live_streams SET stream_key = gen_random_uuid()::text
WHERE stream_key = '' OR stream_key IS NULL;
ALTER TABLE live_streams ALTER COLUMN stream_key SET NOT NULL;
ALTER TABLE live_streams ALTER COLUMN stream_key SET DEFAULT gen_random_uuid()::text;
CREATE INDEX IF NOT EXISTS idx_live_streams_user_id ON live_streams(user_id);
CREATE INDEX IF NOT EXISTS idx_live_streams_is_live ON live_streams(is_live) WHERE is_live = true;
CREATE INDEX IF NOT EXISTS idx_live_streams_scheduled ON live_streams(scheduled_at)
WHERE scheduled_at IS NOT NULL;
```
**Fichier** : `veza-backend-api/internal/models/live_stream.go` — ajouter 3 champs après `Tags` :
```go
ScheduledAt *time.Time `json:"scheduled_at,omitempty" db:"scheduled_at"`
StreamURL string `gorm:"type:text;default:''" json:"stream_url,omitempty" db:"stream_url"`
IsVOD bool `gorm:"default:false" json:"is_vod" db:"is_vod"`
```
**Commit** : `feat(live): add migration 117 and model fields for Go Live`
---
## Step 2 : Service — Stream key generation, ListByUser (GL1-03, GL1-04)
**Fichier** : `veza-backend-api/internal/services/live_stream_service.go`
Modifier `Create` pour générer la stream key :
```go
func (s *LiveStreamService) Create(ctx context.Context, userID uuid.UUID, stream *models.LiveStream) (*models.LiveStream, error) {
if stream.Title == "" {
return nil, errors.New("title is required")
}
stream.UserID = userID
stream.StreamKey = uuid.New().String()
if stream.StreamerName == "" {
stream.StreamerName = "Streamer"
}
if err := s.repo.Create(ctx, stream); err != nil {
return nil, err
}
return stream, nil
}
```
Ajouter `ListByUser` :
```go
func (s *LiveStreamService) ListByUser(ctx context.Context, userID uuid.UUID) ([]*models.LiveStream, error) {
return s.repo.ListByUserID(ctx, userID)
}
```
Ajouter `RegenerateStreamKey` :
```go
func (s *LiveStreamService) RegenerateStreamKey(ctx context.Context, id, userID uuid.UUID) (string, error) {
existing, err := s.repo.GetByID(ctx, id)
if err != nil {
return "", err
}
if existing.UserID != userID {
return "", errors.New("stream not found")
}
existing.StreamKey = uuid.New().String()
if err := s.repo.Update(ctx, existing); err != nil {
return "", err
}
return existing.StreamKey, nil
}
```
**Commit** : `feat(live): stream key generation, ListByUser, RegenerateStreamKey`
---
## Step 3 : Handler — Endpoints me, me/key, regenerate, PUT (GL1-04 to GL1-07)
**Fichier** : `veza-backend-api/internal/handlers/live_stream_handler.go` — ajouter :
```go
// GetMyStreams returns the authenticated user's streams (including stream_key)
func (h *LiveStreamHandler) GetMyStreams(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok { return }
streams, err := h.service.ListByUser(c.Request.Context(), userID)
if err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to list streams", err))
return
}
type streamWithKey struct {
*models.LiveStream
StreamKey string `json:"stream_key"`
}
result := make([]streamWithKey, len(streams))
for i, s := range streams {
result[i] = streamWithKey{LiveStream: s, StreamKey: s.StreamKey}
}
RespondSuccess(c, http.StatusOK, gin.H{"streams": result})
}
// GetMyStreamKey returns the user's stream key (creates draft if none exist)
func (h *LiveStreamHandler) GetMyStreamKey(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok { return }
streams, err := h.service.ListByUser(c.Request.Context(), userID)
if err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to get streams", err))
return
}
var streamKey string
if len(streams) > 0 {
streamKey = streams[0].StreamKey
} else {
draft := &models.LiveStream{Title: "My Stream"}
created, createErr := h.service.Create(c.Request.Context(), userID, draft)
if createErr != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to create stream", createErr))
return
}
streamKey = created.StreamKey
}
RespondSuccess(c, http.StatusOK, gin.H{
"stream_key": streamKey,
"rtmp_url": "rtmp://stream.veza.app/live",
})
}
// RegenerateStreamKey generates a new stream key for the user's first stream
func (h *LiveStreamHandler) RegenerateStreamKey(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok { return }
streams, err := h.service.ListByUser(c.Request.Context(), userID)
if err != nil || len(streams) == 0 {
RespondWithAppError(c, apperrors.NewNotFoundError("no stream found"))
return
}
newKey, err := h.service.RegenerateStreamKey(c.Request.Context(), streams[0].ID, userID)
if err != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to regenerate key", err))
return
}
RespondSuccess(c, http.StatusOK, gin.H{
"stream_key": newKey,
"rtmp_url": "rtmp://stream.veza.app/live",
})
}
// UpdateLiveStream updates a live stream's metadata (ownership check)
func (h *LiveStreamHandler) UpdateLiveStream(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok { return }
id, err := uuid.Parse(c.Param("id"))
if err != nil {
RespondWithAppError(c, apperrors.NewValidationError("invalid stream ID"))
return
}
var req CreateLiveStreamRequest
if appErr := NewCommonHandler(h.logger).BindAndValidateJSON(c, &req); appErr != nil {
RespondWithAppError(c, appErr)
return
}
stream := &models.LiveStream{
Title: req.Title, Description: req.Description,
Category: req.Category, ThumbnailURL: req.ThumbnailURL,
StreamerName: req.StreamerName, Tags: req.Tags,
}
updated, updateErr := h.service.Update(c.Request.Context(), id, userID, stream)
if updateErr != nil {
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "Failed to update stream", updateErr))
return
}
RespondSuccess(c, http.StatusOK, gin.H{"stream": updated})
}
```
**Fichier** : `veza-backend-api/internal/api/routes_live.go` — ajouter dans le bloc `protected` :
```go
protected.GET("/streams/me", liveStreamHandler.GetMyStreams)
protected.GET("/streams/me/key", liveStreamHandler.GetMyStreamKey)
protected.POST("/streams/me/key/regenerate", liveStreamHandler.RegenerateStreamKey)
protected.PUT("/streams/:id", liveStreamHandler.UpdateLiveStream)
```
**Commit** : `feat(live): add handler endpoints for Go Live (me, key, regenerate, update)`
---
## Step 4 : Tests backend live stream (GL1-10)
**Fichier** : `veza-backend-api/internal/services/live_stream_service_test.go` (nouveau)
Cas de test (SQLite in-memory, AutoMigrate LiveStream) :
- `TestCreate_GeneratesStreamKey` — Create → stream.StreamKey non vide, format UUID
- `TestCreate_RequiresTitle` — Create avec title="" → erreur
- `TestListByUser_ReturnsOnlyUserStreams` — 2 users, 3 streams → ListByUser retourne seulement les streams de l'user
- `TestRegenerateStreamKey_Success` — RegenerateStreamKey → nouveau key != ancien key
- `TestRegenerateStreamKey_WrongUser` — RegenerateStreamKey avec mauvais userID → erreur
- `TestSetIsLive_SetsStartedAt` — SetIsLive(true) → StartedAt set, EndedAt nil
- `TestSetIsLive_SetsEndedAt` — SetIsLive(false) → EndedAt set
- `TestUpdateViewerCount_Clamps` — UpdateViewerCount(-100) → viewer_count = 0 (pas négatif)
- `TestUpdate_OwnershipCheck` — Update avec mauvais userID → erreur
```go
package services
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"veza-backend-api/internal/models"
"veza-backend-api/internal/repositories"
)
func setupLiveStreamTest(t *testing.T) (*LiveStreamService, *gorm.DB) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&models.LiveStream{}))
repo := repositories.NewLiveStreamRepository(db)
svc := NewLiveStreamService(repo)
return svc, db
}
func TestCreate_GeneratesStreamKey(t *testing.T) {
svc, _ := setupLiveStreamTest(t)
stream := &models.LiveStream{Title: "Test Stream"}
created, err := svc.Create(context.Background(), uuid.New(), stream)
require.NoError(t, err)
assert.NotEmpty(t, created.StreamKey)
_, parseErr := uuid.Parse(created.StreamKey)
assert.NoError(t, parseErr, "stream key should be valid UUID")
}
func TestCreate_RequiresTitle(t *testing.T) {
svc, _ := setupLiveStreamTest(t)
_, err := svc.Create(context.Background(), uuid.New(), &models.LiveStream{})
assert.Error(t, err)
}
```
**Commit** : `test(live): add live stream service unit tests`
---
## Step 5 : Frontend GoLivePage + GoLiveView (GL2-01 to GL2-04)
**Fichier** : `apps/web/src/features/live/pages/go-live-page/GoLiveView.tsx` (nouveau)
```typescript
import { useState } from 'react';
import { Copy, Eye, EyeOff, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import toast from '@/utils/toast';
interface GoLiveViewProps {
streamKey: string | null;
rtmpUrl: string;
onCreateStream: (data: { title: string; description: string; category: string; tags: string[] }) => Promise<void>;
onRegenerateKey: () => Promise<void>;
isLoading: boolean;
}
export function GoLiveView({ streamKey, rtmpUrl, onCreateStream, onRegenerateKey, isLoading }: GoLiveViewProps) {
const [showKey, setShowKey] = useState(false);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
// ... form and stream key display ...
}
export function GoLiveViewSkeleton() {
return <div className="animate-pulse space-y-4">/* skeleton */</div>;
}
```
**Fichier** : `apps/web/src/features/live/pages/GoLivePage.tsx` (nouveau)
```typescript
import { useState, useEffect } from 'react';
import { GoLiveView, GoLiveViewSkeleton } from './go-live-page/GoLiveView';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
import { liveService } from '@/services/liveService';
export function GoLivePage() {
const [streamKey, setStreamKey] = useState<string | null>(null);
const [rtmpUrl, setRtmpUrl] = useState('rtmp://stream.veza.app/live');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
liveService.getMyStreamKey()
.then((res) => { setStreamKey(res.stream_key); setRtmpUrl(res.rtmp_url); })
.catch((e) => setError(e instanceof Error ? e : new Error(String(e))))
.finally(() => setLoading(false));
}, []);
if (loading) return <GoLiveViewSkeleton />;
if (error) return <ErrorDisplay error={error} onRetry={() => window.location.reload()} />;
return (
<GoLiveView
streamKey={streamKey}
rtmpUrl={rtmpUrl}
onCreateStream={async (data) => { await liveService.createStream(data); }}
onRegenerateKey={async () => {
const res = await liveService.regenerateStreamKey();
setStreamKey(res.stream_key);
}}
isLoading={false}
/>
);
}
```
**Fichier** : `apps/web/src/components/ui/lazy-component/lazyExports.ts` — ajouter :
```typescript
export const LazyGoLive = createLazyComponent(
() => import('@/features/live/pages/GoLivePage').then((m) => ({ default: m.GoLivePage })),
undefined,
'Go Live',
);
```
**Fichier** : `apps/web/src/router/routeConfig.tsx` — ajouter après `/live` :
```typescript
{ path: '/live/go-live', element: wrapProtected(<LazyGoLive />) },
```
**Commit** : `feat(live): add GoLivePage, GoLiveView, lazy export, route`
---
## Step 6 : Navbar + liveService + MSW (GL2-05 to GL2-07)
**Fichier** : `apps/web/src/components/layout/Navbar.tsx` L217-224 — remplacer le onClick Go Live :
```typescript
onClick={() => {
onNavigate('go-live');
setShowUserMenu(false);
}}
```
**Fichier** : `apps/web/src/services/liveService.ts` — ajouter :
```typescript
async getMyStreams(): Promise<{ streams: LiveStream[] }> {
const response = await api.get('/live/streams/me');
return response.data.data;
},
async getMyStreamKey(): Promise<{ stream_key: string; rtmp_url: string }> {
const response = await api.get('/live/streams/me/key');
return response.data.data;
},
async regenerateStreamKey(): Promise<{ stream_key: string; rtmp_url: string }> {
const response = await api.post('/live/streams/me/key/regenerate');
return response.data.data;
},
async updateStream(id: string, data: Partial<LiveStream>): Promise<LiveStream> {
const response = await api.put(`/live/streams/${id}`, data);
return response.data.data.stream;
},
async createStream(data: { title: string; description: string; category: string; tags: string[] }): Promise<LiveStream> {
const response = await api.post('/live/streams', data);
return response.data.data.stream;
},
```
**Fichier** : `apps/web/src/mocks/handlers-live.ts` (nouveau)
```typescript
import { http, HttpResponse } from 'msw';
export const liveHandlers = [
http.get('*/api/v1/live/streams/me/key', () => {
return HttpResponse.json({
success: true,
data: {
stream_key: 'sk-abc123-def456-ghi789',
rtmp_url: 'rtmp://stream.veza.app/live',
},
});
}),
http.get('*/api/v1/live/streams/me', () => {
return HttpResponse.json({
success: true,
data: { streams: [/* mock stream with key */] },
});
}),
http.post('*/api/v1/live/streams/me/key/regenerate', () => {
return HttpResponse.json({
success: true,
data: {
stream_key: 'sk-new-' + Date.now(),
rtmp_url: 'rtmp://stream.veza.app/live',
},
});
}),
http.put('*/api/v1/live/streams/:id', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ success: true, data: { stream: body } });
}),
];
```
Enregistrer dans `apps/web/src/mocks/handlers.ts` : `import { liveHandlers } from './handlers-live';` et ajouter `...liveHandlers` dans le tableau.
**Commit** : `feat(live): wire Navbar Go Live, liveService methods, MSW handlers`
---
## Step 7 : Live Chat WebSocket (GL3-01 to GL3-03)
**Fichier** : `apps/web/src/features/live/pages/live-page/LiveViewChat.tsx` — remplacer les données mock par le hook `useChat` existant :
- Utiliser `JoinConversation` avec le stream ID comme room identifier
- Écouter les messages entrants via le WebSocket hub existant
- Afficher les messages en temps réel
- Input de message connecté à `SendMessage`
**Fichier** : `apps/web/src/features/live/pages/live-page/LiveViewPlayer.tsx` — écouter les events viewer count via WebSocket ou polling GET /live/streams/:id
**Commit** : `feat(live): connect LiveViewChat to WebSocket, real-time viewer count`
---
## Step 8 : Player enhancements (GL4-01 to GL4-03)
**Fichier** : `apps/web/src/features/player/components/audio-player/` — ajouter un composant `PlaybackSpeedButton` :
```typescript
const SPEED_OPTIONS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const;
export function PlaybackSpeedButton({ speed, onSpeedChange }: {
speed: number;
onSpeedChange: (speed: number) => void;
}) {
const cycleSpeed = () => {
const idx = SPEED_OPTIONS.indexOf(speed as typeof SPEED_OPTIONS[number]);
const next = SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length];
onSpeedChange(next);
};
return (
<button onClick={cycleSpeed} className="text-xs font-mono px-1.5 py-0.5 rounded hover:bg-muted">
{speed}x
</button>
);
}
```
**Fichier** : `apps/web/src/features/player/hooks/useMediaSession.ts` (nouveau)
```typescript
import { useEffect } from 'react';
interface MediaSessionActions {
onPlay: () => void;
onPause: () => void;
onPrevious: () => void;
onNext: () => void;
onSeekBackward: () => void;
onSeekForward: () => void;
}
export function useMediaSession(
track: { title: string; artist?: string; coverUrl?: string } | null,
actions: MediaSessionActions,
) {
useEffect(() => {
if (!('mediaSession' in navigator) || !track) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist ?? 'Unknown',
artwork: track.coverUrl ? [{ src: track.coverUrl, sizes: '512x512', type: 'image/png' }] : [],
});
navigator.mediaSession.setActionHandler('play', actions.onPlay);
navigator.mediaSession.setActionHandler('pause', actions.onPause);
navigator.mediaSession.setActionHandler('previoustrack', actions.onPrevious);
navigator.mediaSession.setActionHandler('nexttrack', actions.onNext);
navigator.mediaSession.setActionHandler('seekbackward', actions.onSeekBackward);
navigator.mediaSession.setActionHandler('seekforward', actions.onSeekForward);
return () => {
navigator.mediaSession.metadata = null;
};
}, [track?.title, track?.artist, track?.coverUrl]);
}
```
**Commit** : `feat(player): add playback speed control and Media Session API`
---
## Step 9 : Stories GoLiveView (GL2-08)
**Fichier** : `apps/web/src/features/live/pages/go-live-page/GoLiveView.stories.tsx` (nouveau)
```typescript
import type { Meta, StoryObj } from '@storybook/react';
import { GoLiveView, GoLiveViewSkeleton } from './GoLiveView';
import { ErrorDisplay } from '@/components/ui/ErrorDisplay';
const meta: Meta<typeof GoLiveView> = {
title: 'Features/Live/GoLiveView',
component: GoLiveView,
};
export default meta;
type Story = StoryObj<typeof GoLiveView>;
export const Default: Story = {
args: {
streamKey: 'sk-abc123-def456-ghi789',
rtmpUrl: 'rtmp://stream.veza.app/live',
onCreateStream: async () => {},
onRegenerateKey: async () => {},
isLoading: false,
},
};
export const Loading: Story = {
render: () => <GoLiveViewSkeleton />,
};
export const Error: Story = {
name: 'Erreur',
render: () => (
<ErrorDisplay
error={new Error('Failed to load stream key')}
onRetry={() => window.location.reload()}
/>
),
};
export const StreamKeyVisible: Story = {
args: {
...Default.args,
streamKey: 'sk-visible-key-for-testing',
},
};
```
**Commit** : `feat(storybook): add GoLiveView stories (Default, Loading, Error, StreamKeyVisible)`
---
## Step 10 : API Reference + Documentation (QA1)
**Fichier** : `docs/API_REFERENCE.md` — ajouter section "Live Streaming" :
```markdown
## Live Streaming
### GET /live/streams
List live streams (public). Optional `?is_live=true` filter.
### GET /live/streams/:id
Get stream details.
### POST /live/streams
Create a new stream (auth required). Body: `{ "title": "...", "description": "..." }`.
### GET /live/streams/me
List authenticated user's streams (includes stream_key). Auth required.
### GET /live/streams/me/key
Get user's stream key + RTMP URL. Creates draft stream if none exist. Auth required.
### POST /live/streams/me/key/regenerate
Regenerate stream key. Auth required.
### PUT /live/streams/:id
Update stream metadata (ownership check). Auth required.
```
**Fichier** : `CHANGELOG.md` — ajouter section v0.703
**Fichier** : `docs/PROJECT_STATE.md` — mettre à jour Dernier tag → v0.703, Prochaine version → v0.801, ajouter section v0.703
**Fichier** : `docs/FEATURE_STATUS.md` — mettre à jour limitation Go Live (supprimée), ajouter section "Livré en v0.703"
**Commit** : `docs: update API_REFERENCE, CHANGELOG, PROJECT_STATE, FEATURE_STATUS for v0.703`
---
## Step 11 : Rétrospective + Scope Control + placeholder v0.801
**Fichier** : `docs/RETROSPECTIVE_V0703.md` (nouveau)
**Fichier** : `docs/V0_801_RELEASE_SCOPE.md` (nouveau, placeholder)
**Fichier** : `docs/SCOPE_CONTROL.md` — Référence active → V0_801_RELEASE_SCOPE.md, historique v0.703 taguée
**Fichier** : `.cursorrules` — Scope v0.801
**Commit** : `docs: add RETROSPECTIVE_V0703, placeholder V0_801, update SCOPE_CONTROL`
---
## Step 12 : Archive + tag
```bash
mv docs/V0_703_RELEASE_SCOPE.md docs/archive/V0_703_RELEASE_SCOPE.md
git add .
git commit -m "chore(docs): archive V0_703_RELEASE_SCOPE"
git tag v0.703
```
---
## Dépendances entre steps
```mermaid
graph TD
S1[Step1 Migration+Model] --> S2[Step2 Service]
S2 --> S3[Step3 Handler+Routes]
S3 --> S4[Step4 Tests backend]
S5[Step5 GoLivePage] --> S6[Step6 Navbar+MSW]
S6 --> S7[Step7 LiveChat WS]
S8[Step8 Player] --> S9[Step9 Stories]
S4 --> S10[Step10 Docs]
S7 --> S10
S9 --> S10
S10 --> S11[Step11 Retro]
S11 --> S12[Step12 Tag]
```
---
## Validation finale
```bash
cd veza-backend-api && go build ./... && go vet ./... && go test ./... -v
cd apps/web && npm run build
git tag v0.703
```