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

@@ -129,6 +129,7 @@ import { ref, computed } from 'vue'
import { useToast } from 'vue-toastification'
import { useRouter } from 'vue-router'
import axios from '@/utils/axios'
import { uploadFormData } from '@/utils/axios'
import { useAuthStore } from '@/stores/auth'
import { Plus, X, Send, Ticket } from 'lucide-vue-next'
import LoadingLogo from '@/components/LoadingLogo.vue'
@@ -189,18 +190,7 @@ async function submitTicket() {
// Debug: afficher les données envoyées
console.log('DEBUG - Ticket form data:')
console.log(' title:', ticketForm.value.title)
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)
await uploadFormData('/api/tickets/', formData)
toast.success('Ticket envoyé avec succès !')
closeModal()

View File

@@ -1,10 +1,28 @@
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 {
constructor() {
this.pollingInterval = null
this.pollInterval = 30000 // 30 secondes
this.isPolling = false
this.vapidPublicKey = null
}
// Détecter iOS
@@ -25,6 +43,14 @@ class NotificationService {
console.warn('Notifications API not supported')
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+)
if (this.isIOS()) {
@@ -49,6 +75,8 @@ class NotificationService {
}
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
const authStore = useAuthStore()
@@ -81,40 +109,9 @@ class NotificationService {
}
try {
const result = await authStore.fetchNotifications()
// Si de nouvelles notifications non lues ont été détectées
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 }
})
}
}
// Juste mettre à jour le store (compteur, liste)
// Les notifications push sont gérées par le service worker
await authStore.fetchNotifications()
} catch (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
async requestNotificationPermission() {
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()) {
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
}
if (Notification.permission === 'granted') {
console.log('✅ Notification permission already granted')
// S'assurer qu'on est bien abonné au push
this.subscribeToPush()
return true
}
@@ -167,7 +200,6 @@ class NotificationService {
// Sur iOS, s'assurer que la PWA est installée avant de demander
if (this.isIOS() && !this.isPWAInstalled()) {
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
}
@@ -176,29 +208,9 @@ class NotificationService {
const permission = await Notification.requestPermission()
const granted = permission === 'granted'
console.log('🔔 Résultat de la demande:', permission)
if (granted) {
console.log('✅ Notification permission granted')
// 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)
}
}
await this.subscribeToPush()
} else {
console.warn('❌ Notification permission denied:', permission)
}
@@ -211,115 +223,30 @@ class NotificationService {
}
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()) {
console.warn('⚠️ Cannot show notification - not supported on this platform')
return null
}
const hasPermission = await this.requestNotificationPermission()
if (!hasPermission) {
console.warn('⚠️ Cannot show notification - permission not granted')
return null
}
console.log('✅ Permission granted, affichage de la notification...')
// Préparer les options de notification (iOS ne supporte pas vibrate)
const notificationOptions = {
body: options.body || options.message || '',
icon: '/icon-192x192.png',
badge: '/icon-96x96.png',
tag: 'lediscord-notification',
requireInteraction: false,
data: {
link: options.link || '/',
notificationId: options.data?.notificationId
},
...options
}
// Retirer vibrate sur iOS (non supporté)
if (!this.isIOS()) {
notificationOptions.vibrate = [200, 100, 200]
}
if (!this.isNotificationSupported()) return null
if (Notification.permission !== 'granted') return null
// 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) return null
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
})
// 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
await registration.showNotification(title, {
icon: '/icon-192x192.png',
badge: '/icon-96x96.png',
tag: 'lediscord-notification',
...options
})
// Continuer avec le fallback
return true
} catch (error) {
console.error('Error showing notification:', error)
}
} 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) {
console.error('Error showing notification:', error)
return null
}
return null
}
// Écouter les messages du service worker pour les notifications push
@@ -327,8 +254,8 @@ class NotificationService {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'NOTIFICATION') {
const { title, options } = event.data
this.showPushNotification(title, options)
// Notification reçue via message (fallback)
console.log('Notification message received:', event.data)
}
})
}

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from '@/utils/axios'
import { uploadFormData } from '@/utils/axios'
import router from '@/router'
import { useToast } from 'vue-toastification'
@@ -112,15 +113,13 @@ export const useAuthStore = defineStore('auth', () => {
const formData = new FormData()
formData.append('file', file)
const response = await axios.post('/api/users/me/avatar', formData)
user.value = response.data
const data = await uploadFormData('/api/users/me/avatar', formData)
user.value = data
toast.success('Avatar mis à jour')
return { success: true, data: response.data }
return { success: true, data }
} catch (error) {
console.error('Error uploading avatar:', error)
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)
if ((config.method === 'POST' || config.method === 'PUT') && config.data instanceof FormData) {
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
if (config.headers && config.headers['Content-Type'] === 'multipart/form-data') {
// IMPORTANT: Supprimer le Content-Type pour laisser le navigateur définir le multipart/form-data avec la boundary
// 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']
}
}
@@ -109,15 +110,15 @@ instance.interceptors.response.use(
requestHeaders: error.config?.headers
})
// Log supplémentaire pour les erreurs 401/403
if (error.response?.status === 401 || error.response?.status === 403) {
// Log supplémentaire pour les erreurs 401/403/422
if ([401, 403, 422].includes(error.response?.status)) {
const token = localStorage.getItem('token')
console.error('🔍 Diagnostic erreur auth:', {
console.error(`🔍 Diagnostic erreur ${error.response?.status}:`, {
hasToken: !!token,
tokenLength: token?.length,
tokenPreview: token ? token.substring(0, 20) + '...' : null,
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() {
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 { useToast } from 'vue-toastification'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatDateInFrenchTimezone, formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import {
ArrowLeft,
@@ -716,7 +716,7 @@ async function uploadMedia() {
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
await fetchAlbum()

View File

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

View File

@@ -289,7 +289,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import {
Plus,
@@ -465,9 +465,8 @@ async function handleImageChange(event) {
const formData = new FormData()
formData.append('file', file)
const response = await axios.post('/api/posts/upload-image', formData)
newPost.value.image_url = response.data.image_url
const data = await uploadFormData('/api/posts/upload-image', formData)
newPost.value.image_url = data.image_url
} catch (error) {
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 { useRouter } from 'vue-router'
import axios from '@/utils/axios'
import { getMediaUrl } from '@/utils/axios'
import { getMediaUrl, uploadFormData } from '@/utils/axios'
import { formatRelativeDateInFrenchTimezone } from '@/utils/dateUtils'
import {
Plus,
@@ -435,14 +435,13 @@ async function createVlog() {
formData.append('thumbnail', newVlog.value.thumbnailFile)
}
const response = await axios.post('/api/vlogs/upload', formData)
vlogs.value.unshift(response.data)
const data = await uploadFormData('/api/vlogs/upload', formData)
vlogs.value.unshift(data)
showCreateModal.value = false
resetForm()
toast.success('Vlog créé avec succès')
} 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
}