fix(notification+vlog upload)
Some checks failed
Deploy to Development / build-and-deploy (push) Failing after 46s
Deploy to Production / build-and-deploy (push) Successful in 1m47s

This commit is contained in:
EvanChal
2026-01-27 02:39:51 +01:00
parent 658b7a9dda
commit f33dfd5ab7
20 changed files with 499 additions and 262 deletions

View File

@@ -9,6 +9,7 @@ from models.notification import Notification, NotificationType
from schemas.event import EventCreate, EventUpdate, EventResponse, ParticipationUpdate from schemas.event import EventCreate, EventUpdate, EventResponse, ParticipationUpdate
from utils.security import get_current_active_user from utils.security import get_current_active_user
from utils.email import send_event_notification from utils.email import send_event_notification
from utils.push_service import send_push_to_user
router = APIRouter() router = APIRouter()
@@ -48,15 +49,22 @@ async def create_event(
# Create notification # Create notification
if user.id != current_user.id: if user.id != current_user.id:
notif_title = f"Invitation à un événement privé: {event.title}"
notif_message = f"{current_user.full_name} vous a invité à un événement privé"
notif_link = f"/events/{event.id}"
notification = Notification( notification = Notification(
user_id=user.id, user_id=user.id,
type=NotificationType.EVENT_INVITATION, type=NotificationType.EVENT_INVITATION,
title=f"Invitation à un événement privé: {event.title}", title=notif_title,
message=f"{current_user.full_name} vous a invité à un événement privé", message=notif_message,
link=f"/events/{event.id}" link=notif_link
) )
db.add(notification) db.add(notification)
# Send push notification
send_push_to_user(db, user.id, notif_title, notif_message, notif_link)
# Send email notification # Send email notification
try: try:
send_event_notification(user.email, event) send_event_notification(user.email, event)
@@ -75,15 +83,22 @@ async def create_event(
# Create notification # Create notification
if user.id != current_user.id: if user.id != current_user.id:
notif_title = f"Nouvel événement: {event.title}"
notif_message = f"{current_user.full_name} a créé un nouvel événement"
notif_link = f"/events/{event.id}"
notification = Notification( notification = Notification(
user_id=user.id, user_id=user.id,
type=NotificationType.EVENT_INVITATION, type=NotificationType.EVENT_INVITATION,
title=f"Nouvel événement: {event.title}", title=notif_title,
message=f"{current_user.full_name} a créé un nouvel événement", message=notif_message,
link=f"/events/{event.id}" link=notif_link
) )
db.add(notification) db.add(notification)
# Send push notification
send_push_to_user(db, user.id, notif_title, notif_message, notif_link)
# Send email notification # Send email notification
try: try:
send_event_notification(user.email, event) send_event_notification(user.email, event)
@@ -309,15 +324,22 @@ async def invite_users_to_event(
db.add(participation) db.add(participation)
# Créer une notification # Créer une notification
notif_title = f"Invitation à un événement privé: {event.title}"
notif_message = f"{current_user.full_name} vous a invité à un événement privé"
notif_link = f"/events/{event.id}"
notification = Notification( notification = Notification(
user_id=user.id, user_id=user.id,
type=NotificationType.EVENT_INVITATION, type=NotificationType.EVENT_INVITATION,
title=f"Invitation à un événement privé: {event.title}", title=notif_title,
message=f"{current_user.full_name} vous a invité à un événement privé", message=notif_message,
link=f"/events/{event.id}" link=notif_link
) )
db.add(notification) db.add(notification)
# Send push notification
send_push_to_user(db, user.id, notif_title, notif_message, notif_link)
# Envoyer un email # Envoyer un email
try: try:
send_event_notification(user.email, event) send_event_notification(user.email, event)

View File

@@ -0,0 +1,67 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from config.database import get_db
from config.settings import settings
from models.notification import PushSubscription
from models.user import User
from schemas.notification import PushSubscriptionCreate, VapidPublicKeyResponse
from utils.security import get_current_active_user
router = APIRouter()
@router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
async def get_vapid_public_key(current_user: User = Depends(get_current_active_user)):
"""Get the VAPID public key for push notifications."""
if not settings.VAPID_PUBLIC_KEY:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="VAPID keys not configured on server"
)
return {"public_key": settings.VAPID_PUBLIC_KEY}
@router.post("/subscribe")
async def subscribe_push(
subscription: PushSubscriptionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Subscribe to push notifications."""
# Check if subscription already exists
existing = db.query(PushSubscription).filter(
PushSubscription.endpoint == subscription.endpoint
).first()
if existing:
# Update existing subscription
existing.user_id = current_user.id
existing.p256dh = subscription.keys.p256dh
existing.auth = subscription.keys.auth
db.commit()
return {"message": "Subscription updated"}
# Create new subscription
new_sub = PushSubscription(
user_id=current_user.id,
endpoint=subscription.endpoint,
p256dh=subscription.keys.p256dh,
auth=subscription.keys.auth
)
db.add(new_sub)
db.commit()
return {"message": "Subscribed successfully"}
@router.delete("/unsubscribe")
async def unsubscribe_push(
endpoint: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Unsubscribe from push notifications."""
db.query(PushSubscription).filter(
PushSubscription.endpoint == endpoint
).delete()
db.commit()
return {"message": "Unsubscribed successfully"}

View File

@@ -13,6 +13,7 @@ from schemas.vlog import VlogCreate, VlogUpdate, VlogResponse, VlogCommentCreate
from utils.security import get_current_active_user from utils.security import get_current_active_user
from utils.video_utils import generate_video_thumbnail, get_video_duration from utils.video_utils import generate_video_thumbnail, get_video_duration
from utils.settings_service import SettingsService from utils.settings_service import SettingsService
from utils.push_service import send_push_to_user
router = APIRouter() router = APIRouter()
@@ -254,6 +255,9 @@ async def upload_vlog_video(
"""Upload a vlog video.""" """Upload a vlog video."""
# Validate video file # Validate video file
if not SettingsService.is_file_type_allowed(db, video.content_type or "unknown"): if not SettingsService.is_file_type_allowed(db, video.content_type or "unknown"):
# Fallback check for common video types if content_type is generic application/octet-stream
filename = video.filename.lower()
if not (filename.endswith('.mp4') or filename.endswith('.mov') or filename.endswith('.webm') or filename.endswith('.mkv')):
allowed_types = SettingsService.get_setting(db, "allowed_video_types", allowed_types = SettingsService.get_setting(db, "allowed_video_types",
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"]) ["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
raise HTTPException( raise HTTPException(
@@ -335,18 +339,25 @@ async def upload_vlog_video(
# Create notifications for all active users (except the creator) # Create notifications for all active users (except the creator)
users = db.query(User).filter(User.is_active == True).all() users = db.query(User).filter(User.is_active == True).all()
notif_title = "Nouveau vlog"
notif_message = f"{current_user.full_name} a publié un nouveau vlog : {vlog.title}"
notif_link = f"/vlogs/{vlog.id}"
for user in users: for user in users:
if user.id != current_user.id: if user.id != current_user.id:
notification = Notification( notification = Notification(
user_id=user.id, user_id=user.id,
type=NotificationType.NEW_VLOG, type=NotificationType.NEW_VLOG,
title="Nouveau vlog", title=notif_title,
message=f"{current_user.full_name} a publié un nouveau vlog : {vlog.title}", message=notif_message,
link=f"/vlogs/{vlog.id}", link=notif_link,
is_read=False is_read=False
) )
db.add(notification) db.add(notification)
# Envoyer la notification push
send_push_to_user(db, user.id, notif_title, notif_message, notif_link)
db.commit() db.commit()
return format_vlog_response(vlog, db, current_user.id) return format_vlog_response(vlog, db, current_user.id)

View File

@@ -8,7 +8,7 @@ import mimetypes
from config.settings import settings from config.settings import settings
from config.database import engine, Base from config.database import engine, Base
from api.routers import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings as settings_router, information, tickets from api.routers import auth, users, events, albums, posts, vlogs, stats, admin, notifications, settings as settings_router, information, tickets, push
from utils.init_db import init_database from utils.init_db import init_database
from utils.settings_service import SettingsService from utils.settings_service import SettingsService
from config.database import SessionLocal from config.database import SessionLocal
@@ -294,6 +294,7 @@ app.include_router(vlogs.router, prefix="/api/vlogs", tags=["Vlogs"])
app.include_router(stats.router, prefix="/api/stats", tags=["Statistics"]) app.include_router(stats.router, prefix="/api/stats", tags=["Statistics"])
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"]) app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"])
app.include_router(push.router, prefix="/api/push", tags=["Push Notifications"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"]) app.include_router(settings_router.router, prefix="/api/settings", tags=["Settings"])
app.include_router(information.router, prefix="/api/information", tags=["Information"]) app.include_router(information.router, prefix="/api/information", tags=["Information"])
app.include_router(tickets.router, prefix="/api/tickets", tags=["Tickets"]) app.include_router(tickets.router, prefix="/api/tickets", tags=["Tickets"])

View File

@@ -55,6 +55,11 @@ class Settings:
if not ADMIN_PASSWORD: if not ADMIN_PASSWORD:
raise ValueError("ADMIN_PASSWORD variable is required") raise ValueError("ADMIN_PASSWORD variable is required")
# Notifications Push (VAPID)
VAPID_PRIVATE_KEY: str = os.getenv("VAPID_PRIVATE_KEY", "")
VAPID_PUBLIC_KEY: str = os.getenv("VAPID_PUBLIC_KEY", "")
VAPID_CLAIMS_EMAIL: str = os.getenv("VAPID_CLAIMS_EMAIL", "mailto:admin@lediscord.com")
# App # App
APP_NAME: str = os.getenv("APP_NAME", "LeDiscord") APP_NAME: str = os.getenv("APP_NAME", "LeDiscord")
APP_URL: str = os.getenv("APP_URL", "http://localhost:5173") APP_URL: str = os.getenv("APP_URL", "http://localhost:5173")

View File

@@ -0,0 +1,38 @@
"""Add push_subscriptions table for Web Push notifications
Revision ID: 0002_push_subscriptions
Revises: 89527c8da8e1
Create Date: 2025-01-27
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0002_push_subscriptions'
down_revision = '89527c8da8e1'
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'push_subscriptions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('endpoint', sa.String(), nullable=False),
sa.Column('p256dh', sa.String(), nullable=False),
sa.Column('auth', sa.String(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('endpoint')
)
op.create_index(op.f('ix_push_subscriptions_id'), 'push_subscriptions', ['id'], unique=False)
def downgrade():
op.drop_index(op.f('ix_push_subscriptions_id'), table_name='push_subscriptions')
op.drop_table('push_subscriptions')

View File

@@ -27,3 +27,16 @@ class Notification(Base):
# Relationships # Relationships
user = relationship("User", back_populates="notifications") user = relationship("User", back_populates="notifications")
class PushSubscription(Base):
__tablename__ = "push_subscriptions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
endpoint = Column(String, unique=True, nullable=False)
p256dh = Column(String, nullable=False)
auth = Column(String, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="push_subscriptions")

View File

@@ -27,6 +27,7 @@ class User(Base):
mentions = relationship("PostMention", back_populates="mentioned_user", cascade="all, delete-orphan") mentions = relationship("PostMention", back_populates="mentioned_user", cascade="all, delete-orphan")
vlogs = relationship("Vlog", back_populates="author", cascade="all, delete-orphan") vlogs = relationship("Vlog", back_populates="author", cascade="all, delete-orphan")
notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan") notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan")
push_subscriptions = relationship("PushSubscription", back_populates="user", cascade="all, delete-orphan")
vlog_likes = relationship("VlogLike", back_populates="user", cascade="all, delete-orphan") vlog_likes = relationship("VlogLike", back_populates="user", cascade="all, delete-orphan")
vlog_comments = relationship("VlogComment", back_populates="user", cascade="all, delete-orphan") vlog_comments = relationship("VlogComment", back_populates="user", cascade="all, delete-orphan")
media_likes = relationship("MediaLike", back_populates="user", cascade="all, delete-orphan") media_likes = relationship("MediaLike", back_populates="user", cascade="all, delete-orphan")

View File

@@ -1,22 +1,15 @@
fastapi==0.104.1 fastapi>=0.68.0
uvicorn[standard]==0.24.0 uvicorn>=0.15.0
sqlalchemy==2.0.23 sqlalchemy>=1.4.0
alembic==1.12.1 psycopg2-binary>=2.9.0
psycopg2-binary==2.9.9 python-jose[cryptography]>=3.3.0
python-jose[cryptography]==3.3.0 passlib[bcrypt]>=1.7.4
passlib[bcrypt]==1.7.4 python-multipart>=0.0.5
bcrypt==4.0.1 python-dotenv>=0.19.0
python-multipart==0.0.6 pydantic>=1.8.0
python-dotenv==1.0.0 email-validator>=1.1.3
pydantic==2.5.0 pillow>=9.0.0
pydantic[email]==2.5.0 moviepy>=1.0.3
pydantic-settings==2.1.0 aiofiles>=0.8.0
aiofiles==23.2.1 python-magic>=0.4.27
pillow==10.1.0 pywebpush>=1.14.0
httpx==0.25.2
redis==5.0.1
celery==5.3.4
flower==2.0.1
python-magic==0.4.27
numpy==1.26.4
opencv-python==4.8.1.78

View File

@@ -15,3 +15,14 @@ class NotificationResponse(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class PushSubscriptionKeys(BaseModel):
p256dh: str
auth: str
class PushSubscriptionCreate(BaseModel):
endpoint: str
keys: PushSubscriptionKeys
class VapidPublicKeyResponse(BaseModel):
public_key: str

View File

@@ -0,0 +1,136 @@
"""
Service d'envoi de notifications push via Web Push (VAPID).
"""
from typing import Optional, Dict, Any
from sqlalchemy.orm import Session
from config.settings import settings
from models.notification import PushSubscription
# Import conditionnel de pywebpush
try:
from pywebpush import webpush, WebPushException
WEBPUSH_AVAILABLE = True
except ImportError:
WEBPUSH_AVAILABLE = False
print("⚠️ pywebpush non installé - Les notifications push sont désactivées")
def is_push_configured() -> bool:
"""Vérifie si les notifications push sont configurées."""
return (
WEBPUSH_AVAILABLE and
bool(settings.VAPID_PRIVATE_KEY) and
bool(settings.VAPID_PUBLIC_KEY)
)
def send_push_to_user(
db: Session,
user_id: int,
title: str,
body: str,
link: str = "/",
data: Optional[Dict[str, Any]] = None
) -> int:
"""
Envoie une notification push à tous les appareils d'un utilisateur.
Args:
db: Session de base de données
user_id: ID de l'utilisateur
title: Titre de la notification
body: Corps de la notification
link: Lien vers lequel rediriger
data: Données supplémentaires
Returns:
Nombre de notifications envoyées avec succès
"""
if not is_push_configured():
return 0
# Récupérer tous les abonnements de l'utilisateur
subscriptions = db.query(PushSubscription).filter(
PushSubscription.user_id == user_id
).all()
if not subscriptions:
return 0
success_count = 0
failed_endpoints = []
import json
payload = json.dumps({
"title": title,
"body": body,
"link": link,
"data": data or {}
})
vapid_claims = {
"sub": settings.VAPID_CLAIMS_EMAIL
}
for sub in subscriptions:
try:
webpush(
subscription_info={
"endpoint": sub.endpoint,
"keys": {
"p256dh": sub.p256dh,
"auth": sub.auth
}
},
data=payload,
vapid_private_key=settings.VAPID_PRIVATE_KEY,
vapid_claims=vapid_claims
)
success_count += 1
print(f"✅ Push envoyé à {sub.endpoint[:50]}...")
except WebPushException as e:
print(f"❌ Erreur push pour {sub.endpoint[:50]}...: {e}")
# Si l'abonnement est expiré ou invalide, on le marque pour suppression
if e.response and e.response.status_code in [404, 410]:
failed_endpoints.append(sub.endpoint)
except Exception as e:
print(f"❌ Erreur inattendue push: {type(e).__name__}: {e}")
# Supprimer les abonnements invalides
if failed_endpoints:
db.query(PushSubscription).filter(
PushSubscription.endpoint.in_(failed_endpoints)
).delete(synchronize_session=False)
db.commit()
print(f"🗑️ Supprimé {len(failed_endpoints)} abonnements invalides")
return success_count
def send_push_to_users(
db: Session,
user_ids: list,
title: str,
body: str,
link: str = "/",
data: Optional[Dict[str, Any]] = None
) -> int:
"""
Envoie une notification push à plusieurs utilisateurs.
Args:
db: Session de base de données
user_ids: Liste des IDs utilisateurs
title: Titre de la notification
body: Corps de la notification
link: Lien vers lequel rediriger
data: Données supplémentaires
Returns:
Nombre total de notifications envoyées avec succès
"""
total = 0
for user_id in user_ids:
total += send_push_to_user(db, user_id, title, body, link, data)
return total

View File

@@ -50,6 +50,8 @@ services:
MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE} MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE}
ALLOWED_IMAGE_TYPES: ${ALLOWED_IMAGE_TYPES} ALLOWED_IMAGE_TYPES: ${ALLOWED_IMAGE_TYPES}
ALLOWED_VIDEO_TYPES: ${ALLOWED_VIDEO_TYPES} ALLOWED_VIDEO_TYPES: ${ALLOWED_VIDEO_TYPES}
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY}
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY}
volumes: volumes:
- ${UPLOAD_PATH:-./uploads}:/app/uploads - ${UPLOAD_PATH:-./uploads}:/app/uploads
- ./backend:/app - ./backend:/app

View File

@@ -129,6 +129,7 @@ import { ref, computed } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { uploadFormData } from '@/utils/axios'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { Plus, X, Send, Ticket } from 'lucide-vue-next' import { Plus, X, Send, Ticket } from 'lucide-vue-next'
import LoadingLogo from '@/components/LoadingLogo.vue' import LoadingLogo from '@/components/LoadingLogo.vue'
@@ -189,18 +190,7 @@ async function submitTicket() {
// Debug: afficher les données envoyées // Debug: afficher les données envoyées
console.log('DEBUG - Ticket form data:') console.log('DEBUG - Ticket form data:')
console.log(' title:', ticketForm.value.title) await uploadFormData('/api/tickets/', formData)
console.log(' description:', ticketForm.value.description)
console.log(' ticket_type:', ticketForm.value.ticket_type)
console.log(' priority:', ticketForm.value.priority)
console.log(' screenshot:', screenshotInput.value?.files[0])
// Debug: afficher le FormData
for (let [key, value] of formData.entries()) {
console.log(`DEBUG - FormData entry: ${key} = ${value}`)
}
await axios.post('/api/tickets/', formData)
toast.success('Ticket envoyé avec succès !') toast.success('Ticket envoyé avec succès !')
closeModal() closeModal()

View File

@@ -1,10 +1,28 @@
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import axios from '@/utils/axios'
// Utilitaire pour convertir la clé VAPID
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4)
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
class NotificationService { class NotificationService {
constructor() { constructor() {
this.pollingInterval = null this.pollingInterval = null
this.pollInterval = 30000 // 30 secondes this.pollInterval = 30000 // 30 secondes
this.isPolling = false this.isPolling = false
this.vapidPublicKey = null
} }
// Détecter iOS // Détecter iOS
@@ -25,6 +43,14 @@ class NotificationService {
console.warn('Notifications API not supported') console.warn('Notifications API not supported')
return false return false
} }
if (!('serviceWorker' in navigator)) {
console.warn('Service Worker API not supported')
return false
}
if (!('PushManager' in window)) {
console.warn('Push API not supported')
return false
}
// Sur iOS, les notifications push ne fonctionnent que si la PWA est installée (iOS 16.4+) // Sur iOS, les notifications push ne fonctionnent que si la PWA est installée (iOS 16.4+)
if (this.isIOS()) { if (this.isIOS()) {
@@ -49,6 +75,8 @@ class NotificationService {
} }
startPolling() { startPolling() {
// Le polling est conservé comme fallback pour les notifications in-app
// ou si le push n'est pas supporté/activé
if (this.isPolling) return if (this.isPolling) return
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -81,40 +109,9 @@ class NotificationService {
} }
try { try {
const result = await authStore.fetchNotifications() // Juste mettre à jour le store (compteur, liste)
// Les notifications push sont gérées par le service worker
// Si de nouvelles notifications non lues ont été détectées await authStore.fetchNotifications()
if (result && result.hasNewNotifications && result.newCount > result.previousCount) {
// Trouver les nouvelles notifications non lues (les plus récentes en premier)
const newUnreadNotifications = authStore.notifications
.filter(n => !n.is_read)
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, result.newCount - result.previousCount)
if (newUnreadNotifications.length > 0) {
// Ne pas afficher de notification si l'app est en focus
// (pour éviter les doublons avec les notifications dans l'app)
if (document.hasFocus()) {
return
}
// Afficher une notification push pour la plus récente
const latestNotification = newUnreadNotifications[0]
// Gérer les liens de posts différemment
let link = latestNotification.link || '/'
if (link.startsWith('/posts/')) {
const postId = link.split('/posts/')[1]
link = `/posts?highlight=${postId}`
}
await this.showPushNotification(latestNotification.title, {
body: latestNotification.message,
link: link,
data: { notificationId: latestNotification.id }
})
}
}
} catch (error) { } catch (error) {
console.error('Error polling notifications:', error) console.error('Error polling notifications:', error)
} }
@@ -128,34 +125,70 @@ class NotificationService {
} }
} }
// Récupérer la clé publique VAPID depuis le backend
async getVapidPublicKey() {
if (this.vapidPublicKey) return this.vapidPublicKey
try {
const response = await axios.get('/api/push/vapid-public-key')
this.vapidPublicKey = response.data.public_key
return this.vapidPublicKey
} catch (error) {
console.error('Error fetching VAPID public key:', error)
return null
}
}
// S'abonner aux notifications push
async subscribeToPush() {
if (!this.isNotificationSupported()) return false
try {
const registration = await navigator.serviceWorker.ready
const publicKey = await this.getVapidPublicKey()
if (!publicKey) {
console.error('No VAPID public key available')
return false
}
const convertedVapidKey = urlBase64ToUint8Array(publicKey)
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey
})
console.log('✅ Push subscription successful:', subscription)
// Envoyer l'abonnement au backend
await axios.post('/api/push/subscribe', {
endpoint: subscription.endpoint,
keys: {
p256dh: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('p256dh')))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''),
auth: btoa(String.fromCharCode.apply(null, new Uint8Array(subscription.getKey('auth')))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
})
return true
} catch (error) {
console.error('Error subscribing to push:', error)
return false
}
}
// Gestion des notifications push PWA // Gestion des notifications push PWA
async requestNotificationPermission() { async requestNotificationPermission() {
console.log('🔔 requestNotificationPermission appelée') console.log('🔔 requestNotificationPermission appelée')
console.log('🔔 isIOS:', this.isIOS())
console.log('🔔 isPWAInstalled:', this.isPWAInstalled())
console.log('🔔 Notification API disponible:', 'Notification' in window)
console.log('🔔 Permission actuelle:', Notification.permission)
if (!this.isNotificationSupported()) { if (!this.isNotificationSupported()) {
console.warn('⚠️ Notifications not supported on this platform')
if (this.isIOS()) {
if (!this.isPWAInstalled()) {
console.warn('⚠️ iOS: PWA must be installed (added to home screen)')
}
const iosVersion = navigator.userAgent.match(/OS (\d+)_(\d+)/)
if (iosVersion) {
const major = parseInt(iosVersion[1], 10)
const minor = parseInt(iosVersion[2], 10)
if (major < 16 || (major === 16 && minor < 4)) {
console.warn(`⚠️ iOS: Version ${major}.${minor} - Push notifications require iOS 16.4+`)
}
}
}
return false return false
} }
if (Notification.permission === 'granted') { if (Notification.permission === 'granted') {
console.log('✅ Notification permission already granted') console.log('✅ Notification permission already granted')
// S'assurer qu'on est bien abonné au push
this.subscribeToPush()
return true return true
} }
@@ -167,7 +200,6 @@ class NotificationService {
// Sur iOS, s'assurer que la PWA est installée avant de demander // Sur iOS, s'assurer que la PWA est installée avant de demander
if (this.isIOS() && !this.isPWAInstalled()) { if (this.isIOS() && !this.isPWAInstalled()) {
console.warn('⚠️ iOS: Cannot request notification permission - PWA must be installed first') console.warn('⚠️ iOS: Cannot request notification permission - PWA must be installed first')
console.warn('⚠️ Instructions: Add the app to home screen, then open it from home screen')
return false return false
} }
@@ -176,29 +208,9 @@ class NotificationService {
const permission = await Notification.requestPermission() const permission = await Notification.requestPermission()
const granted = permission === 'granted' const granted = permission === 'granted'
console.log('🔔 Résultat de la demande:', permission)
if (granted) { if (granted) {
console.log('✅ Notification permission granted') console.log('✅ Notification permission granted')
await this.subscribeToPush()
// Sur iOS, vérifier que le service worker est prêt
if (this.isIOS() && 'serviceWorker' in navigator) {
try {
const registration = await navigator.serviceWorker.ready
console.log('✅ Service worker ready on iOS')
// Tester une notification pour vérifier que ça fonctionne
await registration.showNotification('Test LeDiscord', {
body: 'Les notifications sont activées !',
icon: '/icon-192x192.png',
badge: '/icon-96x96.png',
tag: 'test-notification'
})
console.log('✅ Test notification sent successfully')
} catch (error) {
console.error('❌ Error testing notification on iOS:', error)
}
}
} else { } else {
console.warn('❌ Notification permission denied:', permission) console.warn('❌ Notification permission denied:', permission)
} }
@@ -211,124 +223,39 @@ class NotificationService {
} }
async showPushNotification(title, options = {}) { async showPushNotification(title, options = {}) {
console.log('🔔 showPushNotification appelée:', { title, options }) // Cette méthode est maintenant principalement utilisée pour les tests
// ou les notifications locales générées par le client
if (!this.isNotificationSupported()) { if (!this.isNotificationSupported()) return null
console.warn('⚠️ Cannot show notification - not supported on this platform') if (Notification.permission !== 'granted') return null
return null
}
const hasPermission = await this.requestNotificationPermission() // Toujours utiliser le service worker pour les notifications push
if (!hasPermission) { if ('serviceWorker' in navigator) {
console.warn('⚠️ Cannot show notification - permission not granted') try {
return null const registration = await navigator.serviceWorker.ready
} if (!registration.active) return null
console.log('✅ Permission granted, affichage de la notification...') await registration.showNotification(title, {
// Préparer les options de notification (iOS ne supporte pas vibrate)
const notificationOptions = {
body: options.body || options.message || '',
icon: '/icon-192x192.png', icon: '/icon-192x192.png',
badge: '/icon-96x96.png', badge: '/icon-96x96.png',
tag: 'lediscord-notification', tag: 'lediscord-notification',
requireInteraction: false,
data: {
link: options.link || '/',
notificationId: options.data?.notificationId
},
...options ...options
}
// Retirer vibrate sur iOS (non supporté)
if (!this.isIOS()) {
notificationOptions.vibrate = [200, 100, 200]
}
// Toujours utiliser le service worker pour les notifications push
// Cela permet aux notifications de fonctionner même quand l'app est fermée
if ('serviceWorker' in navigator) {
try {
console.log('🔔 Tentative d\'affichage via service worker...')
const registration = await navigator.serviceWorker.ready
console.log('🔔 Service worker ready, active:', !!registration.active)
if (!registration.active) {
console.warn('⚠️ Service worker not active, using fallback')
throw new Error('Service worker not active')
}
// Sur iOS, utiliser directement l'API du service worker
// (les messages peuvent ne pas fonctionner correctement)
if (this.isIOS()) {
console.log('🔔 iOS: Utilisation directe de showNotification')
await registration.showNotification(title, notificationOptions)
console.log('✅ Notification affichée via service worker (iOS)')
} else {
// Envoyer un message au service worker pour afficher la notification
registration.active.postMessage({
type: 'SHOW_NOTIFICATION',
title,
options: notificationOptions
}) })
return true
// Aussi utiliser l'API directe du service worker
await registration.showNotification(title, notificationOptions)
console.log('✅ Notification affichée via service worker')
}
return null
} catch (error) {
console.error('❌ Error showing notification via service worker:', error)
console.error('❌ Error details:', {
message: error.message,
stack: error.stack,
name: error.name
})
// Continuer avec le fallback
}
} else {
console.warn('⚠️ Service worker not available')
}
// Fallback: notification native du navigateur (seulement si le SW n'est pas disponible)
try {
const notification = new Notification(title, notificationOptions)
notification.onclick = () => {
window.focus()
notification.close()
if (options.link) {
// Utiliser le router si disponible
if (window.location.pathname !== options.link) {
window.location.href = options.link
}
}
}
// Fermer automatiquement après 5 secondes (sauf sur iOS où c'est géré par le système)
if (!this.isIOS()) {
setTimeout(() => {
notification.close()
}, 5000)
}
console.log('Notification shown via native API')
return notification
} catch (error) { } catch (error) {
console.error('Error showing notification:', error) console.error('Error showing notification:', error)
return null
} }
} }
return null
}
// Écouter les messages du service worker pour les notifications push // Écouter les messages du service worker pour les notifications push
setupServiceWorkerListener() { setupServiceWorkerListener() {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => { navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'NOTIFICATION') { if (event.data && event.data.type === 'NOTIFICATION') {
const { title, options } = event.data // Notification reçue via message (fallback)
this.showPushNotification(title, options) console.log('Notification message received:', event.data)
} }
}) })
} }

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { uploadFormData } from '@/utils/axios'
import router from '@/router' import router from '@/router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
@@ -112,15 +113,13 @@ export const useAuthStore = defineStore('auth', () => {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
const response = await axios.post('/api/users/me/avatar', formData) const data = await uploadFormData('/api/users/me/avatar', formData)
user.value = data
user.value = response.data
toast.success('Avatar mis à jour') toast.success('Avatar mis à jour')
return { success: true, data: response.data } return { success: true, data }
} catch (error) { } catch (error) {
console.error('Error uploading avatar:', error)
toast.error('Erreur lors de l\'upload de l\'avatar') toast.error('Erreur lors de l\'upload de l\'avatar')
return { success: false, error: error.response?.data?.detail || 'Erreur inconnue' } return { success: false, error: error.message || 'Erreur inconnue' }
} }
} }

View File

@@ -71,9 +71,10 @@ instance.interceptors.request.use(
// Augmenter le timeout pour les requêtes POST/PUT avec FormData (uploads) // Augmenter le timeout pour les requêtes POST/PUT avec FormData (uploads)
if ((config.method === 'POST' || config.method === 'PUT') && config.data instanceof FormData) { if ((config.method === 'POST' || config.method === 'PUT') && config.data instanceof FormData) {
config.timeout = 120000 // 2 minutes pour les uploads config.timeout = 120000 // 2 minutes pour les uploads
// Ne pas définir Content-Type pour FormData - laisser le navigateur l'ajouter avec la boundary
// C'est crucial sur mobile où définir explicitement le Content-Type peut causer des erreurs // IMPORTANT: Supprimer le Content-Type pour laisser le navigateur définir le multipart/form-data avec la boundary
if (config.headers && config.headers['Content-Type'] === 'multipart/form-data') { // Axios peut avoir mis 'application/json' par défaut ou on peut l'avoir mis manuellement
if (config.headers && config.headers['Content-Type']) {
delete config.headers['Content-Type'] delete config.headers['Content-Type']
} }
} }
@@ -109,15 +110,15 @@ instance.interceptors.response.use(
requestHeaders: error.config?.headers requestHeaders: error.config?.headers
}) })
// Log supplémentaire pour les erreurs 401/403 // Log supplémentaire pour les erreurs 401/403/422
if (error.response?.status === 401 || error.response?.status === 403) { if ([401, 403, 422].includes(error.response?.status)) {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
console.error('🔍 Diagnostic erreur auth:', { console.error(`🔍 Diagnostic erreur ${error.response?.status}:`, {
hasToken: !!token, hasToken: !!token,
tokenLength: token?.length, tokenLength: token?.length,
tokenPreview: token ? token.substring(0, 20) + '...' : null,
url: error.config?.url, url: error.config?.url,
method: error.config?.method method: error.config?.method,
validationErrors: error.response?.data?.detail
}) })
} }
@@ -208,3 +209,28 @@ export function getApiUrl() {
export function getAppUrl() { export function getAppUrl() {
return import.meta.env.VITE_APP_URL || window.location.origin return import.meta.env.VITE_APP_URL || window.location.origin
} }
// Fonction utilitaire pour upload de FormData via fetch natif
// (contourne les problèmes d'axios avec FormData sur certains navigateurs/mobiles)
export async function uploadFormData(endpoint, formData) {
const token = localStorage.getItem('token')
const apiUrl = getApiUrl()
const response = await fetch(`${apiUrl}${endpoint}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
// Ne PAS mettre Content-Type, fetch le gère automatiquement avec FormData
},
body: formData
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const error = new Error(errorData.detail || 'Erreur lors de l\'upload')
error.response = { status: response.status, data: errorData }
throw error
}
return await response.json()
}

View File

@@ -566,7 +566,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils' import { formatDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import { import {
ArrowLeft, ArrowLeft,
@@ -716,7 +716,7 @@ async function uploadMedia() {
formData.append('files', media.file) formData.append('files', media.file)
}) })
await axios.post(`/api/albums/${album.value.id}/media`, formData) await uploadFormData(`/api/albums/${album.value.id}/media`, formData)
// Refresh album data // Refresh album data
await fetchAlbum() await fetchAlbum()

View File

@@ -334,7 +334,7 @@ import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone } from '@/utils/dateUtils' import { formatRelativeDateInFrenchTimezone, formatShortDateInFrenchTimezone } from '@/utils/dateUtils'
import { import {
Plus, Plus,
@@ -653,14 +653,11 @@ async function createAlbum() {
} }
}) })
await axios.post(`/api/albums/${album.id}/media`, formData, { await uploadFormData(`/api/albums/${album.id}/media`, formData)
onUploadProgress: (progressEvent) => {
// Update progress for this batch // Update progress
const batchProgress = (progressEvent.loaded / progressEvent.total) * 100
const overallProgress = ((batchIndex * batchSize + batch.length) / newAlbum.value.media.length) * 100 const overallProgress = ((batchIndex * batchSize + batch.length) / newAlbum.value.media.length) * 100
uploadProgress.value = Math.min(overallProgress, 100) uploadProgress.value = Math.min(overallProgress, 100)
}
})
// Mark batch as successful // Mark batch as successful
batch.forEach((media, index) => { batch.forEach((media, index) => {

View File

@@ -289,7 +289,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils' import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import { import {
Plus, Plus,
@@ -465,9 +465,8 @@ async function handleImageChange(event) {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
const response = await axios.post('/api/posts/upload-image', formData) const data = await uploadFormData('/api/posts/upload-image', formData)
newPost.value.image_url = data.image_url
newPost.value.image_url = response.data.image_url
} catch (error) { } catch (error) {
toast.error('Erreur lors de l\'upload de l\'image') toast.error('Erreur lors de l\'upload de l\'image')
} }

View File

@@ -267,7 +267,7 @@ import { ref, onMounted } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import axios from '@/utils/axios' import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios' import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils' import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import { import {
Plus, Plus,
@@ -435,14 +435,13 @@ async function createVlog() {
formData.append('thumbnail', newVlog.value.thumbnailFile) formData.append('thumbnail', newVlog.value.thumbnailFile)
} }
const response = await axios.post('/api/vlogs/upload', formData) const data = await uploadFormData('/api/vlogs/upload', formData)
vlogs.value.unshift(data)
vlogs.value.unshift(response.data)
showCreateModal.value = false showCreateModal.value = false
resetForm() resetForm()
toast.success('Vlog créé avec succès') toast.success('Vlog créé avec succès')
} catch (error) { } catch (error) {
toast.error('Erreur lors de la création du vlog') toast.error(error.message || 'Erreur lors de la création du vlog')
} }
creating.value = false creating.value = false
} }