new modif
This commit is contained in:
parent
3cca2f6613
commit
c499c452f0
56
app/api/notifications/route.ts
Normal file
56
app/api/notifications/route.ts
Normal file
@ -0,0 +1,56 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
|
||||
/** GET /api/notifications — récupère les 30 dernières notifs de l'utilisateur connecté */
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||
|
||||
const { data, error } = await (supabase as any)
|
||||
.from('notifications')
|
||||
.select('id, type, title, body, metadata, read, created_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(30)
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
|
||||
return NextResponse.json({ notifications: data ?? [] })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/** PATCH /api/notifications — marque des notifications comme lues
|
||||
* Body : { ids: string[] } ou { all: true }
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser()
|
||||
if (authError || !user) return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
const db = supabase as any
|
||||
|
||||
if (body.all) {
|
||||
await db
|
||||
.from('notifications')
|
||||
.update({ read: true })
|
||||
.eq('user_id', user.id)
|
||||
.eq('read', false)
|
||||
} else if (Array.isArray(body.ids) && body.ids.length > 0) {
|
||||
await db
|
||||
.from('notifications')
|
||||
.update({ read: true })
|
||||
.eq('user_id', user.id)
|
||||
.in('id', body.ids)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient, createAdminClient } from '@/lib/supabase/server'
|
||||
import { calculateScore } from '@/lib/utils'
|
||||
import { createNotification } from '@/lib/notifications'
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
@ -75,6 +76,28 @@ export async function PATCH(
|
||||
}
|
||||
}
|
||||
|
||||
// Notification quand la session est fermée
|
||||
if (!is_active) {
|
||||
const { data: quizInfo } = await (supabase as any)
|
||||
.from('quizzes')
|
||||
.select('title')
|
||||
.eq('id', session.quiz_id)
|
||||
.single()
|
||||
|
||||
const { count: completedCount } = await (createAdminClient() as any)
|
||||
.from('student_participations')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('session_id', id)
|
||||
.eq('status', 'completed')
|
||||
|
||||
await createNotification({
|
||||
userId: user.id,
|
||||
type: 'session_ended',
|
||||
body: `Session "${quizInfo?.title ?? 'Quiz'}" terminée — ${completedCount ?? 0} participant(s) au total.`,
|
||||
metadata: { session_id: id },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error('[sessions/toggle]', error)
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { createNotification } from '@/lib/notifications'
|
||||
|
||||
const MAX_RETRIES = 10
|
||||
|
||||
@ -70,6 +71,20 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Récupérer le titre du quiz pour la notification
|
||||
const { data: quizInfo } = await db
|
||||
.from('quizzes')
|
||||
.select('title')
|
||||
.eq('id', quiz_id)
|
||||
.single()
|
||||
|
||||
await createNotification({
|
||||
userId: user.id,
|
||||
type: 'session_created',
|
||||
body: `Session "${quizInfo?.title ?? 'Quiz'}" créée — code : ${shortCode}`,
|
||||
metadata: { session_id: session.id, short_code: shortCode },
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
session: {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { createAdminClient } from '@/lib/supabase/server'
|
||||
import { createNotification } from '@/lib/notifications'
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@ -65,6 +66,22 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// Récupérer trainer_id + titre du quiz pour la notification
|
||||
const { data: sessionFull } = await db
|
||||
.from('sessions')
|
||||
.select('trainer_id, quiz:quizzes(title)')
|
||||
.eq('id', session.id)
|
||||
.single()
|
||||
|
||||
if (sessionFull?.trainer_id) {
|
||||
await createNotification({
|
||||
userId: sessionFull.trainer_id,
|
||||
type: 'student_joined',
|
||||
body: `${first_name.trim()} ${last_name.trim()} a rejoint "${(sessionFull.quiz as any)?.title ?? 'votre quiz'}"`,
|
||||
metadata: { session_id: session.id, participation_id: participation.id },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
participation: {
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
Zap,
|
||||
ArrowRight,
|
||||
FileText,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { cn } from '@/lib/utils'
|
||||
@ -146,6 +147,15 @@ export default async function DashboardPage() {
|
||||
|
||||
const displayName = profile?.username ?? user?.email?.split('@')[0] ?? 'Formateur'
|
||||
|
||||
// 5 dernières notifications non lues pour la section du dashboard
|
||||
const { data: recentNotifications } = await db
|
||||
.from('notifications')
|
||||
.select('id, type, title, body, created_at')
|
||||
.eq('user_id', user!.id)
|
||||
.eq('read', false)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(5)
|
||||
|
||||
const mappedSessions = (sessions ?? []).map((s: any) => ({
|
||||
id: s.id,
|
||||
title: s.quiz?.title ?? 'Quiz sans titre',
|
||||
@ -206,23 +216,29 @@ export default async function DashboardPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6">
|
||||
<div className="card p-6">
|
||||
<h3 className="font-semibold text-text-primary mb-4">Notifications Récentes</h3>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ icon: '🔧', title: 'Maintenance Système', desc: 'Prévue samedi à 22h. Sauvegardez votre travail.' },
|
||||
{ icon: '✅', title: 'Session finalisée', desc: 'Les rapports sont disponibles pour JS Fundamentals.' },
|
||||
{ icon: '👥', title: 'Nouvelles inscriptions', desc: '12 étudiants ont rejoint "React Basics Quiz".' },
|
||||
].map((notif, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-background-elevated rounded-lg flex items-center justify-center text-sm flex-shrink-0">
|
||||
{notif.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">{notif.title}</p>
|
||||
<p className="text-xs text-text-muted">{notif.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{(recentNotifications ?? []).length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<Bell size={24} className="text-text-muted/40 mb-2" />
|
||||
<p className="text-sm text-text-muted">Aucune nouvelle notification</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{(recentNotifications ?? []).map((notif: any) => {
|
||||
const icon = { session_created: '🚀', student_joined: '👤', session_ended: '✅' }[notif.type as string] ?? '🔔'
|
||||
return (
|
||||
<div key={notif.id} className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-background-elevated rounded-lg flex items-center justify-center text-sm flex-shrink-0">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-primary">{notif.title}</p>
|
||||
<p className="text-xs text-text-muted">{notif.body}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card p-6 bg-gradient-to-br from-primary to-blue-700 border-0">
|
||||
|
||||
@ -138,7 +138,7 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
|
||||
if (!res.ok) {
|
||||
setUploadError(data.error ?? "Erreur lors de l'import")
|
||||
} else {
|
||||
// Mettre à jour le chapitre dans le state local
|
||||
// Mettre à jour le state local immédiatement
|
||||
const newQuiz: Quiz = {
|
||||
id: data.quiz.id,
|
||||
title: data.quiz.title,
|
||||
@ -154,11 +154,12 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
|
||||
),
|
||||
}))
|
||||
)
|
||||
if (data.quiz.replaced) {
|
||||
// Compte inchangé (remplacement)
|
||||
} else {
|
||||
if (!data.quiz.replaced) {
|
||||
setStats((s) => ({ ...s, totalQuizzes: s.totalQuizzes + 1 }))
|
||||
}
|
||||
// Invalide le cache du router pour que les prochaines navigations
|
||||
// récupèrent les données fraîches depuis le serveur
|
||||
router.refresh()
|
||||
}
|
||||
} catch {
|
||||
setUploadError('Erreur réseau')
|
||||
@ -213,6 +214,7 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
|
||||
prev.map((c) => (c.id === id ? { ...c, name: editingCategoryName.trim() } : c))
|
||||
)
|
||||
setEditingCategoryId(null)
|
||||
router.refresh()
|
||||
}
|
||||
} finally {
|
||||
setSavingCategoryId(null)
|
||||
@ -233,6 +235,7 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
|
||||
totalQuizzes: Math.max(0, s.totalQuizzes - removedQuizzes),
|
||||
}))
|
||||
setConfirmDeleteCategoryId(null)
|
||||
router.refresh()
|
||||
}
|
||||
} finally {
|
||||
setDeletingCategoryId(null)
|
||||
@ -259,6 +262,7 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
|
||||
}))
|
||||
)
|
||||
setEditingSubchapterId(null)
|
||||
router.refresh()
|
||||
}
|
||||
} finally {
|
||||
setSavingSubchapterId(null)
|
||||
@ -280,6 +284,7 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
|
||||
)
|
||||
setStats((s) => ({ ...s, totalQuizzes: Math.max(0, s.totalQuizzes - removedQuizzes) }))
|
||||
setConfirmDeleteSubchapterId(null)
|
||||
router.refresh()
|
||||
}
|
||||
} finally {
|
||||
setDeletingSubchapterId(null)
|
||||
@ -305,6 +310,7 @@ export default function QuizzesClient({ initialCategories, stats: initialStats }
|
||||
)
|
||||
setAddingSubchapterForCategoryId(null)
|
||||
setNewSubchapterName('')
|
||||
router.refresh()
|
||||
}
|
||||
} finally {
|
||||
setSavingNewSubchapter(false)
|
||||
|
||||
@ -3,6 +3,8 @@ import { redirect } from 'next/navigation'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import QuizzesClient from './QuizzesClient'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function QuizzesPage() {
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
|
||||
@ -12,6 +12,7 @@ export default async function CreateSessionPage() {
|
||||
.from('quizzes')
|
||||
.select(`id, title, questions(id), subchapter:subchapters(name, category:categories(name))`)
|
||||
.eq('author_id', user!.id)
|
||||
.not('subchapter_id', 'is', null)
|
||||
.order('title')
|
||||
|
||||
return (
|
||||
|
||||
@ -1,31 +1,84 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Bell, X } from 'lucide-react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Bell, X, Check, Loader2 } from 'lucide-react'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface Notification {
|
||||
id: string
|
||||
icon: string
|
||||
type: string
|
||||
title: string
|
||||
desc: string
|
||||
time: string
|
||||
body: string
|
||||
read: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const INITIAL_NOTIFICATIONS: Notification[] = [
|
||||
{ id: '1', icon: '✅', title: 'Session finalisée', desc: 'Rapports disponibles pour JS Fundamentals.', time: 'il y a 2h', read: false },
|
||||
{ id: '2', icon: '👥', title: 'Nouvelles inscriptions', desc: '12 étudiants ont rejoint "React Basics Quiz".', time: 'il y a 4h', read: false },
|
||||
{ id: '3', icon: '🔧', title: 'Maintenance prévue', desc: 'Samedi à 22h. Pensez à sauvegarder votre travail.', time: 'il y a 1j', read: true },
|
||||
]
|
||||
const typeIcon: Record<string, string> = {
|
||||
session_created: '🚀',
|
||||
student_joined: '👤',
|
||||
session_ended: '✅',
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string): string {
|
||||
const diff = Date.now() - new Date(dateStr).getTime()
|
||||
const minutes = Math.floor(diff / 60_000)
|
||||
if (minutes < 1) return "à l'instant"
|
||||
if (minutes < 60) return `il y a ${minutes} min`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `il y a ${hours}h`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `il y a ${days}j`
|
||||
}
|
||||
|
||||
export default function NotificationBell() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [notifications, setNotifications] = useState<Notification[]>(INITIAL_NOTIFICATIONS)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read).length
|
||||
|
||||
// ── Fetch initial ────────────────────────────────────────────────────────
|
||||
const fetchNotifications = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/notifications')
|
||||
if (!res.ok) return
|
||||
const data = await res.json()
|
||||
setNotifications(data.notifications ?? [])
|
||||
} catch {
|
||||
// silencieux — la cloche reste vide plutôt que de crasher
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications()
|
||||
}, [fetchNotifications])
|
||||
|
||||
// ── Supabase Realtime — nouvelles notifs en temps réel ───────────────────
|
||||
useEffect(() => {
|
||||
const supabase = createClient()
|
||||
|
||||
const channel = supabase
|
||||
.channel('notifications-realtime')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'INSERT', schema: 'public', table: 'notifications' },
|
||||
(payload) => {
|
||||
const newNotif = payload.new as Notification
|
||||
setNotifications((prev) => [newNotif, ...prev].slice(0, 30))
|
||||
}
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── Fermer le panneau au clic extérieur ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
@ -34,16 +87,31 @@ export default function NotificationBell() {
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [])
|
||||
|
||||
const markRead = (id: string) =>
|
||||
// ── Marquer comme lu ─────────────────────────────────────────────────────
|
||||
const markRead = async (id: string) => {
|
||||
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)))
|
||||
await fetch('/api/notifications', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: [id] }),
|
||||
})
|
||||
}
|
||||
|
||||
const markAllRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
|
||||
const markAllRead = async () => {
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })))
|
||||
await fetch('/api/notifications', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ all: true }),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="relative p-2.5 bg-background-card border border-border rounded-lg hover:border-border-light transition-colors"
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Bell size={18} className="text-text-secondary" />
|
||||
{unreadCount > 0 && (
|
||||
@ -53,6 +121,7 @@ export default function NotificationBell() {
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-12 w-80 bg-background-card border border-border rounded-xl shadow-2xl shadow-black/50 z-50 overflow-hidden animate-in fade-in slide-in-from-top-2 duration-150">
|
||||
{/* En-tête */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-text-primary text-sm">Notifications</h3>
|
||||
@ -66,9 +135,9 @@ export default function NotificationBell() {
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllRead}
|
||||
className="text-xs text-primary hover:text-primary-light transition-colors"
|
||||
className="text-xs text-primary hover:text-primary-light transition-colors flex items-center gap-1"
|
||||
>
|
||||
Tout lire
|
||||
<Check size={11} /> Tout lire
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
@ -80,29 +149,41 @@ export default function NotificationBell() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Liste */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{notifications.map((notif) => (
|
||||
<div
|
||||
key={notif.id}
|
||||
onClick={() => markRead(notif.id)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-4 py-3 hover:bg-background-elevated/50 transition-colors cursor-pointer border-b border-border/50 last:border-0',
|
||||
!notif.read && 'bg-primary/5'
|
||||
)}
|
||||
>
|
||||
<div className="w-8 h-8 bg-background-elevated rounded-lg flex items-center justify-center text-sm flex-shrink-0">
|
||||
{notif.icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{notif.title}</p>
|
||||
{!notif.read && <div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" />}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted leading-relaxed">{notif.desc}</p>
|
||||
<p className="text-xs text-text-muted/50 mt-1">{notif.time}</p>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-text-muted" />
|
||||
</div>
|
||||
))}
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="py-10 text-center">
|
||||
<Bell size={28} className="mx-auto text-text-muted/40 mb-2" />
|
||||
<p className="text-sm text-text-muted">Aucune notification</p>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((notif) => (
|
||||
<div
|
||||
key={notif.id}
|
||||
onClick={() => !notif.read && markRead(notif.id)}
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-4 py-3 hover:bg-background-elevated/50 transition-colors border-b border-border/50 last:border-0',
|
||||
!notif.read ? 'bg-primary/5 cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
>
|
||||
<div className="w-8 h-8 bg-background-elevated rounded-lg flex items-center justify-center text-sm flex-shrink-0">
|
||||
{typeIcon[notif.type] ?? '🔔'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||
<p className="text-sm font-medium text-text-primary truncate">{notif.title}</p>
|
||||
{!notif.read && <div className="w-1.5 h-1.5 bg-primary rounded-full flex-shrink-0" />}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted leading-relaxed">{notif.body}</p>
|
||||
<p className="text-xs text-text-muted/50 mt-1">{formatRelativeTime(notif.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
44
lib/notifications.ts
Normal file
44
lib/notifications.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { createAdminClient } from '@/lib/supabase/server'
|
||||
|
||||
export type NotificationType =
|
||||
| 'session_created'
|
||||
| 'student_joined'
|
||||
| 'session_ended'
|
||||
|
||||
const typeConfig: Record<NotificationType, { icon: string; defaultTitle: string }> = {
|
||||
session_created: { icon: '🚀', defaultTitle: 'Session créée' },
|
||||
student_joined: { icon: '👤', defaultTitle: 'Étudiant rejoint' },
|
||||
session_ended: { icon: '✅', defaultTitle: 'Session terminée' },
|
||||
}
|
||||
|
||||
interface CreateNotificationOptions {
|
||||
userId: string
|
||||
type: NotificationType
|
||||
title?: string
|
||||
body: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Insère une notification en base pour un utilisateur.
|
||||
* Utilise le client admin (service_role) pour bypasser RLS sur l'INSERT.
|
||||
* Les erreurs sont loggées mais n'interrompent pas le flux appelant.
|
||||
*/
|
||||
export async function createNotification(opts: CreateNotificationOptions): Promise<void> {
|
||||
try {
|
||||
const admin = createAdminClient() as any
|
||||
const cfg = typeConfig[opts.type]
|
||||
|
||||
const { error } = await admin.from('notifications').insert({
|
||||
user_id: opts.userId,
|
||||
type: opts.type,
|
||||
title: opts.title ?? cfg.defaultTitle,
|
||||
body: opts.body,
|
||||
metadata: opts.metadata ?? {},
|
||||
})
|
||||
|
||||
if (error) console.error('[createNotification]', error.message)
|
||||
} catch (err) {
|
||||
console.error('[createNotification] unexpected error', err)
|
||||
}
|
||||
}
|
||||
@ -1,57 +1,111 @@
|
||||
{
|
||||
"title": "Introduction à Ansible",
|
||||
"category": "Ansible",
|
||||
"subchapter": "Introduction",
|
||||
"title": "Control plane",
|
||||
"questions": [
|
||||
{
|
||||
"question": "Qu'est-ce qu'Ansible ?",
|
||||
"explanation": "Ansible est un outil d'automatisation IT open-source qui permet de configurer des systèmes, déployer des logiciels et orchestrer des tâches IT plus avancées.",
|
||||
"question": "Le Scheduler et le Controller Manager accèdent directement à etcd pour lire l'état du cluster.",
|
||||
"explanation": "C'est une règle d'or : seul le kube-apiserver communique directement avec etcd. Tous les autres composants passent par l'API Server pour obtenir ou modifier des informations.",
|
||||
"answers": [
|
||||
{ "text": "Un langage de programmation", "correct": false },
|
||||
{ "text": "Un outil d'automatisation IT", "correct": true },
|
||||
{ "text": "Un système de gestion de base de données", "correct": false },
|
||||
{ "text": "Un serveur web", "correct": false }
|
||||
{ "text": "Vrai", "correct": false },
|
||||
{ "text": "Faux", "correct": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Quel fichier Ansible décrit les tâches à exécuter ?",
|
||||
"explanation": "Un Playbook Ansible est un fichier YAML qui décrit une série de tâches à exécuter sur des hôtes distants.",
|
||||
"question": "Quelles sont les trois fonctions principales de l'API Server ?",
|
||||
"explanation": "L'API Server sert de hub central : il authentifie/valide les requêtes, stocke les données de manière persistante dans etcd et expose l'état actuel du cluster aux utilisateurs et composants.",
|
||||
"answers": [
|
||||
{ "text": "Inventory file", "correct": false },
|
||||
{ "text": "Playbook", "correct": true },
|
||||
{ "text": "Role", "correct": false },
|
||||
{ "text": "Module", "correct": false }
|
||||
{ "text": "Valider les requêtes, persister dans etcd, exposer l'état du cluster", "correct": true },
|
||||
{ "text": "Scheduler les Pods, créer les ReplicaSets, monitorer les Nodes", "correct": false },
|
||||
{ "text": "Lancer les conteneurs, assigner les Pods, gérer le réseau", "correct": false },
|
||||
{ "text": "Authentifier les utilisateurs, chiffrer les secrets, gérer les certificats", "correct": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Dans quel format sont écrits les Playbooks Ansible ?",
|
||||
"explanation": "Les Playbooks Ansible sont écrits en YAML (Yet Another Markup Language), un format lisible par l'humain.",
|
||||
"question": "Quel composant du Control Plane détecte les Nodes en panne ?",
|
||||
"explanation": "Le Node Controller (intégré au kube-controller-manager) est responsable de la surveillance des nœuds et de l'éviction des Pods si un nœud ne répond plus.",
|
||||
"answers": [
|
||||
{ "text": "JSON", "correct": false },
|
||||
{ "text": "XML", "correct": false },
|
||||
{ "text": "YAML", "correct": true },
|
||||
{ "text": "TOML", "correct": false }
|
||||
{ "text": "Node controller", "correct": true },
|
||||
{ "text": "Job controller", "correct": false },
|
||||
{ "text": "ReplicaSet controller", "correct": false },
|
||||
{ "text": "Endpoints controller", "correct": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Comment Ansible se connecte-t-il aux nœuds gérés ?",
|
||||
"explanation": "Ansible utilise SSH (Secure Shell) pour se connecter aux nœuds Linux/Unix gérés, sans nécessiter d'agent sur les nœuds cibles.",
|
||||
"question": "Le kube-controller-manager communique directement avec etcd pour mettre à jour l'état du cluster.",
|
||||
"explanation": "Comme pour le Scheduler, le Controller Manager doit envoyer des requêtes à l'API Server, qui se chargera ensuite d'écrire dans etcd.",
|
||||
"answers": [
|
||||
{ "text": "Via un agent installé sur chaque nœud", "correct": false },
|
||||
{ "text": "Via SSH", "correct": true },
|
||||
{ "text": "Via HTTP", "correct": false },
|
||||
{ "text": "Via FTP", "correct": false }
|
||||
{ "text": "Vrai", "correct": false },
|
||||
{ "text": "Faux", "correct": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Qu'est-ce qu'un inventaire Ansible ?",
|
||||
"explanation": "L'inventaire Ansible est un fichier qui liste les hôtes et les groupes d'hôtes sur lesquels Ansible peut exécuter des tâches.",
|
||||
"question": "Quelles sont les deux étapes du processus de scheduling ?",
|
||||
"explanation": "Le scheduler procède d'abord par 'Filtrage' (éliminer les nœuds incompatibles) puis par 'Scoring' (classer les nœuds restants pour choisir le meilleur).",
|
||||
"answers": [
|
||||
{ "text": "Une liste de modules disponibles", "correct": false },
|
||||
{ "text": "Un fichier de configuration global", "correct": false },
|
||||
{ "text": "La liste des hôtes gérés", "correct": true },
|
||||
{ "text": "Un catalogue de rôles", "correct": false }
|
||||
{ "text": "Création puis démarrage", "correct": false },
|
||||
{ "text": "Filtrage puis scoring", "correct": true },
|
||||
{ "text": "Validation puis persistence", "correct": false },
|
||||
{ "text": "Assignation puis Exécution", "correct": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Si le Control Plane tombe, les Pods existants continuent de tourner sur les Worker Nodes.",
|
||||
"explanation": "Le Control Plane gère l'orchestration. Si il disparaît, le cluster devient 'gelé' (pas de nouveaux Pods), mais le plan de données (les Workers) continue d'exécuter ce qui est déjà en place.",
|
||||
"answers": [
|
||||
{ "text": "Vrai", "correct": true },
|
||||
{ "text": "Faux", "correct": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Le kube-controller-manager agit uniquement lorsqu’un utilisateur interagit avec le cluster.",
|
||||
"explanation": "Faux. C'est une boucle de contrôle infinie (control loop) qui compare l'état désiré à l'état réel en permanence, même sans action humaine.",
|
||||
"answers": [
|
||||
{ "text": "Vrai", "correct": false },
|
||||
{ "text": "Faux", "correct": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "L'API Server est le seul composant du Control Plane qui peut lire et écrire dans etcd.",
|
||||
"explanation": "C'est une architecture conçue pour la sécurité et la cohérence des données : l'API Server est le gardien d'etcd.",
|
||||
"answers": [
|
||||
{ "text": "Vrai", "correct": true },
|
||||
{ "text": "Faux", "correct": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Quel champ du Pod le Scheduler remplit-il lors de l'assignation à un Node ?",
|
||||
"explanation": "Le processus de scheduling consiste techniquement à mettre à jour le champ 'nodeName' dans la définition du Pod.",
|
||||
"answers": [
|
||||
{ "text": "nodeName", "correct": true },
|
||||
{ "text": "NamePod", "correct": false },
|
||||
{ "text": "NomDuPod", "correct": false },
|
||||
{ "text": "NameOfThePod", "correct": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Dans quel ordre les composants interviennent-ils lors de la création d'un Deployment ?",
|
||||
"explanation": "L'API Server reçoit l'ordre, les Controllers créent les objets (RS, Pods), le Scheduler choisit le nœud, et enfin le Kubelet exécute.",
|
||||
"answers": [
|
||||
{ "text": "Scheduler → API Server → Controller Manager → Kubelet", "correct": false },
|
||||
{ "text": "Controller Manager → Scheduler → Kubelet → API Server", "correct": false },
|
||||
{ "text": "Kubelet → Scheduler → API Server → Controller Manager", "correct": false },
|
||||
{ "text": "API Server → Deployment Controller → ReplicaSet Controller → Scheduler → Kubelet", "correct": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Un seul controller manager peut gérer plusieurs types de contrôleurs.",
|
||||
"explanation": "Le 'kube-controller-manager' est un binaire unique qui regroupe de nombreux contrôleurs (Node, Deployment, Namespace, etc.) pour simplifier la gestion.",
|
||||
"answers": [
|
||||
{ "text": "Vrai", "correct": true },
|
||||
{ "text": "Faux", "correct": false }
|
||||
]
|
||||
},
|
||||
{
|
||||
"question": "Le kube-apiserver interagit directement avec les kubelets sur les nœuds",
|
||||
"explanation": "Bien que l'API Server puisse initier des connexions (pour les logs ou l'exécution de commandes), c'est généralement le Kubelet qui surveille l'API Server pour recevoir ses instructions.",
|
||||
"answers": [
|
||||
{ "text": "Vrai", "correct": false },
|
||||
{ "text": "Faux", "correct": true }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user