fix+feat(everything): lot of things #3

Merged
evan merged 1 commits from develop into prod 2026-01-25 22:16:11 +01:00
15 changed files with 871 additions and 43 deletions
Showing only changes of commit dfeaecce73 - Show all commits

137
backend/MIGRATIONS.md Normal file
View File

@@ -0,0 +1,137 @@
# Guide des Migrations Alembic
Ce projet utilise Alembic pour gérer les migrations de base de données de manière versionnée et contrôlée.
## 🚀 Démarrage rapide
### Première utilisation
Si c'est la première fois que vous utilisez Alembic sur ce projet :
```bash
# Depuis le conteneur backend
docker compose exec backend alembic revision --autogenerate -m "Initial migration"
# Appliquer la migration
docker compose exec backend alembic upgrade head
```
### Commandes courantes
```bash
# Créer une nouvelle migration (autogenerate depuis les modèles)
docker compose exec backend alembic revision --autogenerate -m "Description de la migration"
# Appliquer toutes les migrations en attente
docker compose exec backend alembic upgrade head
# Revenir en arrière d'une migration
docker compose exec backend alembic downgrade -1
# Voir la migration actuelle
docker compose exec backend alembic current
# Voir l'historique des migrations
docker compose exec backend alembic history
```
## 📋 Workflow de développement
### 1. Modifier un modèle
Éditez les fichiers dans `backend/models/` pour ajouter/modifier des colonnes, tables, etc.
### 2. Créer une migration
```bash
docker compose exec backend alembic revision --autogenerate -m "Ajout du champ X à la table Y"
```
Alembic va automatiquement détecter les changements dans vos modèles SQLAlchemy.
### 3. Vérifier la migration
Ouvrez le fichier généré dans `backend/migrations/versions/` et vérifiez que les changements sont corrects.
### 4. Appliquer la migration
```bash
docker compose exec backend alembic upgrade head
```
### 5. Tester
Vérifiez que votre application fonctionne correctement avec les nouvelles migrations.
## ⚠️ Notes importantes
- **Ne modifiez jamais manuellement** les fichiers de migration existants qui ont déjà été appliqués
- **Toujours tester** les migrations en développement avant de les appliquer en production
- **Sauvegardez votre base de données** avant d'appliquer des migrations en production
- Les migrations sont **versionnées** : chaque migration a un ID unique et un historique
## 🔄 Migration manuelle (sans autogenerate)
Si vous avez besoin de créer une migration manuelle (pour des données, des index complexes, etc.) :
```bash
docker compose exec backend alembic revision -m "Description"
```
Puis éditez le fichier généré dans `backend/migrations/versions/` pour ajouter votre logique.
## 🐛 Dépannage
### Migration en conflit
Si vous avez des conflits de migration :
```bash
# Voir l'état actuel
docker compose exec backend alembic current
# Voir l'historique
docker compose exec backend alembic history
# Revenir à une version spécifique
docker compose exec backend alembic downgrade <revision_id>
```
### Migration qui échoue
Si une migration échoue :
1. Vérifiez les logs : `docker compose logs backend`
2. Vérifiez l'état de la base : `docker compose exec backend alembic current`
3. Si nécessaire, corrigez la migration et réessayez
### Réinitialiser les migrations (⚠️ DANGEREUX)
**ATTENTION** : Cela supprime toutes les données !
```bash
# Supprimer toutes les tables
docker compose exec backend alembic downgrade base
# Recréer depuis le début
docker compose exec backend alembic upgrade head
```
## 📁 Structure des fichiers
```
backend/
├── alembic.ini # Configuration Alembic
├── migrations/
│ ├── env.py # Configuration de l'environnement
│ ├── script.py.mako # Template pour les migrations
│ ├── versions/ # Fichiers de migration (générés)
│ └── README.md # Documentation
└── models/ # Modèles SQLAlchemy
```
## 🔗 Ressources
- [Documentation Alembic](https://alembic.sqlalchemy.org/)
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)

115
backend/alembic.ini Normal file
View File

