chore(audit 2.4, 2.5): supprimer code mort Education et cmd/modern-server
- Supprimer routes/handlers/core Education (backend) - Supprimer handler MSW education, refs Sidebar/locales - Basculer Makefile, make/dev.mk, scripts vers cmd/api/main.go - Supprimer veza-backend-api/cmd/modern-server/
This commit is contained in:
parent
43af35fd93
commit
22e5e21757
22 changed files with 291 additions and 2066 deletions
|
|
@ -665,8 +665,8 @@ veza/
|
|||
| 2.1 | ~~Unifier `components/views/` et `features/*/pages/`~~ | **L** | **✅ Fait** — pattern unique `features/*/pages/` |
|
||||
| 2.2 | ~~Nettoyer les 137 fichiers .md à la racine~~ | **M** | **✅ Fait** — archivés dans docs/archive/root-md/ |
|
||||
| 2.3 | ~~Nettoyer les 25 fichiers .json à la racine~~ | **S** | **✅ Fait** — archivés dans docs/archive/root-json/ |
|
||||
| 2.4 | Supprimer le code mort (Education, Studio, Gamification) | **M** | Backend routes + Frontend components |
|
||||
| 2.5 | Supprimer `cmd/modern-server/` | **S** | Serveur alternatif inutile |
|
||||
| 2.4 | ~~Supprimer le code mort (Education, Studio, Gamification)~~ | **M** | **✅ Fait** — routes backend, MSW, refs supprimés |
|
||||
| 2.5 | ~~Supprimer `cmd/modern-server/`~~ | **S** | **✅ Fait** — bascule vers cmd/api/main.go |
|
||||
| 2.6 | ~~Supprimer `pages/` directory legacy~~ | **M** | **✅ Fait** — migré vers `features/*/pages/` |
|
||||
| 2.7 | Découper `config.go` (1 461 LOC) | **M** | Séparer par domaine (DB, JWT, CORS, etc.) |
|
||||
| 2.8 | Gitignorer les fichiers `.out`, test results, `.turbo/` | **S** | Mettre à jour `.gitignore` |
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useLocation, Link } from 'react-router-dom';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Home, Users, Disc, Radio, Settings, LogOut, ShoppingBag,
|
||||
GraduationCap, BarChart2, Shield, Box, MessageSquare,
|
||||
BarChart2, Shield, Box, MessageSquare,
|
||||
Layers, Cpu, Heart, ListMusic, CreditCard, DollarSign, Terminal,
|
||||
ChevronLeft, ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
|
@ -38,7 +38,6 @@ const iconMap: Record<string, React.ReactNode> = {
|
|||
marketplace: <ShoppingBag className="w-4 h-4" />,
|
||||
live: <Radio className="w-4 h-4" />,
|
||||
chat: <MessageSquare className="w-4 h-4" />,
|
||||
education: <GraduationCap className="w-4 h-4" />,
|
||||
sell: <DollarSign className="w-4 h-4" />,
|
||||
wishlist: <Heart className="w-4 h-4" />,
|
||||
purchases: <CreditCard className="w-4 h-4" />,
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@
|
|||
"never": "Never",
|
||||
"changeTheme": "Change theme",
|
||||
"userMenu": "User menu",
|
||||
"notifications": "Notifications"
|
||||
"notifications": "Notifications",
|
||||
"retry": "Retry",
|
||||
"retrying": "Retrying...",
|
||||
"dismiss": "Dismiss"
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
|
|
@ -199,7 +202,9 @@
|
|||
"empty": {
|
||||
"title": "Your library is empty",
|
||||
"subtitle": "Start by uploading your first files",
|
||||
"uploadButton": "Upload file"
|
||||
"description": "Upload your first track or create a playlist to get started.",
|
||||
"uploadButton": "Upload file",
|
||||
"uploadTrack": "Upload Track"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
|
|
@ -510,7 +515,6 @@
|
|||
"marketplace": "Marketplace",
|
||||
"live": "Live Sessions",
|
||||
"chat": "Channels",
|
||||
"education": "Academy",
|
||||
"sell": "Seller Dashboard",
|
||||
"wishlist": "Wishlist",
|
||||
"purchases": "Purchases",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@
|
|||
"never": "Jamais",
|
||||
"changeTheme": "Changer le thème",
|
||||
"userMenu": "Menu utilisateur",
|
||||
"notifications": "Notifications"
|
||||
"notifications": "Notifications",
|
||||
"retry": "Réessayer",
|
||||
"retrying": "Nouvelle tentative...",
|
||||
"dismiss": "Fermer"
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
|
|
@ -199,7 +202,9 @@
|
|||
"empty": {
|
||||
"title": "Votre bibliothèque est vide",
|
||||
"subtitle": "Commencez par téléverser vos premiers fichiers",
|
||||
"uploadButton": "Téléverser un fichier"
|
||||
"description": "Téléversez votre premier titre ou créez une playlist pour commencer.",
|
||||
"uploadButton": "Téléverser un fichier",
|
||||
"uploadTrack": "Téléverser un titre"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
|
|
@ -510,7 +515,6 @@
|
|||
"marketplace": "Marketplace",
|
||||
"live": "Sessions Live",
|
||||
"chat": "Canaux",
|
||||
"education": "Académie",
|
||||
"sell": "Tableau vendeur",
|
||||
"wishlist": "Liste de souhaits",
|
||||
"purchases": "Achats",
|
||||
|
|
|
|||
253
apps/web/src/mocks/handlers-misc.ts
Normal file
253
apps/web/src/mocks/handlers-misc.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/**
|
||||
* MSW handlers for search, notifications, users, chat, streaming, products, inventory, live
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
export const handlersMisc = [
|
||||
http.get('*/api/v1/search', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
tracks: [
|
||||
{ id: 'track-1', title: 'Neon Signal', artist: 'Void Producer', cover_art_path: 'https://picsum.photos/200', created_at: '2024-01-15T12:00:00Z' },
|
||||
{ id: 'track-2', title: 'Deep Frequency', artist: 'Echo Artist', created_at: '2024-01-14T10:00:00Z' },
|
||||
],
|
||||
artists: [{ id: 'artist-1', username: 'ProducerOne', avatar_url: 'https://i.pravatar.cc/150?u=producer1', followers_count: 120 }],
|
||||
playlists: [{ id: 'playlist-1', title: 'Curated Mix', description: 'Hand-picked tracks', cover_url: 'https://picsum.photos/300' }],
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/streaming/bitrate-options', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: [
|
||||
{ id: 'auto', label: 'Auto', bitrate: 0 },
|
||||
{ id: 'high', label: 'High (320kbps)', bitrate: 320000 },
|
||||
{ id: 'medium', label: 'Medium (128kbps)', bitrate: 128000 },
|
||||
{ id: 'low', label: 'Low (64kbps)', bitrate: 64000 },
|
||||
],
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/streaming/stats', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: { bufferHealth: 0.8, bitrate: 128000, droppedFrames: 0, latency: 25 },
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/products*', () => {
|
||||
return HttpResponse.json({
|
||||
data: [{ id: 'prod-1', title: 'Mock Product', price: 29.99, currency: 'USD', author: 'Mock Author', coverUrl: 'https://picsum.photos/300', rating: 4.5, reviewCount: 10, isHot: true }],
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/notifications', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
notifications: [
|
||||
{ id: 'notif-1', user_id: 'user-1', type: 'new_message', title: 'New message', content: 'Someone sent you a message', read: false, created_at: '2024-01-04T00:00:00Z', link: '/chat/1' },
|
||||
{ id: 'notif-2', user_id: 'user-1', type: 'track_uploaded', title: 'New track', content: 'A creator you follow uploaded a track', read: true, created_at: '2024-01-03T12:00:00Z' },
|
||||
],
|
||||
total: 2,
|
||||
unread_count: 1,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/notifications/unread-count', () => {
|
||||
return HttpResponse.json({ success: true, data: { count: 3 } });
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/notifications/:id/read', () => {
|
||||
return HttpResponse.json({ success: true, data: { read: true } });
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/notifications/read-all', () => {
|
||||
return HttpResponse.json({ success: true, data: { read: true } });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/users/search', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: { items: [{ id: 'user-1', username: 'StorybookUser', avatar_url: 'https://i.pravatar.cc/150?u=1' }], total: 1 },
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/users/settings', () => {
|
||||
return HttpResponse.json({
|
||||
notifications: { email_notifications: true, push_notifications: true, marketing_emails: false, new_follower: true, new_comment: true, new_like: true, playlist_update: true },
|
||||
privacy: { allow_search_indexing: true, show_activity: true },
|
||||
content: { explicit_content: false, autoplay: true },
|
||||
preferences: { language: 'en', timezone: 'UTC', theme: 'dark' },
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/users/:userId/settings', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
notifications: { email_notifications: true, push_notifications: true, marketing_emails: false, new_follower: true, new_comment: true, new_like: true, playlist_update: true },
|
||||
privacy: { allow_search_indexing: true, show_activity: true },
|
||||
content: { explicit_content: false, autoplay: true },
|
||||
preferences: { language: 'en', timezone: 'UTC', theme: 'dark' },
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.put('*/api/v1/users/settings', () => {
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
|
||||
http.put('*/api/v1/users/:userId/settings', () => {
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/users/by-username/:username', ({ params }) => {
|
||||
if (params.username === 'notfound') {
|
||||
return HttpResponse.json({ message: 'User not found' }, { status: 404 });
|
||||
}
|
||||
return HttpResponse.json({
|
||||
profile: {
|
||||
id: '123',
|
||||
username: params.username,
|
||||
first_name: 'Story',
|
||||
last_name: 'User',
|
||||
avatar_url: `https://i.pravatar.cc/150?u=${params.username}`,
|
||||
bio: 'Music enthusiast',
|
||||
location: 'Paris, France',
|
||||
birthdate: null,
|
||||
gender: null,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
followers_count: 42,
|
||||
following_count: 10,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/users/:id', ({ params }) => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
id: params.id,
|
||||
username: 'StorybookUser',
|
||||
email: 'user@example.com',
|
||||
avatar_url: `https://i.pravatar.cc/150?u=${params.id}`,
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
role: 'user',
|
||||
bio: 'Music enthusiast',
|
||||
location: 'Paris, France',
|
||||
website: 'https://example.com',
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.put('*/api/v1/users/:id', ({ params }) => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: { id: params.id, username: 'UpdatedUser', email: 'user@example.com' },
|
||||
});
|
||||
}),
|
||||
|
||||
http.delete('*/api/v1/users/:id', () => {
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/users/:id/completion', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: { percentage: 80, missing: ['bio', 'website'] },
|
||||
});
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/users/:id/follow', () => HttpResponse.json({ success: true })),
|
||||
http.delete('*/api/v1/users/:id/follow', () => HttpResponse.json({ success: true })),
|
||||
http.post('*/api/v1/users/:id/block', () => HttpResponse.json({ success: true })),
|
||||
http.delete('*/api/v1/users/:id/block', () => HttpResponse.json({ success: true })),
|
||||
http.post('*/api/v1/users/:id/avatar', () => HttpResponse.json({ avatar_url: 'https://i.pravatar.cc/150?u=new' })),
|
||||
http.delete('*/api/v1/users/:id/avatar', () => HttpResponse.json({ success: true })),
|
||||
http.get('*/api/v1/users/:id/likes', () => {
|
||||
return HttpResponse.json({ success: true, data: { tracks: [], total: 0 } });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/users/:userId/upload-quota', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
quota: {
|
||||
tracks_count: 5,
|
||||
tracks_limit: 10,
|
||||
storage_used: 52428800,
|
||||
storage_limit: 104857600,
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.post('*/api/v1/chat/token', () => {
|
||||
return HttpResponse.json({ success: true, data: { token: 'mock-chat-token' } });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/chat/stats', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: { online_users: 42, active_rooms: 5 },
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/conversations', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: [{ id: 'conv-1', name: 'General Chat', last_message: 'Hello world', updated_at: new Date().toISOString() }],
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/conversations/:id', ({ params }) => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: { id: params.id, messages: [] },
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/inventory/gear', () => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
items: [
|
||||
{ id: '1', name: 'Prophet-6', category: 'Synth', brand: 'Sequential', model: 'Prophet-6 Desktop', serialNumber: 'SQ-P6-99281', purchaseDate: '2023-01-15', purchasePrice: 2499, currency: 'USD', status: 'Active', condition: 'Mint', vendor: 'Sweetwater', image: 'https://picsum.photos/id/100/400/400' },
|
||||
{ id: '2', name: 'Apollo Twin X', category: 'Interface', brand: 'Universal Audio', model: 'Twin X Duo', serialNumber: 'UA-TWX-2210', purchaseDate: '2022-11-20', purchasePrice: 999, currency: 'USD', status: 'Active', condition: 'Good', vendor: 'Thomann', image: 'https://picsum.photos/id/101/400/400' },
|
||||
{ id: '3', name: 'SM7B', category: 'Microphone', brand: 'Shure', model: 'SM7B Dynamic', serialNumber: 'SH-SM7-004', purchaseDate: '2021-05-10', purchasePrice: 399, currency: 'USD', status: 'Maintenance', condition: 'Fair', vendor: 'Guitar Center', image: 'https://picsum.photos/id/102/400/400' },
|
||||
],
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/live/streams', ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const isLive = url.searchParams.get('is_live');
|
||||
const streams = [{ id: '1', title: 'Late Night DnB Production 🎧', streamer: 'Neuro_Glitch', viewers: 1240, thumbnailUrl: 'https://picsum.photos/id/140/800/450', tags: ['Production', 'Ableton', 'DnB'], isLive: true, category: 'Production' }];
|
||||
const filtered = isLive === 'true' ? streams.filter((s) => s.isLive) : isLive === 'false' ? streams.filter((s) => !s.isLive) : streams;
|
||||
return HttpResponse.json({ success: true, data: { streams: filtered } });
|
||||
}),
|
||||
|
||||
http.get('*/api/v1/live/streams/:id', ({ params }) => {
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
stream: {
|
||||
id: params.id,
|
||||
title: 'Late Night DnB Production 🎧',
|
||||
streamer: 'Neuro_Glitch',
|
||||
viewers: 1240,
|
||||
thumbnailUrl: 'https://picsum.photos/id/140/800/450',
|
||||
tags: ['Production', 'Ableton', 'DnB'],
|
||||
isLive: true,
|
||||
category: 'Production',
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
|
@ -15,7 +15,7 @@ services:
|
|||
language: "go"
|
||||
version: "1.23"
|
||||
health_endpoint: "/health"
|
||||
start_command: "go run cmd/modern-server/main.go"
|
||||
start_command: "go run cmd/api/main.go"
|
||||
working_directory: "veza-backend-api"
|
||||
dependencies:
|
||||
- "postgresql"
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ echo "✅ Redis démarré"
|
|||
|
||||
# Démarrer Backend API
|
||||
cd veza-backend-api
|
||||
go run cmd/modern-server/main.go &
|
||||
go run cmd/api/main.go &
|
||||
BACKEND_PID=$!
|
||||
echo "✅ Backend API démarré (PID: $BACKEND_PID)"
|
||||
|
||||
|
|
|
|||
12
make/dev.mk
12
make/dev.mk
|
|
@ -25,7 +25,7 @@ dev-full: check-ports infra-up ## [HIGH] Start Everything inc. Chat + Stream (Ru
|
|||
if command -v air >/dev/null; then \
|
||||
$(ECHO_CMD) "${GREEN}[Go] Hot Reload Active (Air)${NC}" && cd $(ROOT)/$(SERVICE_DIR_backend-api) && air & \
|
||||
else \
|
||||
$(ECHO_CMD) "${YELLOW}[Go] Standard Run${NC}" && cd $(ROOT)/$(SERVICE_DIR_backend-api) && go run cmd/modern-server/main.go & \
|
||||
$(ECHO_CMD) "${YELLOW}[Go] Standard Run${NC}" && cd $(ROOT)/$(SERVICE_DIR_backend-api) && go run cmd/api/main.go & \
|
||||
fi; \
|
||||
if command -v cargo-watch >/dev/null; then \
|
||||
$(ECHO_CMD) "${GREEN}[Chat] Hot Reload Active${NC}" && cd $(ROOT)/$(SERVICE_DIR_chat-server) && cargo watch -x run -q & \
|
||||
|
|
@ -40,7 +40,7 @@ dev-full: check-ports infra-up ## [HIGH] Start Everything inc. Chat + Stream (Ru
|
|||
dev-backend: check-ports infra-up ## [MID] Start Backends Only (Hot Reload supported)
|
||||
@$(ECHO_CMD) "${BOLD}${PURPLE}🚀 STARTING BACKEND ONLY${NC}"
|
||||
@(trap 'kill 0' SIGINT; \
|
||||
if command -v air >/dev/null; then cd $(ROOT)/$(SERVICE_DIR_backend-api) && air & else cd $(ROOT)/$(SERVICE_DIR_backend-api) && go run cmd/modern-server/main.go & fi; \
|
||||
if command -v air >/dev/null; then cd $(ROOT)/$(SERVICE_DIR_backend-api) && air & else cd $(ROOT)/$(SERVICE_DIR_backend-api) && go run cmd/api/main.go & fi; \
|
||||
if command -v cargo-watch >/dev/null; then cd $(ROOT)/$(SERVICE_DIR_chat-server) && cargo watch -x run -q & else cd $(ROOT)/$(SERVICE_DIR_chat-server) && cargo run -q & fi; \
|
||||
if command -v cargo-watch >/dev/null; then cd $(ROOT)/$(SERVICE_DIR_stream-server) && cargo watch -x run -q & else cd $(ROOT)/$(SERVICE_DIR_stream-server) && cargo run -q & fi; \
|
||||
wait)
|
||||
|
|
@ -51,7 +51,7 @@ dev-web: check-ports infra-up ## [MID] Start Web app only (assumes backend elsew
|
|||
|
||||
dev-backend-api: check-ports infra-up ## [MID] Start Go backend only
|
||||
@$(ECHO_CMD) "${GREEN}[Backend API] Starting...${NC}"
|
||||
@if command -v air >/dev/null; then cd $(ROOT)/$(SERVICE_DIR_backend-api) && air; else cd $(ROOT)/$(SERVICE_DIR_backend-api) && go run cmd/modern-server/main.go; fi
|
||||
@if command -v air >/dev/null; then cd $(ROOT)/$(SERVICE_DIR_backend-api) && air; else cd $(ROOT)/$(SERVICE_DIR_backend-api) && go run cmd/api/main.go; fi
|
||||
|
||||
dev-chat-server: check-ports infra-up ## [MID] Start Chat server only
|
||||
@$(ECHO_CMD) "${GREEN}[Chat] Starting...${NC}"
|
||||
|
|
@ -62,14 +62,14 @@ dev-stream-server: check-ports infra-up ## [MID] Start Stream server only
|
|||
@if command -v cargo-watch >/dev/null; then cd $(ROOT)/$(SERVICE_DIR_stream-server) && cargo watch -x run -q; else cd $(ROOT)/$(SERVICE_DIR_stream-server) && cargo run -q; fi
|
||||
|
||||
stop-local-services: ## [LOW] Stop all local processes (air, cargo watch, vite)
|
||||
@pkill -f "air\|cargo watch\|npm run dev\|go run.*modern-server" 2>/dev/null || true
|
||||
@pkill -f "air\|cargo watch\|npm run dev\|go run.*cmd/api" 2>/dev/null || true
|
||||
|
||||
start-local-service: ## [LOW] Start a service locally (usage: make start-local-service SERVICE=backend-api)
|
||||
@if [ -z "$(SERVICE)" ]; then $(ECHO_CMD) "${RED}❌ Please specify SERVICE=name${NC}"; exit 1; fi
|
||||
@$(ECHO_CMD) "${BLUE}🚀 Starting $(SERVICE)...${NC}"
|
||||
@case "$(SERVICE)" in \
|
||||
backend-api) \
|
||||
if command -v air >/dev/null; then cd $(ROOT)/$(SERVICE_DIR_backend-api) && air & else cd $(ROOT)/$(SERVICE_DIR_backend-api) && go run cmd/modern-server/main.go & fi ;; \
|
||||
if command -v air >/dev/null; then cd $(ROOT)/$(SERVICE_DIR_backend-api) && air & else cd $(ROOT)/$(SERVICE_DIR_backend-api) && go run cmd/api/main.go & fi ;; \
|
||||
chat-server) \
|
||||
if command -v cargo-watch >/dev/null; then cd $(ROOT)/$(SERVICE_DIR_chat-server) && cargo watch -x run -q & else cd $(ROOT)/$(SERVICE_DIR_chat-server) && cargo run -q & fi ;; \
|
||||
stream-server) \
|
||||
|
|
@ -83,7 +83,7 @@ start-local-service: ## [LOW] Start a service locally (usage: make start-local-s
|
|||
stop-local-service: ## [LOW] Stop a local service (usage: make stop-local-service SERVICE=backend-api)
|
||||
@if [ -z "$(SERVICE)" ]; then $(ECHO_CMD) "${RED}❌ Please specify SERVICE=name${NC}"; exit 1; fi
|
||||
@case "$(SERVICE)" in \
|
||||
backend-api) pkill -f "air\|go run.*modern-server" 2>/dev/null || true ;; \
|
||||
backend-api) pkill -f "air\|go run.*cmd/api" 2>/dev/null || true ;; \
|
||||
chat-server|stream-server) pkill -f "cargo.*$(SERVICE)" 2>/dev/null || true ;; \
|
||||
web) pkill -f "npm run dev\|vite" 2>/dev/null || true ;; \
|
||||
*) $(ECHO_CMD) "${RED}Unknown service: $(SERVICE)${NC}" ;; \
|
||||
|
|
|
|||
|
|
@ -56,13 +56,13 @@ else
|
|||
echo -e "${BLUE}📍 API sera accessible sur http://127.0.0.1:8080/api/v1${NC}"
|
||||
echo ""
|
||||
|
||||
# Chercher le point d'entrée
|
||||
if [ -f "cmd/modern-server/main.go" ]; then
|
||||
exec go run cmd/modern-server/main.go
|
||||
# Point d'entrée canonique
|
||||
if [ -f "cmd/api/main.go" ]; then
|
||||
exec go run cmd/api/main.go
|
||||
elif [ -f "cmd/server/main.go" ]; then
|
||||
exec go run cmd/server/main.go
|
||||
else
|
||||
echo -e "${RED}❌ Point d'entrée non trouvé (cmd/modern-server/main.go ou cmd/server/main.go)${NC}"
|
||||
echo -e "${RED}❌ Point d'entrée non trouvé (cmd/api/main.go ou cmd/server/main.go)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ if lsof -i :8080 -t >/dev/null 2>&1; then
|
|||
fi
|
||||
|
||||
# Start Backend in background
|
||||
(cd veza-backend-api && go run cmd/modern-server/main.go) > backend_boot.log 2>&1 &
|
||||
(cd veza-backend-api && go run cmd/api/main.go) > backend_boot.log 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
echo $BACKEND_PID > backend.pid
|
||||
echo -e "${GREEN}✅ Backend running (PID: $BACKEND_PID). Logs: backend_boot.log${NC}"
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ cd ..
|
|||
echo -e "${BLUE}⚙️ Starting Backend API (port 8080)...${NC}"
|
||||
# Use standard run, not hot reload for stability in this script, or use air if available?
|
||||
# Let's use simple go run to be robust.
|
||||
(cd veza-backend-api && go run cmd/modern-server/main.go) > backend.log 2>&1 &
|
||||
(cd veza-backend-api && go run cmd/api/main.go) > backend.log 2>&1 &
|
||||
BACKEND_PID=$!
|
||||
echo $BACKEND_PID > backend.pid
|
||||
echo -e "${GREEN}✅ Backend running (PID: $BACKEND_PID). Logs: backend.log${NC}"
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ echo "📜 Contract: docs/BUDGETS.md"
|
|||
echo "🧠 Budget: ${MAX_RSS_MB}MB RSS (Idle)"
|
||||
|
||||
cd "$BACKEND_DIR"
|
||||
go build -o server_mem_check ./cmd/modern-server/main.go
|
||||
go build -o server_mem_check ./cmd/api/main.go
|
||||
|
||||
# Start
|
||||
./server_mem_check > /dev/null 2>&1 &
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ echo "⏱️ Budget: ${MAX_STARTUP_SECONDS}s to reach ready state"
|
|||
cd "$BACKEND_DIR"
|
||||
|
||||
# Build first to not count compilation time
|
||||
go build -o server_perf_check ./cmd/modern-server/main.go
|
||||
go build -o server_perf_check ./cmd/api/main.go
|
||||
|
||||
# Start server in background, measuring time to first log output or port open
|
||||
# This is a naive check: we expect it to NOT crash and to output something quickly.
|
||||
|
|
|
|||
|
|
@ -32,12 +32,12 @@ help: ## Affiche cette aide
|
|||
# Développement
|
||||
build: ## Compile l'application
|
||||
@echo "$(GREEN)🔨 Compilation de l'application...$(NC)"
|
||||
@go build -o bin/$(BINARY_NAME) ./cmd/modern-server/main.go
|
||||
@go build -o bin/$(BINARY_NAME) ./cmd/api/main.go
|
||||
@echo "$(GREEN)✅ Compilation terminée: bin/$(BINARY_NAME)$(NC)"
|
||||
|
||||
build-linux: ## Compile l'application pour Linux
|
||||
@echo "$(GREEN)🔨 Compilation pour Linux...$(NC)"
|
||||
@GOOS=linux GOARCH=amd64 go build -o bin/$(BINARY_NAME)-linux ./cmd/modern-server/main.go
|
||||
@GOOS=linux GOARCH=amd64 go build -o bin/$(BINARY_NAME)-linux ./cmd/api/main.go
|
||||
@echo "$(GREEN)✅ Compilation Linux terminée: bin/$(BINARY_NAME)-linux$(NC)"
|
||||
|
||||
# Tests
|
||||
|
|
@ -107,7 +107,7 @@ deps: ## Installe les dépendances
|
|||
|
||||
install: ## Installe l'application
|
||||
@echo "$(GREEN)📦 Installation de l'application...$(NC)"
|
||||
@go install ./cmd/modern-server/main.go
|
||||
@go install ./cmd/api/main.go
|
||||
@echo "$(GREEN)✅ Application installée$(NC)"
|
||||
|
||||
# Nettoyage
|
||||
|
|
@ -125,7 +125,7 @@ run: build ## Compile et exécute l'application
|
|||
|
||||
dev: ## Exécute l'application en mode développement
|
||||
@echo "$(GREEN)🚀 Mode développement...$(NC)"
|
||||
@go run ./cmd/modern-server/main.go
|
||||
@go run ./cmd/api/main.go
|
||||
|
||||
# Docker
|
||||
docker-build: ## Construit l'image Docker
|
||||
|
|
|
|||
|
|
@ -1,160 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"veza-backend-api/internal/api"
|
||||
// TODO: Réactiver internal/api/handlers après stabilisation du noyau
|
||||
// "veza-backend-api/internal/api/handlers"
|
||||
"veza-backend-api/internal/config"
|
||||
// TODO: Réactiver services après stabilisation du noyau
|
||||
// "veza-backend-api/internal/services"
|
||||
)
|
||||
|
||||
// @title Veza Backend API
|
||||
// @version 1.2.0
|
||||
// @description Backend API for Veza platform.
|
||||
// @termsOfService http://swagger.io/terms/
|
||||
// @contact.name API Support
|
||||
// @contact.url http://www.veza.app/support
|
||||
// @contact.email support@veza.app
|
||||
// @license.name Apache 2.0
|
||||
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
|
||||
// @host localhost:8080
|
||||
// @BasePath /api/v1
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Type "Bearer" followed by a space and JWT token.
|
||||
func main() {
|
||||
// Charger les variables d'environnement depuis le fichier .env
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Printf("⚠️ Impossible de charger le fichier .env: %v", err)
|
||||
}
|
||||
|
||||
// Configuration du logger
|
||||
logger, err := zap.NewProduction()
|
||||
if err != nil {
|
||||
log.Fatalf("Impossible d'initialiser le logger: %v", err)
|
||||
}
|
||||
defer logger.Sync()
|
||||
|
||||
logger.Info("🚀 Démarrage du serveur Veza Backend API (Architecture Moderne)")
|
||||
|
||||
// Charger la configuration
|
||||
cfg, err := config.NewConfig()
|
||||
if err != nil {
|
||||
logger.Fatal("❌ Impossible de charger la configuration", zap.Error(err))
|
||||
}
|
||||
|
||||
// Valider la configuration
|
||||
if err := cfg.Validate(); err != nil {
|
||||
logger.Fatal("❌ Configuration invalide", zap.Error(err))
|
||||
}
|
||||
|
||||
logger.Info("✅ Configuration validée avec succès")
|
||||
|
||||
// La base de données est déjà initialisée dans config.NewConfig()
|
||||
db := cfg.Database
|
||||
if db == nil {
|
||||
logger.Fatal("❌ Base de données non initialisée")
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Initialiser la base de données (migrations, etc.)
|
||||
if err := db.Initialize(); err != nil {
|
||||
logger.Fatal("❌ Impossible d'initialiser la base de données", zap.Error(err))
|
||||
}
|
||||
|
||||
// TODO: Réactiver les services après stabilisation du noyau et alignement des signatures
|
||||
// Initialiser les services
|
||||
// authService := services.NewAuthService(db, &cfg.JWT, logger)
|
||||
// oauthService := services.NewOAuthService(db, cfg, logger)
|
||||
// chatService := services.NewChatService(db, logger)
|
||||
// twoFactorService := services.NewTwoFactorService(db, logger)
|
||||
// rbacService := services.NewRBACService(db, logger)
|
||||
|
||||
// TODO: Réactiver les handlers après stabilisation du noyau et alignement des services
|
||||
// Initialiser les handlers
|
||||
// handlers.InitHandlers(authService, logger)
|
||||
// handlers.InitOAuthHandlers(oauthService, authService, logger)
|
||||
// handlers.InitChatHandlers(chatService, logger)
|
||||
// handlers.InitTwoFactorHandlers(twoFactorService, authService, logger)
|
||||
// handlers.InitRBACHandlers(rbacService, logger)
|
||||
|
||||
// Configuration de Gin selon l'environnement
|
||||
gin.SetMode(gin.DebugMode) // TODO: Utiliser cfg.LogLevel pour déterminer le mode
|
||||
|
||||
// Créer le router Gin
|
||||
router := gin.New()
|
||||
|
||||
// Configuration des routes avec la nouvelle architecture
|
||||
apiRouter := api.NewAPIRouter(db, cfg) // Instantiate APIRouter
|
||||
if err := apiRouter.Setup(router); err != nil {
|
||||
logger.Error("Failed to setup API routes", zap.Error(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Configuration du serveur HTTP
|
||||
port := fmt.Sprintf("%d", cfg.AppPort)
|
||||
if port == "0" {
|
||||
port = "8080"
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: fmt.Sprintf(":%s", port),
|
||||
Handler: router,
|
||||
// TODO: Ajouter ReadTimeout et WriteTimeout si nécessaire
|
||||
}
|
||||
|
||||
// Canal pour écouter les signaux du système
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Démarrer le serveur dans une goroutine
|
||||
go func() {
|
||||
logger.Info("🌐 Serveur HTTP démarré",
|
||||
zap.String("port", port),
|
||||
)
|
||||
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatal("❌ Erreur du serveur HTTP", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
logger.Info("✅ Serveur Veza Backend API prêt à recevoir des requêtes")
|
||||
logger.Info("📋 Endpoints disponibles:")
|
||||
logger.Info(" - GET /health - Health check global")
|
||||
logger.Info(" - POST /api/v1/auth/register - Inscription utilisateur")
|
||||
logger.Info(" - POST /api/v1/auth/login - Connexion utilisateur")
|
||||
logger.Info(" - POST /api/v1/auth/refresh - Renouvellement de token")
|
||||
logger.Info(" - POST /api/v1/auth/logout - Déconnexion utilisateur")
|
||||
logger.Info(" - GET /api/v1/profile - Profil utilisateur")
|
||||
logger.Info(" - PUT /api/v1/profile - Mise à jour profil")
|
||||
logger.Info(" - GET /api/v1/health/detailed - Health check détaillé")
|
||||
|
||||
// Attendre un signal d'arrêt
|
||||
<-quit
|
||||
logger.Info("🔄 Arrêt du serveur en cours...")
|
||||
|
||||
// Créer un contexte avec timeout pour l'arrêt gracieux
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // TODO: Utiliser config pour timeout
|
||||
defer cancel()
|
||||
|
||||
// Arrêt gracieux du serveur
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.Error("❌ Erreur lors de l'arrêt du serveur", zap.Error(err))
|
||||
} else {
|
||||
logger.Info("✅ Serveur arrêté proprement")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,868 +0,0 @@
|
|||
package education
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"veza-backend-api/internal/common"
|
||||
"veza-backend-api/internal/core/education"
|
||||
"veza-backend-api/internal/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Handler gère les requêtes HTTP pour l'éducation
|
||||
type Handler struct {
|
||||
courseManager *education.CourseManager
|
||||
tutorialManager *education.TutorialManager
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewHandler crée un nouveau handler d'éducation
|
||||
func NewHandler(courseManager *education.CourseManager, tutorialManager *education.TutorialManager, logger *zap.Logger) *Handler {
|
||||
return &Handler{
|
||||
courseManager: courseManager,
|
||||
tutorialManager: tutorialManager,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Request/Response structures
|
||||
type CreateCourseRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
Instructor string `json:"instructor" binding:"required"`
|
||||
Category string `json:"category" binding:"required"`
|
||||
Level education.CourseLevel `json:"level" binding:"required"`
|
||||
Duration time.Duration `json:"duration" binding:"required"`
|
||||
Price float64 `json:"price"`
|
||||
Language string `json:"language" binding:"required"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type UpdateCourseRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Instructor *string `json:"instructor"`
|
||||
Category *string `json:"category"`
|
||||
Level *education.CourseLevel `json:"level"`
|
||||
Duration *time.Duration `json:"duration"`
|
||||
Price *float64 `json:"price"`
|
||||
Language *string `json:"language"`
|
||||
IsPublished *bool `json:"is_published"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type CreateTutorialRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
Author string `json:"author" binding:"required"`
|
||||
Category string `json:"category" binding:"required"`
|
||||
VideoURL string `json:"video_url" binding:"required"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Duration time.Duration `json:"duration" binding:"required"`
|
||||
Quality education.VideoQuality `json:"quality" binding:"required"`
|
||||
Language string `json:"language" binding:"required"`
|
||||
IsFree bool `json:"is_free"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type UpdateTutorialRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Author *string `json:"author"`
|
||||
Category *string `json:"category"`
|
||||
VideoURL *string `json:"video_url"`
|
||||
Thumbnail *string `json:"thumbnail"`
|
||||
Duration *time.Duration `json:"duration"`
|
||||
Quality *education.VideoQuality `json:"quality"`
|
||||
IsPublished *bool `json:"is_published"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type AddLessonRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
VideoURL string `json:"video_url"`
|
||||
Duration time.Duration `json:"duration" binding:"required"`
|
||||
Order int `json:"order" binding:"required"`
|
||||
IsFree bool `json:"is_free"`
|
||||
}
|
||||
|
||||
type AddExerciseRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Solution string `json:"solution" binding:"required"`
|
||||
Type education.ExerciseType `json:"type" binding:"required"`
|
||||
Points int `json:"points" binding:"required"`
|
||||
TimeLimit time.Duration `json:"time_limit"`
|
||||
IsRequired bool `json:"is_required"`
|
||||
}
|
||||
|
||||
type UpdateProgressRequest struct {
|
||||
Progress float64 `json:"progress" binding:"required"`
|
||||
CompletedLessons []string `json:"completed_lessons"`
|
||||
CurrentLesson string `json:"current_lesson"`
|
||||
Score float64 `json:"score"`
|
||||
TimeSpent time.Duration `json:"time_spent"`
|
||||
}
|
||||
|
||||
type AddTutorialStepRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
Content string `json:"content" binding:"required"`
|
||||
Order int `json:"order" binding:"required"`
|
||||
Timestamp time.Duration `json:"timestamp"`
|
||||
IsFree bool `json:"is_free"`
|
||||
}
|
||||
|
||||
type AddTutorialCommentRequest struct {
|
||||
Content string `json:"content" binding:"required"`
|
||||
Rating int `json:"rating" binding:"min=1,max=5"`
|
||||
}
|
||||
|
||||
// COURSES HANDLERS
|
||||
|
||||
// CreateCourse crée un nouveau cours
|
||||
func (h *Handler) CreateCourse(c *gin.Context) {
|
||||
_, exists := common.GetUserIDFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "Utilisateur non authentifié")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateCourseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "Données de requête invalides")
|
||||
return
|
||||
}
|
||||
|
||||
course, err := h.courseManager.CreateCourse(
|
||||
c.Request.Context(),
|
||||
req.Title,
|
||||
req.Description,
|
||||
req.Instructor,
|
||||
req.Category,
|
||||
req.Level,
|
||||
req.Duration,
|
||||
req.Price,
|
||||
req.Language,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de création du cours", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec de création du cours")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, course, "Cours créé avec succès")
|
||||
}
|
||||
|
||||
// GetCourse récupère un cours par son ID
|
||||
func (h *Handler) GetCourse(c *gin.Context) {
|
||||
courseID := c.Param("course_id")
|
||||
if courseID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de cours requis")
|
||||
return
|
||||
}
|
||||
|
||||
course, err := h.courseManager.GetCourse(c.Request.Context(), courseID)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de récupération du cours", zap.Error(err))
|
||||
response.Error(c, http.StatusNotFound, "Cours non trouvé")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, course, "Cours récupéré avec succès")
|
||||
}
|
||||
|
||||
// ListCourses liste tous les cours disponibles
|
||||
func (h *Handler) ListCourses(c *gin.Context) {
|
||||
filters := make(map[string]interface{})
|
||||
|
||||
if category := c.Query("category"); category != "" {
|
||||
filters["category"] = category
|
||||
}
|
||||
if level := c.Query("level"); level != "" {
|
||||
filters["level"] = education.CourseLevel(level)
|
||||
}
|
||||
if isPublished := c.Query("is_published"); isPublished != "" {
|
||||
if published, err := strconv.ParseBool(isPublished); err == nil {
|
||||
filters["is_published"] = published
|
||||
}
|
||||
}
|
||||
if isFree := c.Query("is_free"); isFree != "" {
|
||||
if free, err := strconv.ParseBool(isFree); err == nil {
|
||||
filters["is_free"] = free
|
||||
}
|
||||
}
|
||||
|
||||
courses, err := h.courseManager.ListCourses(c.Request.Context(), filters)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de récupération des cours", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec de récupération des cours")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, courses, "Cours récupérés avec succès")
|
||||
}
|
||||
|
||||
// UpdateCourse met à jour un cours
|
||||
func (h *Handler) UpdateCourse(c *gin.Context) {
|
||||
_, exists := common.GetUserIDFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "Utilisateur non authentifié")
|
||||
return
|
||||
}
|
||||
|
||||
courseID := c.Param("course_id")
|
||||
if courseID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de cours requis")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateCourseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "Données de requête invalides")
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if req.Title != nil {
|
||||
updates["title"] = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
updates["description"] = *req.Description
|
||||
}
|
||||
if req.Instructor != nil {
|
||||
updates["instructor"] = *req.Instructor
|
||||
}
|
||||
if req.Category != nil {
|
||||
updates["category"] = *req.Category
|
||||
}
|
||||
if req.Level != nil {
|
||||
updates["level"] = *req.Level
|
||||
}
|
||||
if req.Duration != nil {
|
||||
updates["duration"] = *req.Duration
|
||||
}
|
||||
if req.Price != nil {
|
||||
updates["price"] = *req.Price
|
||||
}
|
||||
if req.Language != nil {
|
||||
updates["language"] = *req.Language
|
||||
}
|
||||
if req.IsPublished != nil {
|
||||
updates["is_published"] = *req.IsPublished
|
||||
}
|
||||
if req.Tags != nil {
|
||||
updates["tags"] = req.Tags
|
||||
}
|
||||
|
||||
course, err := h.courseManager.UpdateCourse(c.Request.Context(), courseID, updates)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de mise à jour du cours", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec de mise à jour du cours")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, course, "Cours mis à jour avec succès")
|
||||
}
|
||||
|
||||
// DeleteCourse supprime un cours
|
||||
func (h *Handler) DeleteCourse(c *gin.Context) {
|
||||
_, exists := common.GetUserIDFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "Utilisateur non authentifié")
|
||||
return
|
||||
}
|
||||
|
||||
courseID := c.Param("course_id")
|
||||
if courseID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de cours requis")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.courseManager.DeleteCourse(c.Request.Context(), courseID)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de suppression du cours", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec de suppression du cours")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, nil, "Cours supprimé avec succès")
|
||||
}
|
||||
|
||||
// AddLesson ajoute une leçon à un cours
|
||||
func (h *Handler) AddLesson(c *gin.Context) {
|
||||
_, exists := common.GetUserIDFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "Utilisateur non authentifié")
|
||||
return
|
||||
}
|
||||
|
||||
courseID := c.Param("course_id")
|
||||
if courseID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de cours requis")
|
||||
return
|
||||
}
|
||||
|
||||
var req AddLessonRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "Données de requête invalides")
|
||||
return
|
||||
}
|
||||
|
||||
lesson, err := h.courseManager.AddLesson(
|
||||
c.Request.Context(),
|
||||
courseID,
|
||||
req.Title,
|
||||
req.Description,
|
||||
req.Content,
|
||||
req.VideoURL,
|
||||
req.Duration,
|
||||
req.Order,
|
||||
req.IsFree,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec d'ajout de leçon", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec d'ajout de leçon")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, lesson, "Leçon ajoutée avec succès")
|
||||
}
|
||||
|
||||
// AddExercise ajoute un exercice à un cours
|
||||
func (h *Handler) AddExercise(c *gin.Context) {
|
||||
_, exists := common.GetUserIDFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "Utilisateur non authentifié")
|
||||
return
|
||||
}
|
||||
|
||||
courseID := c.Param("course_id")
|
||||
lessonID := c.Param("lesson_id")
|
||||
if courseID == "" || lessonID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de cours et de leçon requis")
|
||||
return
|
||||
}
|
||||
|
||||
var req AddExerciseRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "Données de requête invalides")
|
||||
return
|
||||
}
|
||||
|
||||
exercise, err := h.courseManager.AddExercise(
|
||||
c.Request.Context(),
|
||||
courseID,
|
||||
lessonID,
|
||||
req.Title,
|
||||
req.Description,
|
||||
req.Content,
|
||||
req.Solution,
|
||||
req.Type,
|
||||
req.Points,
|
||||
req.TimeLimit,
|
||||
req.IsRequired,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec d'ajout d'exercice", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec d'ajout d'exercice")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, exercise, "Exercice ajouté avec succès")
|
||||
}
|
||||
|
||||
// GetUserProgress récupère la progression d'un utilisateur
|
||||
func (h *Handler) GetUserProgress(c *gin.Context) {
|
||||
userID, exists := common.GetUserIDFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "Utilisateur non authentifié")
|
||||
return
|
||||
}
|
||||
|
||||
courseID := c.Param("course_id")
|
||||
if courseID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de cours requis")
|
||||
return
|
||||
}
|
||||
|
||||
progress, err := h.courseManager.GetUserProgress(c.Request.Context(), userID, courseID)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de récupération de la progression", zap.Error(err))
|
||||
response.Error(c, http.StatusNotFound, "Progression non trouvée")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, progress, "Progression récupérée avec succès")
|
||||
}
|
||||
|
||||
// UpdateUserProgress met à jour la progression d'un utilisateur
|
||||
func (h *Handler) UpdateUserProgress(c *gin.Context) {
|
||||
userID, exists := common.GetUserIDFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "Utilisateur non authentifié")
|
||||
return
|
||||
}
|
||||
|
||||
courseID := c.Param("course_id")
|
||||
if courseID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de cours requis")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateProgressRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "Données de requête invalides")
|
||||
return
|
||||
}
|
||||
|
||||
progress, err := h.courseManager.UpdateUserProgress(
|
||||
c.Request.Context(),
|
||||
userID,
|
||||
courseID,
|
||||
req.Progress,
|
||||
req.CompletedLessons,
|
||||
req.CurrentLesson,
|
||||
req.Score,
|
||||
req.TimeSpent,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de mise à jour de la progression", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec de mise à jour de la progression")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, progress, "Progression mise à jour avec succès")
|
||||
}
|
||||
|
||||
// IssueCertificate émet un certificat
|
||||
func (h *Handler) IssueCertificate(c *gin.Context) {
|
||||
userID, exists := common.GetUserIDFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "Utilisateur non authentifié")
|
||||
return
|
||||
}
|
||||
|
||||
courseID := c.Param("course_id")
|
||||
if courseID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de cours requis")
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les paramètres de la requête
|
||||
title := c.Query("title")
|
||||
description := c.Query("description")
|
||||
scoreStr := c.Query("score")
|
||||
maxScoreStr := c.Query("max_score")
|
||||
|
||||
if title == "" || description == "" || scoreStr == "" || maxScoreStr == "" {
|
||||
response.Error(c, http.StatusBadRequest, "Tous les paramètres sont requis")
|
||||
return
|
||||
}
|
||||
|
||||
score, err := strconv.ParseFloat(scoreStr, 64)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "Score invalide")
|
||||
return
|
||||
}
|
||||
|
||||
maxScore, err := strconv.ParseFloat(maxScoreStr, 64)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "Score maximum invalide")
|
||||
return
|
||||
}
|
||||
|
||||
certificate, err := h.courseManager.IssueCertificate(
|
||||
c.Request.Context(),
|
||||
courseID,
|
||||
userID,
|
||||
title,
|
||||
description,
|
||||
score,
|
||||
maxScore,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec d'émission du certificat", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec d'émission du certificat")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, certificate, "Certificat émis avec succès")
|
||||
}
|
||||
|
||||
// TUTORIALS HANDLERS
|
||||
|
||||
// CreateTutorial crée un nouveau tutoriel
|
||||
func (h *Handler) CreateTutorial(c *gin.Context) {
|
||||
_, exists := common.GetUserIDFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "Utilisateur non authentifié")
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateTutorialRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "Données de requête invalides")
|
||||
return
|
||||
}
|
||||
|
||||
tutorial, err := h.tutorialManager.CreateTutorial(
|
||||
c.Request.Context(),
|
||||
req.Title,
|
||||
req.Description,
|
||||
req.Author,
|
||||
req.Category,
|
||||
req.VideoURL,
|
||||
req.Thumbnail,
|
||||
req.Language,
|
||||
req.Duration,
|
||||
req.Quality,
|
||||
req.IsFree,
|
||||
req.Tags,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de création du tutoriel", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec de création du tutoriel")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, tutorial, "Tutoriel créé avec succès")
|
||||
}
|
||||
|
||||
// GetTutorial récupère un tutoriel par son ID
|
||||
func (h *Handler) GetTutorial(c *gin.Context) {
|
||||
tutorialID := c.Param("tutorial_id")
|
||||
if tutorialID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de tutoriel requis")
|
||||
return
|
||||
}
|
||||
|
||||
tutorial, err := h.tutorialManager.GetTutorial(c.Request.Context(), tutorialID)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de récupération du tutoriel", zap.Error(err))
|
||||
response.Error(c, http.StatusNotFound, "Tutoriel non trouvé")
|
||||
return
|
||||
}
|
||||
|
||||
// Incrémenter les vues
|
||||
go func() {
|
||||
if err := h.tutorialManager.IncrementViews(c.Request.Context(), tutorialID); err != nil {
|
||||
h.logger.Error("Échec d'incrémentation des vues", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
|
||||
response.Success(c, tutorial, "Tutoriel récupéré avec succès")
|
||||
}
|
||||
|
||||
// ListTutorials liste tous les tutoriels disponibles
|
||||
func (h *Handler) ListTutorials(c *gin.Context) {
|
||||
filters := make(map[string]interface{})
|
||||
|
||||
if category := c.Query("category"); category != "" {
|
||||
filters["category"] = category
|
||||
}
|
||||
if isPublished := c.Query("is_published"); isPublished != "" {
|
||||
if published, err := strconv.ParseBool(isPublished); err == nil {
|
||||
filters["is_published"] = published
|
||||
}
|
||||
}
|
||||
if isFree := c.Query("is_free"); isFree != "" {
|
||||
if free, err := strconv.ParseBool(isFree); err == nil {
|
||||
filters["is_free"] = free
|
||||
}
|
||||
}
|
||||
if language := c.Query("language"); language != "" {
|
||||
filters["language"] = language
|
||||
}
|
||||
if author := c.Query("author"); author != "" {
|
||||
filters["author"] = author
|
||||
}
|
||||
|
||||
tutorials, err := h.tutorialManager.ListTutorials(c.Request.Context(), filters)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de récupération des tutoriels", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec de récupération des tutoriels")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, tutorials, "Tutoriels récupérés avec succès")
|
||||
}
|
||||
|
||||
// SearchTutorials recherche des tutoriels
|
||||
func (h *Handler) SearchTutorials(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
response.Error(c, http.StatusBadRequest, "Terme de recherche requis")
|
||||
return
|
||||
}
|
||||
|
||||
filters := make(map[string]interface{})
|
||||
if category := c.Query("category"); category != "" {
|
||||
filters["category"] = category
|
||||
}
|
||||
if isPublished := c.Query("is_published"); isPublished != "" {
|
||||
if published, err := strconv.ParseBool(isPublished); err == nil {
|
||||
filters["is_published"] = published
|
||||
}
|
||||
}
|
||||
if isFree := c.Query("is_free"); isFree != "" {
|
||||
if free, err := strconv.ParseBool(isFree); err == nil {
|
||||
filters["is_free"] = free
|
||||
}
|
||||
}
|
||||
|
||||
tutorials, err := h.tutorialManager.SearchTutorials(c.Request.Context(), query, filters)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de recherche des tutoriels", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec de recherche des tutoriels")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, tutorials, "Recherche de tutoriels terminée")
|
||||
}
|
||||
|
||||
// UpdateTutorial met à jour un tutoriel
|
||||
func (h *Handler) UpdateTutorial(c *gin.Context) {
|
||||
_, exists := common.GetUserIDFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "Utilisateur non authentifié")
|
||||
return
|
||||
}
|
||||
|
||||
tutorialID := c.Param("tutorial_id")
|
||||
if tutorialID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de tutoriel requis")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateTutorialRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "Données de requête invalides")
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if req.Title != nil {
|
||||
updates["title"] = *req.Title
|
||||
}
|
||||
if req.Description != nil {
|
||||
updates["description"] = *req.Description
|
||||
}
|
||||
if req.Author != nil {
|
||||
updates["author"] = *req.Author
|
||||
}
|
||||
if req.Category != nil {
|
||||
updates["category"] = *req.Category
|
||||
}
|
||||
if req.VideoURL != nil {
|
||||
updates["video_url"] = *req.VideoURL
|
||||
}
|
||||
if req.Thumbnail != nil {
|
||||
updates["thumbnail"] = *req.Thumbnail
|
||||
}
|
||||
if req.Duration != nil {
|
||||
updates["duration"] = *req.Duration
|
||||
}
|
||||
if req.Quality != nil {
|
||||
updates["quality"] = *req.Quality
|
||||
}
|
||||
if req.IsPublished != nil {
|
||||
updates["is_published"] = *req.IsPublished
|
||||
}
|
||||
if req.Tags != nil {
|
||||
updates["tags"] = req.Tags
|
||||
}
|
||||
|
||||
tutorial, err := h.tutorialManager.UpdateTutorial(c.Request.Context(), tutorialID, updates)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de mise à jour du tutoriel", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec de mise à jour du tutoriel")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, tutorial, "Tutoriel mis à jour avec succès")
|
||||
}
|
||||
|
||||
// DeleteTutorial supprime un tutoriel
|
||||
func (h *Handler) DeleteTutorial(c *gin.Context) {
|
||||
_, exists := common.GetUserIDFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "Utilisateur non authentifié")
|
||||
return
|
||||
}
|
||||
|
||||
tutorialID := c.Param("tutorial_id")
|
||||
if tutorialID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de tutoriel requis")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.tutorialManager.DeleteTutorial(c.Request.Context(), tutorialID)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de suppression du tutoriel", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec de suppression du tutoriel")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, nil, "Tutoriel supprimé avec succès")
|
||||
}
|
||||
|
||||
// AddTutorialStep ajoute une étape à un tutoriel
|
||||
func (h *Handler) AddTutorialStep(c *gin.Context) {
|
||||
_, exists := common.GetUserIDFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "Utilisateur non authentifié")
|
||||
return
|
||||
}
|
||||
|
||||
tutorialID := c.Param("tutorial_id")
|
||||
if tutorialID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de tutoriel requis")
|
||||
return
|
||||
}
|
||||
|
||||
var req AddTutorialStepRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "Données de requête invalides")
|
||||
return
|
||||
}
|
||||
|
||||
step, err := h.tutorialManager.AddTutorialStep(
|
||||
c.Request.Context(),
|
||||
tutorialID,
|
||||
req.Title,
|
||||
req.Description,
|
||||
req.Content,
|
||||
req.Order,
|
||||
req.Timestamp,
|
||||
req.IsFree,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec d'ajout d'étape de tutoriel", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec d'ajout d'étape de tutoriel")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, step, "Étape de tutoriel ajoutée avec succès")
|
||||
}
|
||||
|
||||
// GetTutorialSteps récupère les étapes d'un tutoriel
|
||||
func (h *Handler) GetTutorialSteps(c *gin.Context) {
|
||||
tutorialID := c.Param("tutorial_id")
|
||||
if tutorialID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de tutoriel requis")
|
||||
return
|
||||
}
|
||||
|
||||
steps, err := h.tutorialManager.GetTutorialSteps(c.Request.Context(), tutorialID)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de récupération des étapes", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec de récupération des étapes")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, steps, "Étapes récupérées avec succès")
|
||||
}
|
||||
|
||||
// AddTutorialComment ajoute un commentaire à un tutoriel
|
||||
func (h *Handler) AddTutorialComment(c *gin.Context) {
|
||||
userID, exists := common.GetUserIDFromContext(c)
|
||||
if !exists {
|
||||
response.Error(c, http.StatusUnauthorized, "Utilisateur non authentifié")
|
||||
return
|
||||
}
|
||||
|
||||
tutorialID := c.Param("tutorial_id")
|
||||
if tutorialID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de tutoriel requis")
|
||||
return
|
||||
}
|
||||
|
||||
var req AddTutorialCommentRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "Données de requête invalides")
|
||||
return
|
||||
}
|
||||
|
||||
username, _ := common.GetUsernameFromContext(c)
|
||||
if username == "" {
|
||||
username = "Utilisateur anonyme"
|
||||
}
|
||||
|
||||
comment, err := h.tutorialManager.AddTutorialComment(
|
||||
c.Request.Context(),
|
||||
tutorialID,
|
||||
userID.String(),
|
||||
username,
|
||||
req.Content,
|
||||
req.Rating,
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec d'ajout de commentaire", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec d'ajout de commentaire")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, comment, "Commentaire ajouté avec succès")
|
||||
}
|
||||
|
||||
// GetTutorialComments récupère les commentaires d'un tutoriel
|
||||
func (h *Handler) GetTutorialComments(c *gin.Context) {
|
||||
tutorialID := c.Param("tutorial_id")
|
||||
if tutorialID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de tutoriel requis")
|
||||
return
|
||||
}
|
||||
|
||||
comments, err := h.tutorialManager.GetTutorialComments(c.Request.Context(), tutorialID)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec de récupération des commentaires", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec de récupération des commentaires")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, comments, "Commentaires récupérés avec succès")
|
||||
}
|
||||
|
||||
// LikeTutorial ajoute un like à un tutoriel
|
||||
func (h *Handler) LikeTutorial(c *gin.Context) {
|
||||
tutorialID := c.Param("tutorial_id")
|
||||
if tutorialID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de tutoriel requis")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.tutorialManager.LikeTutorial(c.Request.Context(), tutorialID)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec d'ajout de like", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec d'ajout de like")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, nil, "Like ajouté avec succès")
|
||||
}
|
||||
|
||||
// DislikeTutorial ajoute un dislike à un tutoriel
|
||||
func (h *Handler) DislikeTutorial(c *gin.Context) {
|
||||
tutorialID := c.Param("tutorial_id")
|
||||
if tutorialID == "" {
|
||||
response.Error(c, http.StatusBadRequest, "ID de tutoriel requis")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.tutorialManager.DislikeTutorial(c.Request.Context(), tutorialID)
|
||||
if err != nil {
|
||||
h.logger.Error("Échec d'ajout de dislike", zap.Error(err))
|
||||
response.Error(c, http.StatusInternalServerError, "Échec d'ajout de dislike")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, nil, "Dislike ajouté avec succès")
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
package education
|
||||
|
||||
import (
|
||||
"veza-backend-api/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SetupRoutes configure les routes d'éducation
|
||||
func SetupRoutes(router *gin.RouterGroup, handler *Handler, jwtSecret string, authMiddleware *middleware.AuthMiddleware) { // Added authMiddleware parameter
|
||||
// Groupe de routes pour l'éducation
|
||||
edu := router.Group("/education")
|
||||
{
|
||||
// Routes des cours
|
||||
courses := edu.Group("/courses")
|
||||
courses.Use(authMiddleware.RequireAuth()) // Changed to authMiddleware.RequireAuth()
|
||||
{
|
||||
courses.POST("/create", handler.CreateCourse)
|
||||
courses.GET("/list", handler.ListCourses)
|
||||
courses.GET("/:course_id", handler.GetCourse)
|
||||
courses.PUT("/:course_id", handler.UpdateCourse)
|
||||
courses.DELETE("/:course_id", handler.DeleteCourse)
|
||||
courses.POST("/:course_id/lessons", handler.AddLesson)
|
||||
courses.POST("/:course_id/lessons/:lesson_id/exercises", handler.AddExercise)
|
||||
courses.GET("/:course_id/progress", handler.GetUserProgress)
|
||||
courses.PUT("/:course_id/progress", handler.UpdateUserProgress)
|
||||
courses.POST("/:course_id/certificate", handler.IssueCertificate)
|
||||
}
|
||||
|
||||
// Routes des tutoriels
|
||||
tutorials := edu.Group("/tutorials")
|
||||
{
|
||||
// Routes publiques (sans authentification)
|
||||
tutorials.GET("/list", handler.ListTutorials)
|
||||
tutorials.GET("/search", handler.SearchTutorials)
|
||||
tutorials.GET("/:tutorial_id", handler.GetTutorial)
|
||||
tutorials.GET("/:tutorial_id/steps", handler.GetTutorialSteps)
|
||||
tutorials.GET("/:tutorial_id/comments", handler.GetTutorialComments)
|
||||
tutorials.POST("/:tutorial_id/like", handler.LikeTutorial)
|
||||
tutorials.POST("/:tutorial_id/dislike", handler.DislikeTutorial)
|
||||
|
||||
// Routes protégées (avec authentification)
|
||||
protected := tutorials.Group("")
|
||||
protected.Use(authMiddleware.RequireAuth()) // Changed to authMiddleware.RequireAuth()
|
||||
{
|
||||
protected.POST("/create", handler.CreateTutorial)
|
||||
protected.PUT("/:tutorial_id", handler.UpdateTutorial)
|
||||
protected.DELETE("/:tutorial_id", handler.DeleteTutorial)
|
||||
protected.POST("/:tutorial_id/steps", handler.AddTutorialStep)
|
||||
protected.POST("/:tutorial_id/comments", handler.AddTutorialComment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -290,9 +290,6 @@ func (r *APIRouter) Setup(router *gin.Engine) error {
|
|||
// Social Routes
|
||||
r.setupSocialRoutes(v1)
|
||||
|
||||
// Education Routes (courses, tutorials)
|
||||
r.setupEducationRoutes(v1)
|
||||
|
||||
// Inventory / Gear Routes
|
||||
r.setupGearRoutes(v1)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
eduApi "veza-backend-api/internal/api/education"
|
||||
eduCore "veza-backend-api/internal/core/education"
|
||||
)
|
||||
|
||||
// setupEducationRoutes configure les routes d'éducation (cours, tutoriels)
|
||||
func (r *APIRouter) setupEducationRoutes(router *gin.RouterGroup) {
|
||||
if r.config == nil || r.config.AuthMiddleware == nil {
|
||||
return
|
||||
}
|
||||
courseManager := eduCore.NewCourseManager(r.logger)
|
||||
tutorialManager := eduCore.NewTutorialManager(r.logger)
|
||||
eduHandler := eduApi.NewHandler(courseManager, tutorialManager, r.logger)
|
||||
eduApi.SetupRoutes(router, eduHandler, r.config.JWTSecret, r.config.AuthMiddleware)
|
||||
}
|
||||
|
|
@ -837,7 +837,7 @@ func (c *Config) initMiddlewares() error {
|
|||
// Les handlers doivent être créés dans main.go ou dans les routes selon les besoins.
|
||||
//
|
||||
// SetupRoutes a été supprimé pour casser le cycle d'import config <-> api.
|
||||
// Utiliser directement api.SetupRoutes() dans cmd/modern-server/main.go
|
||||
// Utiliser directement api.SetupRoutes() dans cmd/api/main.go
|
||||
|
||||
// SetupMiddleware configure les middlewares globaux
|
||||
// DÉPRÉCIÉ : Cette méthode est conservée pour compatibilité mais ne fait plus rien
|
||||
|
|
|
|||
|
|
@ -1,452 +0,0 @@
|
|||
package education
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Course représente un cours de formation
|
||||
type Course struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Instructor string `json:"instructor"`
|
||||
Category string `json:"category"`
|
||||
Level CourseLevel `json:"level"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Price float64 `json:"price"`
|
||||
Currency string `json:"currency"`
|
||||
Language string `json:"language"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
VideoURL string `json:"video_url"`
|
||||
Lessons []*Lesson `json:"lessons"`
|
||||
Exercises []*Exercise `json:"exercises"`
|
||||
Certificates []*Certificate `json:"certificates"`
|
||||
Tags []string `json:"tags"`
|
||||
IsPublished bool `json:"is_published"`
|
||||
IsFree bool `json:"is_free"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// CourseLevel définit le niveau de difficulté d'un cours
|
||||
type CourseLevel string
|
||||
|
||||
const (
|
||||
CourseLevelBeginner CourseLevel = "beginner"
|
||||
CourseLevelIntermediate CourseLevel = "intermediate"
|
||||
CourseLevelAdvanced CourseLevel = "advanced"
|
||||
CourseLevelExpert CourseLevel = "expert"
|
||||
)
|
||||
|
||||
// Lesson représente une leçon dans un cours
|
||||
type Lesson struct {
|
||||
ID string `json:"id"`
|
||||
CourseID string `json:"course_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
VideoURL string `json:"video_url"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Order int `json:"order"`
|
||||
IsFree bool `json:"is_free"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Exercise représente un exercice pratique
|
||||
type Exercise struct {
|
||||
ID string `json:"id"`
|
||||
CourseID string `json:"course_id"`
|
||||
LessonID string `json:"lesson_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Type ExerciseType `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Solution string `json:"solution"`
|
||||
Points int `json:"points"`
|
||||
TimeLimit time.Duration `json:"time_limit"`
|
||||
IsRequired bool `json:"is_required"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ExerciseType définit le type d'exercice
|
||||
type ExerciseType string
|
||||
|
||||
const (
|
||||
ExerciseTypeQuiz ExerciseType = "quiz"
|
||||
ExerciseTypeProject ExerciseType = "project"
|
||||
ExerciseTypeAudio ExerciseType = "audio"
|
||||
ExerciseTypeCode ExerciseType = "code"
|
||||
ExerciseTypeEssay ExerciseType = "essay"
|
||||
)
|
||||
|
||||
// Certificate représente un certificat de formation
|
||||
type Certificate struct {
|
||||
ID string `json:"id"`
|
||||
CourseID string `json:"course_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Score float64 `json:"score"`
|
||||
MaxScore float64 `json:"max_score"`
|
||||
IsPassed bool `json:"is_passed"`
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CourseProgress représente la progression d'un utilisateur dans un cours
|
||||
type CourseProgress struct {
|
||||
ID string `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
CourseID string `json:"course_id"`
|
||||
Progress float64 `json:"progress"` // 0.0 à 1.0
|
||||
CompletedLessons []string `json:"completed_lessons"`
|
||||
CurrentLesson string `json:"current_lesson"`
|
||||
Score float64 `json:"score"`
|
||||
TimeSpent time.Duration `json:"time_spent"`
|
||||
LastAccessed time.Time `json:"last_accessed"`
|
||||
IsCompleted bool `json:"is_completed"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CourseManager gère les cours et formations
|
||||
type CourseManager struct {
|
||||
courses map[string]*Course
|
||||
progress map[string]*CourseProgress
|
||||
logger *zap.Logger
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewCourseManager crée un nouveau gestionnaire de cours
|
||||
func NewCourseManager(logger *zap.Logger) *CourseManager {
|
||||
return &CourseManager{
|
||||
courses: make(map[string]*Course),
|
||||
progress: make(map[string]*CourseProgress),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateCourse crée un nouveau cours
|
||||
func (cm *CourseManager) CreateCourse(ctx context.Context, title, description, instructor, category string, level CourseLevel, duration time.Duration, price float64, language string) (*Course, error) {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
courseID := uuid.New().String()
|
||||
|
||||
course := &Course{
|
||||
ID: courseID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Instructor: instructor,
|
||||
Category: category,
|
||||
Level: level,
|
||||
Duration: duration,
|
||||
Price: price,
|
||||
Currency: "EUR",
|
||||
Language: language,
|
||||
Lessons: []*Lesson{},
|
||||
Exercises: []*Exercise{},
|
||||
Certificates: []*Certificate{},
|
||||
Tags: []string{},
|
||||
IsPublished: false,
|
||||
IsFree: price == 0,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
cm.courses[courseID] = course
|
||||
|
||||
cm.logger.Info("Cours créé",
|
||||
zap.String("course_id", courseID),
|
||||
zap.String("title", title),
|
||||
zap.String("instructor", instructor))
|
||||
|
||||
return course, nil
|
||||
}
|
||||
|
||||
// GetCourse récupère un cours par son ID
|
||||
func (cm *CourseManager) GetCourse(ctx context.Context, courseID string) (*Course, error) {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
|
||||
course, exists := cm.courses[courseID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("cours non trouvé: %s", courseID)
|
||||
}
|
||||
|
||||
return course, nil
|
||||
}
|
||||
|
||||
// ListCourses liste tous les cours disponibles
|
||||
func (cm *CourseManager) ListCourses(ctx context.Context, filters map[string]interface{}) ([]*Course, error) {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
|
||||
var courses []*Course
|
||||
for _, course := range cm.courses {
|
||||
// Appliquer les filtres si fournis
|
||||
if filters != nil {
|
||||
if category, ok := filters["category"].(string); ok && course.Category != category {
|
||||
continue
|
||||
}
|
||||
if level, ok := filters["level"].(CourseLevel); ok && course.Level != level {
|
||||
continue
|
||||
}
|
||||
if isPublished, ok := filters["is_published"].(bool); ok && course.IsPublished != isPublished {
|
||||
continue
|
||||
}
|
||||
if isFree, ok := filters["is_free"].(bool); ok && course.IsFree != isFree {
|
||||
continue
|
||||
}
|
||||
}
|
||||
courses = append(courses, course)
|
||||
}
|
||||
|
||||
return courses, nil
|
||||
}
|
||||
|
||||
// UpdateCourse met à jour un cours
|
||||
func (cm *CourseManager) UpdateCourse(ctx context.Context, courseID string, updates map[string]interface{}) (*Course, error) {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
course, exists := cm.courses[courseID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("cours non trouvé: %s", courseID)
|
||||
}
|
||||
|
||||
// Appliquer les mises à jour
|
||||
if title, ok := updates["title"].(string); ok {
|
||||
course.Title = title
|
||||
}
|
||||
if description, ok := updates["description"].(string); ok {
|
||||
course.Description = description
|
||||
}
|
||||
if instructor, ok := updates["instructor"].(string); ok {
|
||||
course.Instructor = instructor
|
||||
}
|
||||
if category, ok := updates["category"].(string); ok {
|
||||
course.Category = category
|
||||
}
|
||||
if level, ok := updates["level"].(CourseLevel); ok {
|
||||
course.Level = level
|
||||
}
|
||||
if duration, ok := updates["duration"].(time.Duration); ok {
|
||||
course.Duration = duration
|
||||
}
|
||||
if price, ok := updates["price"].(float64); ok {
|
||||
course.Price = price
|
||||
course.IsFree = price == 0
|
||||
}
|
||||
if isPublished, ok := updates["is_published"].(bool); ok {
|
||||
course.IsPublished = isPublished
|
||||
}
|
||||
|
||||
course.UpdatedAt = time.Now()
|
||||
|
||||
cm.logger.Info("Cours mis à jour",
|
||||
zap.String("course_id", courseID),
|
||||
zap.String("title", course.Title))
|
||||
|
||||
return course, nil
|
||||
}
|
||||
|
||||
// DeleteCourse supprime un cours
|
||||
func (cm *CourseManager) DeleteCourse(ctx context.Context, courseID string) error {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
if _, exists := cm.courses[courseID]; !exists {
|
||||
return fmt.Errorf("cours non trouvé: %s", courseID)
|
||||
}
|
||||
|
||||
delete(cm.courses, courseID)
|
||||
|
||||
cm.logger.Info("Cours supprimé",
|
||||
zap.String("course_id", courseID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddLesson ajoute une leçon à un cours
|
||||
func (cm *CourseManager) AddLesson(ctx context.Context, courseID, title, description, content, videoURL string, duration time.Duration, order int, isFree bool) (*Lesson, error) {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
course, exists := cm.courses[courseID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("cours non trouvé: %s", courseID)
|
||||
}
|
||||
|
||||
lessonID := uuid.New().String()
|
||||
lesson := &Lesson{
|
||||
ID: lessonID,
|
||||
CourseID: courseID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Content: content,
|
||||
VideoURL: videoURL,
|
||||
Duration: duration,
|
||||
Order: order,
|
||||
IsFree: isFree,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
course.Lessons = append(course.Lessons, lesson)
|
||||
course.UpdatedAt = time.Now()
|
||||
|
||||
cm.logger.Info("Leçon ajoutée",
|
||||
zap.String("course_id", courseID),
|
||||
zap.String("lesson_id", lessonID),
|
||||
zap.String("title", title))
|
||||
|
||||
return lesson, nil
|
||||
}
|
||||
|
||||
// AddExercise ajoute un exercice à un cours
|
||||
func (cm *CourseManager) AddExercise(ctx context.Context, courseID, lessonID, title, description, content, solution string, exerciseType ExerciseType, points int, timeLimit time.Duration, isRequired bool) (*Exercise, error) {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
course, exists := cm.courses[courseID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("cours non trouvé: %s", courseID)
|
||||
}
|
||||
|
||||
exerciseID := uuid.New().String()
|
||||
exercise := &Exercise{
|
||||
ID: exerciseID,
|
||||
CourseID: courseID,
|
||||
LessonID: lessonID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Type: exerciseType,
|
||||
Content: content,
|
||||
Solution: solution,
|
||||
Points: points,
|
||||
TimeLimit: timeLimit,
|
||||
IsRequired: isRequired,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
course.Exercises = append(course.Exercises, exercise)
|
||||
course.UpdatedAt = time.Now()
|
||||
|
||||
cm.logger.Info("Exercice ajouté",
|
||||
zap.String("course_id", courseID),
|
||||
zap.String("exercise_id", exerciseID),
|
||||
zap.String("title", title))
|
||||
|
||||
return exercise, nil
|
||||
}
|
||||
|
||||
// GetUserProgress récupère la progression d'un utilisateur dans un cours
|
||||
func (cm *CourseManager) GetUserProgress(ctx context.Context, userID uuid.UUID, courseID string) (*CourseProgress, error) {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
|
||||
progressKey := fmt.Sprintf("%s_%s", userID.String(), courseID)
|
||||
progress, exists := cm.progress[progressKey]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("progression non trouvée pour l'utilisateur %s dans le cours %s", userID, courseID)
|
||||
}
|
||||
|
||||
return progress, nil
|
||||
}
|
||||
|
||||
// UpdateUserProgress met à jour la progression d'un utilisateur
|
||||
func (cm *CourseManager) UpdateUserProgress(ctx context.Context, userID uuid.UUID, courseID string, progress float64, completedLessons []string, currentLesson string, score float64, timeSpent time.Duration) (*CourseProgress, error) {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
progressKey := fmt.Sprintf("%s_%s", userID.String(), courseID)
|
||||
|
||||
userProgress, exists := cm.progress[progressKey]
|
||||
if !exists {
|
||||
userProgress = &CourseProgress{
|
||||
ID: uuid.New().String(),
|
||||
UserID: userID,
|
||||
CourseID: courseID,
|
||||
Progress: progress,
|
||||
CompletedLessons: completedLessons,
|
||||
CurrentLesson: currentLesson,
|
||||
Score: score,
|
||||
TimeSpent: timeSpent,
|
||||
LastAccessed: time.Now(),
|
||||
IsCompleted: progress >= 1.0,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
cm.progress[progressKey] = userProgress
|
||||
} else {
|
||||
userProgress.Progress = progress
|
||||
userProgress.CompletedLessons = completedLessons
|
||||
userProgress.CurrentLesson = currentLesson
|
||||
userProgress.Score = score
|
||||
userProgress.TimeSpent = timeSpent
|
||||
userProgress.LastAccessed = time.Now()
|
||||
userProgress.IsCompleted = progress >= 1.0
|
||||
userProgress.UpdatedAt = time.Now()
|
||||
|
||||
if userProgress.IsCompleted && userProgress.CompletedAt.IsZero() {
|
||||
userProgress.CompletedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
cm.logger.Info("Progression utilisateur mise à jour",
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.String("course_id", courseID),
|
||||
zap.Float64("progress", progress))
|
||||
|
||||
return userProgress, nil
|
||||
}
|
||||
|
||||
// IssueCertificate émet un certificat pour un utilisateur
|
||||
func (cm *CourseManager) IssueCertificate(ctx context.Context, courseID string, userID uuid.UUID, title, description string, score, maxScore float64) (*Certificate, error) {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
certificateID := uuid.New().String()
|
||||
isPassed := score >= maxScore*0.7 // 70% pour réussir
|
||||
|
||||
certificate := &Certificate{
|
||||
ID: certificateID,
|
||||
CourseID: courseID,
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Score: score,
|
||||
MaxScore: maxScore,
|
||||
IsPassed: isPassed,
|
||||
IssuedAt: time.Now(),
|
||||
ExpiresAt: time.Now().AddDate(2, 0, 0), // Valide 2 ans
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Ajouter le certificat au cours
|
||||
if course, exists := cm.courses[courseID]; exists {
|
||||
course.Certificates = append(course.Certificates, certificate)
|
||||
course.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
cm.logger.Info("Certificat émis",
|
||||
zap.String("certificate_id", certificateID),
|
||||
zap.String("course_id", courseID),
|
||||
zap.String("user_id", userID.String()),
|
||||
zap.Bool("is_passed", isPassed))
|
||||
|
||||
return certificate, nil
|
||||
}
|
||||
|
|
@ -1,479 +0,0 @@
|
|||
package education
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Tutorial représente un tutoriel vidéo
|
||||
type Tutorial struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Author string `json:"author"`
|
||||
Category string `json:"category"`
|
||||
Tags []string `json:"tags"`
|
||||
VideoURL string `json:"video_url"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
Quality VideoQuality `json:"quality"`
|
||||
Language string `json:"language"`
|
||||
IsFree bool `json:"is_free"`
|
||||
IsPublished bool `json:"is_published"`
|
||||
Views int64 `json:"views"`
|
||||
Likes int64 `json:"likes"`
|
||||
Dislikes int64 `json:"dislikes"`
|
||||
Rating float64 `json:"rating"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// VideoQuality définit la qualité de la vidéo
|
||||
type VideoQuality string
|
||||
|
||||
const (
|
||||
VideoQualityHD VideoQuality = "hd"
|
||||
VideoQuality4K VideoQuality = "4k"
|
||||
VideoQuality8K VideoQuality = "8k"
|
||||
)
|
||||
|
||||
// TutorialStep représente une étape dans un tutoriel
|
||||
type TutorialStep struct {
|
||||
ID string `json:"id"`
|
||||
TutorialID string `json:"tutorial_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Order int `json:"order"`
|
||||
Timestamp time.Duration `json:"timestamp"`
|
||||
IsFree bool `json:"is_free"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TutorialComment représente un commentaire sur un tutoriel
|
||||
type TutorialComment struct {
|
||||
ID string `json:"id"`
|
||||
TutorialID string `json:"tutorial_id"`
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Content string `json:"content"`
|
||||
Rating int `json:"rating"` // 1-5 étoiles
|
||||
IsHelpful bool `json:"is_helpful"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TutorialManager gère les tutoriels vidéo
|
||||
type TutorialManager struct {
|
||||
tutorials map[string]*Tutorial
|
||||
steps map[string][]*TutorialStep
|
||||
comments map[string][]*TutorialComment
|
||||
logger *zap.Logger
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewTutorialManager crée un nouveau gestionnaire de tutoriels
|
||||
func NewTutorialManager(logger *zap.Logger) *TutorialManager {
|
||||
return &TutorialManager{
|
||||
tutorials: make(map[string]*Tutorial),
|
||||
steps: make(map[string][]*TutorialStep),
|
||||
comments: make(map[string][]*TutorialComment),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTutorial crée un nouveau tutoriel
|
||||
func (tm *TutorialManager) CreateTutorial(ctx context.Context, title, description, author, category, videoURL, thumbnail, language string, duration time.Duration, quality VideoQuality, isFree bool, tags []string) (*Tutorial, error) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
tutorialID := uuid.New().String()
|
||||
|
||||
tutorial := &Tutorial{
|
||||
ID: tutorialID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Author: author,
|
||||
Category: category,
|
||||
Tags: tags,
|
||||
VideoURL: videoURL,
|
||||
Thumbnail: thumbnail,
|
||||
Duration: duration,
|
||||
Quality: quality,
|
||||
Language: language,
|
||||
IsFree: isFree,
|
||||
IsPublished: false,
|
||||
Views: 0,
|
||||
Likes: 0,
|
||||
Dislikes: 0,
|
||||
Rating: 0.0,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
tm.tutorials[tutorialID] = tutorial
|
||||
|
||||
tm.logger.Info("Tutoriel créé",
|
||||
zap.String("tutorial_id", tutorialID),
|
||||
zap.String("title", title),
|
||||
zap.String("author", author))
|
||||
|
||||
return tutorial, nil
|
||||
}
|
||||
|
||||
// GetTutorial récupère un tutoriel par son ID
|
||||
func (tm *TutorialManager) GetTutorial(ctx context.Context, tutorialID string) (*Tutorial, error) {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
tutorial, exists := tm.tutorials[tutorialID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("tutoriel non trouvé: %s", tutorialID)
|
||||
}
|
||||
|
||||
return tutorial, nil
|
||||
}
|
||||
|
||||
// ListTutorials liste tous les tutoriels disponibles
|
||||
func (tm *TutorialManager) ListTutorials(ctx context.Context, filters map[string]interface{}) ([]*Tutorial, error) {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
var tutorials []*Tutorial
|
||||
for _, tutorial := range tm.tutorials {
|
||||
// Appliquer les filtres si fournis
|
||||
if filters != nil {
|
||||
if category, ok := filters["category"].(string); ok && tutorial.Category != category {
|
||||
continue
|
||||
}
|
||||
if isPublished, ok := filters["is_published"].(bool); ok && tutorial.IsPublished != isPublished {
|
||||
continue
|
||||
}
|
||||
if isFree, ok := filters["is_free"].(bool); ok && tutorial.IsFree != isFree {
|
||||
continue
|
||||
}
|
||||
if language, ok := filters["language"].(string); ok && tutorial.Language != language {
|
||||
continue
|
||||
}
|
||||
if author, ok := filters["author"].(string); ok && tutorial.Author != author {
|
||||
continue
|
||||
}
|
||||
}
|
||||
tutorials = append(tutorials, tutorial)
|
||||
}
|
||||
|
||||
return tutorials, nil
|
||||
}
|
||||
|
||||
// UpdateTutorial met à jour un tutoriel
|
||||
func (tm *TutorialManager) UpdateTutorial(ctx context.Context, tutorialID string, updates map[string]interface{}) (*Tutorial, error) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
tutorial, exists := tm.tutorials[tutorialID]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("tutoriel non trouvé: %s", tutorialID)
|
||||
}
|
||||
|
||||
// Appliquer les mises à jour
|
||||
if title, ok := updates["title"].(string); ok {
|
||||
tutorial.Title = title
|
||||
}
|
||||
if description, ok := updates["description"].(string); ok {
|
||||
tutorial.Description = description
|
||||
}
|
||||
if author, ok := updates["author"].(string); ok {
|
||||
tutorial.Author = author
|
||||
}
|
||||
if category, ok := updates["category"].(string); ok {
|
||||
tutorial.Category = category
|
||||
}
|
||||
if videoURL, ok := updates["video_url"].(string); ok {
|
||||
tutorial.VideoURL = videoURL
|
||||
}
|
||||
if thumbnail, ok := updates["thumbnail"].(string); ok {
|
||||
tutorial.Thumbnail = thumbnail
|
||||
}
|
||||
if duration, ok := updates["duration"].(time.Duration); ok {
|
||||
tutorial.Duration = duration
|
||||
}
|
||||
if quality, ok := updates["quality"].(VideoQuality); ok {
|
||||
tutorial.Quality = quality
|
||||
}
|
||||
if isPublished, ok := updates["is_published"].(bool); ok {
|
||||
tutorial.IsPublished = isPublished
|
||||
}
|
||||
if tags, ok := updates["tags"].([]string); ok {
|
||||
tutorial.Tags = tags
|
||||
}
|
||||
|
||||
tutorial.UpdatedAt = time.Now()
|
||||
|
||||
tm.logger.Info("Tutoriel mis à jour",
|
||||
zap.String("tutorial_id", tutorialID),
|
||||
zap.String("title", tutorial.Title))
|
||||
|
||||
return tutorial, nil
|
||||
}
|
||||
|
||||
// DeleteTutorial supprime un tutoriel
|
||||
func (tm *TutorialManager) DeleteTutorial(ctx context.Context, tutorialID string) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
if _, exists := tm.tutorials[tutorialID]; !exists {
|
||||
return fmt.Errorf("tutoriel non trouvé: %s", tutorialID)
|
||||
}
|
||||
|
||||
delete(tm.tutorials, tutorialID)
|
||||
delete(tm.steps, tutorialID)
|
||||
delete(tm.comments, tutorialID)
|
||||
|
||||
tm.logger.Info("Tutoriel supprimé",
|
||||
zap.String("tutorial_id", tutorialID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddTutorialStep ajoute une étape à un tutoriel
|
||||
func (tm *TutorialManager) AddTutorialStep(ctx context.Context, tutorialID, title, description, content string, order int, timestamp time.Duration, isFree bool) (*TutorialStep, error) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
stepID := uuid.New().String()
|
||||
step := &TutorialStep{
|
||||
ID: stepID,
|
||||
TutorialID: tutorialID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
Content: content,
|
||||
Order: order,
|
||||
Timestamp: timestamp,
|
||||
IsFree: isFree,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
tm.steps[tutorialID] = append(tm.steps[tutorialID], step)
|
||||
|
||||
tm.logger.Info("Étape de tutoriel ajoutée",
|
||||
zap.String("tutorial_id", tutorialID),
|
||||
zap.String("step_id", stepID),
|
||||
zap.String("title", title))
|
||||
|
||||
return step, nil
|
||||
}
|
||||
|
||||
// GetTutorialSteps récupère toutes les étapes d'un tutoriel
|
||||
func (tm *TutorialManager) GetTutorialSteps(ctx context.Context, tutorialID string) ([]*TutorialStep, error) {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
steps, exists := tm.steps[tutorialID]
|
||||
if !exists {
|
||||
return []*TutorialStep{}, nil
|
||||
}
|
||||
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// AddTutorialComment ajoute un commentaire à un tutoriel
|
||||
func (tm *TutorialManager) AddTutorialComment(ctx context.Context, tutorialID, userID, username, content string, rating int) (*TutorialComment, error) {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
commentID := uuid.New().String()
|
||||
comment := &TutorialComment{
|
||||
ID: commentID,
|
||||
TutorialID: tutorialID,
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Content: content,
|
||||
Rating: rating,
|
||||
IsHelpful: false,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
tm.comments[tutorialID] = append(tm.comments[tutorialID], comment)
|
||||
|
||||
// Mettre à jour la note moyenne du tutoriel
|
||||
tm.updateTutorialRating(tutorialID)
|
||||
|
||||
tm.logger.Info("Commentaire ajouté",
|
||||
zap.String("tutorial_id", tutorialID),
|
||||
zap.String("comment_id", commentID),
|
||||
zap.String("username", username))
|
||||
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
// GetTutorialComments récupère tous les commentaires d'un tutoriel
|
||||
func (tm *TutorialManager) GetTutorialComments(ctx context.Context, tutorialID string) ([]*TutorialComment, error) {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
comments, exists := tm.comments[tutorialID]
|
||||
if !exists {
|
||||
return []*TutorialComment{}, nil
|
||||
}
|
||||
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
// IncrementViews incrémente le nombre de vues d'un tutoriel
|
||||
func (tm *TutorialManager) IncrementViews(ctx context.Context, tutorialID string) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
tutorial, exists := tm.tutorials[tutorialID]
|
||||
if !exists {
|
||||
return fmt.Errorf("tutoriel non trouvé: %s", tutorialID)
|
||||
}
|
||||
|
||||
tutorial.Views++
|
||||
tutorial.UpdatedAt = time.Now()
|
||||
|
||||
tm.logger.Debug("Vues incrémentées",
|
||||
zap.String("tutorial_id", tutorialID),
|
||||
zap.Int64("views", tutorial.Views))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LikeTutorial ajoute un like à un tutoriel
|
||||
func (tm *TutorialManager) LikeTutorial(ctx context.Context, tutorialID string) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
tutorial, exists := tm.tutorials[tutorialID]
|
||||
if !exists {
|
||||
return fmt.Errorf("tutoriel non trouvé: %s", tutorialID)
|
||||
}
|
||||
|
||||
tutorial.Likes++
|
||||
tutorial.UpdatedAt = time.Now()
|
||||
|
||||
tm.logger.Debug("Like ajouté",
|
||||
zap.String("tutorial_id", tutorialID),
|
||||
zap.Int64("likes", tutorial.Likes))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DislikeTutorial ajoute un dislike à un tutoriel
|
||||
func (tm *TutorialManager) DislikeTutorial(ctx context.Context, tutorialID string) error {
|
||||
tm.mu.Lock()
|
||||
defer tm.mu.Unlock()
|
||||
|
||||
tutorial, exists := tm.tutorials[tutorialID]
|
||||
if !exists {
|
||||
return fmt.Errorf("tutoriel non trouvé: %s", tutorialID)
|
||||
}
|
||||
|
||||
tutorial.Dislikes++
|
||||
tutorial.UpdatedAt = time.Now()
|
||||
|
||||
tm.logger.Debug("Dislike ajouté",
|
||||
zap.String("tutorial_id", tutorialID),
|
||||
zap.Int64("dislikes", tutorial.Dislikes))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateTutorialRating met à jour la note moyenne d'un tutoriel
|
||||
func (tm *TutorialManager) updateTutorialRating(tutorialID string) {
|
||||
comments, exists := tm.comments[tutorialID]
|
||||
if !exists || len(comments) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var totalRating int
|
||||
var ratedComments int
|
||||
|
||||
for _, comment := range comments {
|
||||
if comment.Rating > 0 {
|
||||
totalRating += comment.Rating
|
||||
ratedComments++
|
||||
}
|
||||
}
|
||||
|
||||
if ratedComments > 0 {
|
||||
tutorial, exists := tm.tutorials[tutorialID]
|
||||
if exists {
|
||||
tutorial.Rating = float64(totalRating) / float64(ratedComments)
|
||||
tutorial.UpdatedAt = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SearchTutorials recherche des tutoriels par mots-clés
|
||||
func (tm *TutorialManager) SearchTutorials(ctx context.Context, query string, filters map[string]interface{}) ([]*Tutorial, error) {
|
||||
tm.mu.RLock()
|
||||
defer tm.mu.RUnlock()
|
||||
|
||||
var results []*Tutorial
|
||||
query = fmt.Sprintf("%%%s%%", query) // Recherche LIKE
|
||||
|
||||
for _, tutorial := range tm.tutorials {
|
||||
// Vérifier si le tutoriel correspond à la recherche
|
||||
matches := false
|
||||
if contains(tutorial.Title, query) || contains(tutorial.Description, query) || contains(tutorial.Author, query) {
|
||||
matches = true
|
||||
}
|
||||
|
||||
// Vérifier les tags
|
||||
for _, tag := range tutorial.Tags {
|
||||
if contains(tag, query) {
|
||||
matches = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !matches {
|
||||
continue
|
||||
}
|
||||
|
||||
// Appliquer les filtres si fournis
|
||||
if filters != nil {
|
||||
if category, ok := filters["category"].(string); ok && tutorial.Category != category {
|
||||
continue
|
||||
}
|
||||
if isPublished, ok := filters["is_published"].(bool); ok && tutorial.IsPublished != isPublished {
|
||||
continue
|
||||
}
|
||||
if isFree, ok := filters["is_free"].(bool); ok && tutorial.IsFree != isFree {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, tutorial)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// contains vérifie si une chaîne contient une sous-chaîne (insensible à la casse)
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr ||
|
||||
(len(s) > len(substr) && (s[:len(substr)] == substr ||
|
||||
s[len(s)-len(substr):] == substr ||
|
||||
containsSubstring(s, substr))))
|
||||
}
|
||||
|
||||
// containsSubstring vérifie si une chaîne contient une sous-chaîne
|
||||
func containsSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Loading…
Reference in a new issue