veza/docs/PLAN_V0_703_IMPLEMENTATION.md
senke 963a327a0a docs: complete roadmap documentation v0.703 to v0.903 (v1.0 target)
Add Release Scope, Implementation Plan, and Smoke Test for 7 versions:
- v0.703: Go Live & Streaming Complet (Phase 7 Finale)
- v0.801: UX/UI Polish, Accessibilite & PWA (Phase 8)
- v0.802: Cloud Complet, Fichiers & Gear Avance (Phase 8)
- v0.803: Securite, Compliance & Outillage Dev (Phase 8)
- v0.901: Marketplace Complet & Analytics Avances (Phase 9)
- v0.902: Social Complet, Chat & Notifications (Phase 9)
- v0.903: Stabilisation v1.0 & Launch Readiness (Phase 9)

21 documents total (3 per version), covering all remaining features
needed to reach v1.0 from v0.702.
2026-02-24 01:32:04 +01:00

24 KiB

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


Step 1 : Migration + Model update (GL1-01, GL1-02)

Fichier : veza-backend-api/migrations/117_live_streams_go_live.sql (nouveau)

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 :

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 :

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 :

func (s *LiveStreamService) ListByUser(ctx context.Context, userID uuid.UUID) ([]*models.LiveStream, error) {
    return s.repo.ListByUserID(ctx, userID)
}

Ajouter RegenerateStreamKey :

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 :

// 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 :

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

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)

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 :

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 :

{ 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 :

onClick={() => {
  onNavigate('go-live');
  setShowUserMenu(false);
}}

Fichier : apps/web/src/services/liveService.ts — ajouter :

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)

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 :

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)

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)

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

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

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

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

cd veza-backend-api && go build ./... && go vet ./... && go test ./... -v
cd apps/web && npm run build
git tag v0.703