@@ -0,0 +1,115 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = migrations
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to migrations/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# post_write_hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# post_write_hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -19,40 +19,76 @@ async def create_event(
current_user: User = Depends(get_current_active_user)
):
"""Create a new event."""
event_dict = event_data.dict(exclude={'invited_user_ids'})
event = Event(
**event_data.dict(),
**event_dict,
creator_id=current_user.id
)
db.add(event)
db.commit()
db.refresh(event)
# Create participations for all active users
users = db.query(User).filter(User.is_active == True).all()
for user in users:
participation = EventParticipation(
event_id=event.id,
user_id=user.id,
status=ParticipationStatus.PENDING
)
db.add(participation)
# Gérer les invitations selon le type d'événement
if event.is_private:
# Événement privé : inviter uniquement les utilisateurs sélectionnés
invited_user_ids = event_data.invited_user_ids or []
# Toujours inclure le créateur
if current_user.id not in invited_user_ids:
invited_user_ids.append(current_user.id)
# Create notification
if user.id != current_user.id:
notification = Notification(
for user_id in invited_user_ids:
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
if user:
participation = EventParticipation(
event_id=event.id,
user_id=user.id,
status=ParticipationStatus.PENDING
)
db.add(participation)
# Create notification
if user.id != current_user.id:
notification = Notification(
user_id=user.id,
type=NotificationType.EVENT_INVITATION,
title=f"Invitation à un événement privé: {event.title}",
message=f"{current_user.full_name} vous a invité à un événement privé",
link=f"/events/{event.id}"
)
db.add(notification)
# Send email notification
try:
send_event_notification(user.email, event)
except:
pass
else:
# Événement public : inviter tous les utilisateurs actifs
users = db.query(User).filter(User.is_active == True).all()
for user in users:
participation = EventParticipation(
event_id=event.id,
user_id=user.id,
type=NotificationType.EVENT_INVITATION,
title=f"Nouvel événement: {event.title}",
message=f"{current_user.full_name} a créé un nouvel événement",
link=f"/events/{event.id}"
status=ParticipationStatus.PENDING
)
db.add(notification)
db.add(participation)
# Send email notification (async task would be better)
try:
send_event_notification(user.email, event)
except:
pass # Don't fail if email sending fails
# Create notification
if user.id != current_user.id:
notification = Notification(
user_id=user.id,
type=NotificationType.EVENT_INVITATION,
title=f"Nouvel événement: {event.title}",
message=f"{current_user.full_name} a créé un nouvel événement",
link=f"/events/{event.id}"
)
db.add(notification)
# Send email notification
try:
send_event_notification(user.email, event)
except:
pass
db.commit()
return format_event_response(event, db)
@@ -75,7 +111,23 @@ async def get_events(
# If upcoming is None, return all events
events = query.order_by(Event.date.desc()).all()
return [format_event_response(event, db) for event in events]
# Filtrer les événements privés : ne montrer que ceux où l'utilisateur est invité
filtered_events = []
for event in events:
if event.is_private:
# Vérifier si l'utilisateur est invité (a une participation)
participation = db.query(EventParticipation).filter(
EventParticipation.event_id == event.id,
EventParticipation.user_id == current_user.id
).first()
if participation or event.creator_id == current_user.id or current_user.is_admin:
filtered_events.append(event)
else:
# Événement public : visible par tous
filtered_events.append(event)
return [format_event_response(event, db) for event in filtered_events]
@router.get("/upcoming", response_model=List[EventResponse])
async def get_upcoming_events(
@@ -86,7 +138,21 @@ async def get_upcoming_events(
events = db.query(Event).options(joinedload(Event.creator)).filter(
Event.date >= datetime.utcnow()
).order_by(Event.date).all()
return [format_event_response(event, db) for event in events]
# Filtrer les événements privés
filtered_events = []
for event in events:
if event.is_private:
participation = db.query(EventParticipation).filter(
EventParticipation.event_id == event.id,
EventParticipation.user_id == current_user.id
).first()
if participation or event.creator_id == current_user.id or current_user.is_admin:
filtered_events.append(event)
else:
filtered_events.append(event)
return [format_event_response(event, db) for event in filtered_events]
@router.get("/past", response_model=List[EventResponse])
async def get_past_events(
@@ -97,7 +163,21 @@ async def get_past_events(
events = db.query(Event).options(joinedload(Event.creator)).filter(
Event.date < datetime.utcnow()
).order_by(Event.date.desc()).all()
return [format_event_response(event, db) for event in events]
# Filtrer les événements privés
filtered_events = []
for event in events:
if event.is_private:
participation = db.query(EventParticipation).filter(
EventParticipation.event_id == event.id,
EventParticipation.user_id == current_user.id
).first()
if participation or event.creator_id == current_user.id or current_user.is_admin:
filtered_events.append(event)
else:
filtered_events.append(event)
return [format_event_response(event, db) for event in filtered_events]
@router.get("/{event_id}", response_model=EventResponse)
async def get_event(
@@ -112,6 +192,19 @@ async def get_event(
status_code=status.HTTP_404_NOT_FOUND,
detail="Event not found"
)
# Vérifier l'accès pour les événements privés
if event.is_private:
participation = db.query(EventParticipation).filter(
EventParticipation.event_id == event.id,
EventParticipation.user_id == current_user.id
).first()
if not participation and event.creator_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this private event"
)
return format_event_response(event, db)
@router.put("/{event_id}", response_model=EventResponse)
@@ -166,6 +259,74 @@ async def delete_event(
db.commit()
return {"message": "Event deleted successfully"}
@router.post("/{event_id}/invite", response_model=EventResponse)
async def invite_users_to_event(
event_id: int,
user_ids: List[int],
db: Session = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Invite users to a private event."""
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Event not found"
)
# Vérifier que l'événement est privé et que l'utilisateur est le créateur ou admin
if not event.is_private:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This endpoint is only for private events"
)
if event.creator_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only the event creator can invite users"
)
# Inviter les utilisateurs
for user_id in user_ids:
# Vérifier si l'utilisateur existe et est actif
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
if not user:
continue
# Vérifier si l'utilisateur n'est pas déjà invité
existing_participation = db.query(EventParticipation).filter(
EventParticipation.event_id == event_id,
EventParticipation.user_id == user_id
).first()
if not existing_participation:
participation = EventParticipation(
event_id=event.id,
user_id=user.id,
status=ParticipationStatus.PENDING
)
db.add(participation)
# Créer une notification
notification = Notification(
user_id=user.id,
type=NotificationType.EVENT_INVITATION,
title=f"Invitation à un événement privé: {event.title}",
message=f"{current_user.full_name} vous a invité à un événement privé",
link=f"/events/{event.id}"
)
db.add(notification)
# Envoyer un email
try:
send_event_notification(user.email, event)
except:
pass
db.commit()
return format_event_response(event, db)
@router.put("/{event_id}/participation", response_model=EventResponse)
async def update_participation(
event_id: int,
@@ -174,6 +335,25 @@ async def update_participation(
current_user: User = Depends(get_current_active_user)
):
"""Update user participation status for an event."""
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Event not found"
)
# Pour les événements privés, vérifier que l'utilisateur est invité
if event.is_private:
participation_check = db.query(EventParticipation).filter(
EventParticipation.event_id == event_id,
EventParticipation.user_id == current_user.id
).first()
if not participation_check and event.creator_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not invited to this private event"
)
participation = db.query(EventParticipation).filter(
EventParticipation.event_id == event_id,
EventParticipation.user_id == current_user.id
@@ -236,6 +416,7 @@ def format_event_response(event: Event, db: Session) -> dict:
"end_date": event.end_date,
"creator_id": event.creator_id,
"cover_image": event.cover_image,
"is_private": event.is_private if event.is_private is not None else False,
"created_at": event.created_at,
"updated_at": event.updated_at,
"creator_name": creator.full_name if creator else "Unknown",

View File

@@ -175,8 +175,9 @@ def init_default_settings():
async def lifespan(app: FastAPI):
# Startup
print("Starting LeDiscord backend...")
# Create tables
Base.metadata.create_all(bind=engine)
# Note: Database migrations are handled by Alembic
# Run migrations manually with: alembic upgrade head
# Base.metadata.create_all(bind=engine) # Disabled in favor of Alembic migrations
# Initialize database with admin user
init_database()
# Initialize default settings

View File

@@ -0,0 +1,43 @@
# Migrations Alembic
Ce dossier contient les migrations de base de données gérées par Alembic.
## Commandes utiles
### Créer une nouvelle migration
```bash
alembic revision --autogenerate -m "Description de la migration"
```
### Appliquer les migrations
```bash
alembic upgrade head
```
### Revenir en arrière d'une migration
```bash
alembic downgrade -1
```
### Voir l'historique des migrations
```bash
alembic history
```
### Voir la migration actuelle
```bash
alembic current
```
### Créer une migration vide (sans autogenerate)
```bash
alembic revision -m "Description de la migration"
```
## Notes importantes
- Les migrations sont automatiquement détectées depuis les modèles SQLAlchemy dans `models/`
- Ne modifiez jamais manuellement les fichiers de migration existants
- Testez toujours les migrations en développement avant de les appliquer en production
- En cas de problème, vous pouvez toujours revenir en arrière avec `alembic downgrade`

104
backend/migrations/env.py Normal file
View File

@@ -0,0 +1,104 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
from config.database import Base
from config.settings import settings
# Import all models so Alembic can detect them
from models import (
User,
Event,
EventParticipation,
Album,
Media,
MediaLike,
Post,
PostMention,
PostLike,
PostComment,
Vlog,
VlogLike,
VlogComment,
Notification,
SystemSettings,
Information,
Ticket
)
# set the sqlalchemy.url from settings
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL)
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,38 @@
"""Add is_private field to events
Revision ID: 89527c8da8e1
Revises:
Create Date: 2026-01-25 18:43:00.881982
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '89527c8da8e1'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Ajouter la colonne avec nullable=True d'abord
op.add_column('events', sa.Column('is_private', sa.Boolean(), nullable=True))
# Mettre à jour toutes les lignes existantes avec False
op.execute("UPDATE events SET is_private = FALSE WHERE is_private IS NULL")
# Rendre la colonne non-nullable avec une valeur par défaut
op.alter_column('events', 'is_private',
existing_type=sa.Boolean(),
nullable=False,
server_default='false')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('events', 'is_private')
# ### end Alembic commands ###

View File

@@ -1,7 +1,7 @@
from .user import User
from .event import Event, EventParticipation
from .album import Album, Media, MediaLike
from .post import Post, PostMention
from .post import Post, PostMention, PostLike, PostComment
from .vlog import Vlog, VlogLike, VlogComment
from .notification import Notification
from .settings import SystemSettings
@@ -17,6 +17,8 @@ __all__ = [
"MediaLike",
"Post",
"PostMention",
"PostLike",
"PostComment",
"Vlog",
"VlogLike",
"VlogComment",

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum as SQLEnum, Float
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum as SQLEnum, Float, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
@@ -23,6 +23,7 @@ class Event(Base):
end_date = Column(DateTime)
creator_id = Column(Integer, ForeignKey("users.id"), nullable=False)
cover_image = Column(String)
is_private = Column(Boolean, default=False) # Événement privé ou public
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -14,6 +14,8 @@ class EventBase(BaseModel):
class EventCreate(EventBase):
cover_image: Optional[str] = None
is_private: bool = False
invited_user_ids: Optional[List[int]] = None # Liste des IDs des utilisateurs invités (pour événements privés)
class EventUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
@@ -24,6 +26,7 @@ class EventUpdate(BaseModel):
date: Optional[datetime] = None
end_date: Optional[datetime] = None
cover_image: Optional[str] = None
is_private: Optional[bool] = None
class ParticipationResponse(BaseModel):
user_id: int
@@ -42,6 +45,7 @@ class EventResponse(EventBase):
creator_name: str
creator_avatar: Optional[str] = None
cover_image: Optional[str]
is_private: bool = False
created_at: datetime
participations: List[ParticipationResponse] = []
present_count: int = 0

42
backend/scripts/migrate.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Script helper pour gérer les migrations Alembic
set -e
case "$1" in
create)
if [ -z "$2" ]; then
echo "Usage: ./scripts/migrate.sh create 'message de migration'"
exit 1
fi
alembic revision --autogenerate -m "$2"
;;
upgrade)
alembic upgrade head
;;
downgrade)
if [ -z "$2" ]; then
alembic downgrade -1
else
alembic downgrade "$2"
fi
;;
current)
alembic current
;;
history)
alembic history
;;
*)
echo "Usage: ./scripts/migrate.sh {create|upgrade|downgrade|current|history}"
echo ""
echo "Commands:"
echo " create 'message' - Créer une nouvelle migration"
echo " upgrade - Appliquer toutes les migrations en attente"
echo " downgrade [rev] - Revenir en arrière (par défaut -1)"
echo " current - Afficher la migration actuelle"
echo " history - Afficher l'historique des migrations"
exit 1
;;
esac

View File

@@ -1,15 +1,67 @@
FROM node:18-alpine
# Stage 1 : Build prod
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files first for better caching
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy application files
COPY . .
# Run the application in development mode
CMD ["npm", "run", "dev"]
ARG VITE_API_URL=https://api.lediscord.com
ARG VITE_APP_URL=https://lediscord.com
ARG VITE_UPLOAD_URL=https://api.lediscord.com/uploads
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_APP_URL=$VITE_APP_URL
ENV VITE_UPLOAD_URL=$VITE_UPLOAD_URL
RUN npm run build
# Stage 2 : Image finale avec les deux modes
FROM node:20-alpine
RUN apk add --no-cache nginx
WORKDIR /app
# Copier les sources pour le mode dev
COPY package*.json ./
RUN npm ci
COPY . .
# Copier le build prod
COPY --from=builder /app/dist /usr/share/nginx/html
# Config nginx
RUN echo 'server { \
listen 8080; \
root /usr/share/nginx/html; \
index index.html; \
location / { \
try_files $uri $uri/ /index.html; \
} \
location /assets { \
expires 1y; \
add_header Cache-Control "public, immutable"; \
} \
}' > /etc/nginx/conf.d/default.conf
# Script d'entrée
COPY <<EOF /entrypoint.sh
#!/bin/sh
if [ "\$MODE" = "dev" ]; then
echo "🔧 Mode DEVELOPPEMENT"
exec npm run dev -- --host 0.0.0.0 --port 8080
else
echo "🚀 Mode PRODUCTION"
exec nginx -g "daemon off;"
fi
EOF
RUN chmod +x /entrypoint.sh
EXPOSE 8080
CMD ["/entrypoint.sh"]

View File

@@ -1,5 +1,5 @@
<template>
<div class="video-player-container">
<div class="video-player-container px-2 sm:px-0">
<div class="relative">
<!-- Video.js Player -->
<div data-vjs-player>

View File

@@ -71,7 +71,14 @@
</div>
<!-- Participation Badge -->
<div class="absolute top-2 right-2">
<div class="absolute top-2 right-2 flex gap-1">
<span
v-if="event.is_private"
class="px-2 py-1 rounded-full text-xs font-medium text-white bg-purple-600"
title="Événement privé"
>
🔒 Privé
</span>
<span
v-if="getUserParticipation(event)"
class="px-2 py-1 rounded-full text-xs font-medium text-white"
@@ -235,6 +242,68 @@
</div>
</div>
<!-- Type d'événement -->
<div>
<label class="label">Type d'événement</label>
<div class="flex gap-4">
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="newEvent.is_private"
:value="false"
class="mr-2"
>
<span class="text-sm">Public (visible par tous)</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
v-model="newEvent.is_private"
:value="true"
class="mr-2"
>
<span class="text-sm">Privé (invitations uniquement)</span>
</label>
</div>
</div>
<!-- Sélection des invités pour événements privés -->
<div v-if="newEvent.is_private">
<label class="label">Inviter des membres</label>
<div class="border border-gray-300 rounded-lg p-3 max-h-48 overflow-y-auto">
<div
v-for="user in users.filter(u => u.id !== authStore.user?.id)"
:key="user.id"
class="flex items-center justify-between py-2 border-b border-gray-100 last:border-b-0"
>
<div class="flex items-center">
<img
v-if="user.avatar_url"
:src="getMediaUrl(user.avatar_url)"
:alt="user.full_name"
class="w-8 h-8 rounded-full mr-2 object-cover"
>
<div v-else class="w-8 h-8 rounded-full bg-primary-100 flex items-center justify-center mr-2">
<User class="w-4 h-4 text-primary-600" />
</div>
<div>
<div class="text-sm font-medium text-gray-900">{{ user.full_name }}</div>
<div class="text-xs text-gray-500">@{{ user.username }}</div>
</div>
</div>
<input
type="checkbox"
:value="user.id"
v-model="newEvent.invited_user_ids"
class="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
>
</div>
</div>
<p class="text-xs text-gray-500 mt-2">
Sélectionnez les membres à inviter à cet événement privé
</p>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
@@ -294,7 +363,9 @@ const newEvent = ref({
description: '',
date: '',
location: '',
end_date: null
end_date: null,
is_private: false,
invited_user_ids: []
})
const eventMentions = ref([])
@@ -399,6 +470,12 @@ async function quickParticipation(eventId, status) {
async function createEvent() {
if (!newEvent.value.title || !newEvent.value.date) return
// Vérifier que des invités sont sélectionnés pour les événements privés
if (newEvent.value.is_private && (!newEvent.value.invited_user_ids || newEvent.value.invited_user_ids.length === 0)) {
toast.warning('Veuillez sélectionner au moins un membre à inviter pour un événement privé')
return
}
creating.value = true
try {
const eventData = {
@@ -406,7 +483,9 @@ async function createEvent() {
description: newEvent.value.description,
date: new Date(newEvent.value.date).toISOString(),
location: newEvent.value.location,
end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null
end_date: newEvent.value.end_date ? new Date(newEvent.value.end_date).toISOString() : null,
is_private: newEvent.value.is_private,
invited_user_ids: newEvent.value.is_private ? newEvent.value.invited_user_ids : null
}
await axios.post('/api/events', eventData)
@@ -416,7 +495,7 @@ async function createEvent() {
showCreateModal.value = false
resetForm()
toast.success('Événement créé avec succès')
toast.success(newEvent.value.is_private ? 'Événement privé créé avec succès' : 'Événement créé avec succès')
} catch (error) {
toast.error('Erreur lors de la création de l\'événement')
}
@@ -429,7 +508,9 @@ function resetForm() {
description: '',
date: '',
location: '',
end_date: null
end_date: null,
is_private: false,
invited_user_ids: []
}
}