fix(notification+vlog upload)
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
67
backend/api/routers/push.py
Normal file
67
backend/api/routers/push.py
Normal 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"}
|
||||||
|
|
||||||
@@ -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,12 +255,15 @@ 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"):
|
||||||
allowed_types = SettingsService.get_setting(db, "allowed_video_types",
|
# Fallback check for common video types if content_type is generic application/octet-stream
|
||||||
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
|
filename = video.filename.lower()
|
||||||
raise HTTPException(
|
if not (filename.endswith('.mp4') or filename.endswith('.mov') or filename.endswith('.webm') or filename.endswith('.mkv')):
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
allowed_types = SettingsService.get_setting(db, "allowed_video_types",
|
||||||
detail=f"Invalid video type. Allowed types: {', '.join(allowed_types)}"
|
["video/mp4", "video/mpeg", "video/quicktime", "video/webm"])
|
||||||
)
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid video type. Allowed types: {', '.join(allowed_types)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Check file size
|
# Check file size
|
||||||
video_content = await video.read()
|
video_content = await video.read()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
38
backend/migrations/versions/0002_add_push_subscriptions.py
Normal file
38
backend/migrations/versions/0002_add_push_subscriptions.py
Normal 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')
|
||||||
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
136
backend/utils/push_service.py
Normal file
136
backend/utils/push_service.py
Normal 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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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,115 +223,30 @@ 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()
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toujours utiliser le service worker pour les notifications push
|
// 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) {
|
if ('serviceWorker' in navigator) {
|
||||||
try {
|
try {
|
||||||
console.log('🔔 Tentative d\'affichage via service worker...')
|
|
||||||
const registration = await navigator.serviceWorker.ready
|
const registration = await navigator.serviceWorker.ready
|
||||||
console.log('🔔 Service worker ready, active:', !!registration.active)
|
if (!registration.active) return null
|
||||||
|
|
||||||
if (!registration.active) {
|
await registration.showNotification(title, {
|
||||||
console.warn('⚠️ Service worker not active, using fallback')
|
icon: '/icon-192x192.png',
|
||||||
throw new Error('Service worker not active')
|
badge: '/icon-96x96.png',
|
||||||
}
|
tag: 'lediscord-notification',
|
||||||
|
...options
|
||||||
// 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
|
|
||||||
})
|
})
|
||||||
// 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
|
// Écouter les messages du service worker pour les notifications push
|
||||||
@@ -327,8 +254,8 @@ class NotificationService {
|
|||||||
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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user