Compare commits

..
52 Commits
Author SHA1 Message Date
Nell e6d6968e52 init 2026-08-09 20:07:00 +02:00
Nell 20beea24d5 init 2026-08-09 10:30:54 +02:00
Nell 93800e8460 init 2026-08-08 20:55:01 +02:00
Nell 8f3fd6a127 init 2026-08-08 20:19:33 +02:00
Nell 42ab990f7d init 2026-08-08 19:56:57 +02:00
Nell d1f9234457 init 2026-08-08 14:57:04 +02:00
Nell 0d8c86af16 init 2026-08-08 14:17:24 +02:00
Nell e9fe51363f init 2026-08-02 16:36:26 +02:00
Nell c10925b84b init 2026-08-02 13:11:11 +02:00
Nell bb4e17ba2f init 2026-08-02 12:07:51 +02:00
Nell aa486be6e5 init 2026-08-02 11:09:50 +02:00
Nell 659fd0f304 init 2026-08-02 10:34:18 +02:00
Nell 9dbb7ffd5b init 2026-08-02 10:13:49 +02:00
Nell ba51dde1c2 init 2026-08-02 09:17:39 +02:00
Nell 73379e9ca8 init 2026-08-01 22:26:43 +02:00
Nell b946cfd866 init 2026-08-01 14:11:19 +02:00
Nell 22b0bc36bb init 2026-08-01 08:59:35 +02:00
Nell fdc9fb592d init 2026-07-31 20:56:02 +02:00
Nell 621f8cefa0 init 2026-07-31 11:04:34 +02:00
Nell 086e5ab0ea init 2026-07-30 19:34:39 +02:00
Nell 1bb00e1edf init 2026-07-30 11:14:33 +02:00
Nell 759bc1dc15 post services integrations 2026-07-29 18:09:38 +02:00
Nell 6cb9acf98b init 2026-07-29 17:41:52 +02:00
Nell 98050bb770 init 2026-07-29 16:25:12 +02:00
Nell 9b705f0d96 init 2026-07-28 22:49:50 +02:00
Nell 066074dcd4 init 2026-07-28 08:56:14 +02:00
Nell 96ffe27040 init 2026-07-28 02:11:10 +02:00
Nell c29e38d2dc init 2026-07-27 10:47:27 +02:00
Nell ed4cb2a39c init 2026-07-27 10:38:22 +02:00
Nell 7b33b76b3d init 2026-07-27 09:37:36 +02:00
Nell 70c0b649e6 init 2026-07-27 09:15:28 +02:00
Nell d0e4bdd90e init 2026-07-27 00:24:43 +02:00
Nell 40e98bf3e2 init 2026-07-26 09:09:18 +02:00
Nell 068e100ca1 init 2026-07-25 17:19:36 +02:00
Nell 6fb8ab19aa init 2026-07-25 08:55:36 +02:00
Nell b54b60c988 init 2026-07-25 01:17:19 +02:00
Nell 62f7c6edba init 2026-07-19 19:27:02 +02:00
Nell 23998b9ea9 init 2026-07-18 00:49:23 +02:00
Nell 94b012465a init 2026-07-17 01:06:05 +02:00
Nell 8afa694aed init 2026-07-14 23:07:48 +02:00
Nell b7c48ce7f3 init 2026-07-14 02:51:24 +02:00
Nell b40373f3e3 init 2026-07-14 02:51:16 +02:00
Nell c96101ec3d init 2026-07-13 18:46:19 +02:00
Nell dc2be94a8d init 2026-07-13 16:16:00 +02:00
Nell 843dad8d79 init 2026-07-13 09:27:07 +02:00
Nell 09cf5f37fb init 2026-07-13 01:38:36 +02:00
Nell ede38cc042 init 2026-07-12 23:41:10 +02:00
Nell 13f188666d init 2026-07-12 18:01:58 +02:00
Nell 1979db2da3 init 2026-07-12 11:53:01 +02:00
Nell 2965ef8a5a init 2026-07-12 00:22:28 +02:00
Nell 796f74e4eb init 2026-07-11 21:36:57 +02:00
Nell 825db3a292 init 2026-07-11 11:21:09 +02:00
161 changed files with 11243 additions and 3465 deletions
-533
View File
@@ -1,533 +0,0 @@
Oui. Si tu gardes les bitflags, je simplifierais tes modèles autour de cette règle :
> Les permissions appartiennent aux **groupes** ou sont accordées directement à un **membre**, et chaque attribution
> possède un périmètre.
Je ne mettrais plus les permissions par défaut directement sur `server` et `channel`.
## 1. `user`
Je garderais quasiment ton modèle actuel :
```plain text
user
----
id
username
password
pub_key
created_at
updated_at
is_superuser
```
`is_superuser` reste un bypass global, indépendant des permissions dun serveur.
Je ne mettrais pas de permissions directement dans `user`, car les permissions doivent être contextuelles à un serveur.
## 2. `server`
Je supprimerais :
```plain text
default_server_permissions
default_channel_permissions
default_voice_permissions
```
Le serveur ne devrait pas porter directement les permissions. Il possède plutôt des groupes.
```plain text
server
------
id
name
password
created_at
updated_at
is_default
```
Les permissions par défaut seraient celles du groupe système `Everyone`.
## 3. `server_user`
Je garderais cette table pour lappartenance dun utilisateur à un serveur, mais je supprimerais probablement :
```plain text
is_admin
is_owner
server_permissions
channel_permissions
voice_permissions
```
Je remplacerais les statuts par des groupes :
```plain text
server_user
-----------
id
server_id
user_id
username
joined_at
updated_at
```
Puis :
```plain text
group_member
------------
group_id
user_id
```
Par exemple :
```plain text
Everyone
Membre
Modérateur
Administrateur
```
Lutilisateur propriétaire pourrait rester une exception structurelle :
```plain text
server.owner_id
```
ou être représenté par un groupe système très privilégié. Personnellement, je conserverais `owner_id` pour éviter quun
propriétaire perde accidentellement ses droits.
## 4. `group`
Ta table actuelle est déjà proche de ce quil faut :
```plain text
group
-----
id
server_id
name
is_default
created_at
updated_at
```
Je garderais les trois bitmasks :
```plain text
server_permissions
channel_permissions
voice_permissions
```
Donc :
```rust
pub struct Model {
pub id: Uuid,
pub server_id: Uuid,
pub name: String,
pub is_default: bool,
pub server_permissions: i64,
pub channel_permissions: i64,
pub voice_permissions: i64,
pub created_at: DateTimeUtc,
}
```
Chaque groupe est alors un rôle contenant un ensemble de permissions.
Exemples :
```plain text
Everyone :
READ_CHANNEL
SEND_MESSAGE
JOIN_CHANNEL
SPEAK
Modérateur :
DELETE_OTHERS_MESSAGES
MANAGE_MESSAGES
MUTE_OTHERS
```
## 5. `group_member`
Cest ici que jajouterais le périmètre.
Actuellement, un groupe est uniquement attribué à un utilisateur de manière globale au serveur :
```plain text
group_id
user_id
```
Pour permettre à un modérateur dagir seulement dans une catégorie ou un canal, il faut ajouter un scope.
### Option que je recommande
```plain text
group_member
------------
group_id
user_id
scope_type
scope_id
```
Exemples :
```plain text
Modérateur -> Alice -> server -> serveur A
Modérateur -> Bob -> category -> catégorie Support
Modérateur -> Claire -> channel -> canal Général
```
Avec un enum :
```rust
pub enum PermissionScopeType {
Server,
Category,
Channel,
}
```
Le problème est que `scope_id` peut pointer vers plusieurs tables. Il faudra donc valider la cohérence côté application,
ou utiliser trois colonnes nullable.
### Variante plus propre SQL
```plain text
group_member
------------
group_id
user_id
server_id
category_id
channel_id
```
Avec la règle :
```plain text
exactement une portée est définie
```
Mais cette variante est plus lourde à manipuler.
Pour un projet avec SeaORM, je choisirais probablement `scope_type + scope_id`, avec validation dans le service de
permissions.
## 6. Permissions individuelles
Tu as deux possibilités.
### Option simple : les stocker dans `server_user`
Pour une permission individuelle valable sur tout le serveur :
```plain text
server_user
-----------
server_permissions
channel_permissions
voice_permissions
```
Cela permet :
```plain text
Alice possède individuellement SEND_MESSAGE sur le serveur
```
Mais cette solution ne permet pas facilement une permission individuelle limitée à une catégorie ou un canal.
### Option plus flexible : créer `user_permission_scope`
Je recommande cette table :
```plain text
user_permission
---------------
id
user_id
server_id
category_id nullable
channel_id nullable
server_permissions
channel_permissions
voice_permissions
created_at
```
Exemples :
```plain text
Alice -> SEND_MESSAGE -> serveur A
Bob -> DELETE_OTHERS_MESSAGES -> catégorie Support
Claire -> SEND_MESSAGE -> canal Général
```
La colonne `server_id` est utile pour garantir que toutes les ressources appartiennent au bon serveur.
## 7. Que faire de `channel_user` ?
Ta table actuelle contient :
```plain text
channel_user
------------
channel_id
user_id
role
permissions
```
Je ne garderais pas `role` et `permissions` sous cette forme si tu as déjà `group` et `group_member`.
Je lutiliserais uniquement pour représenter une relation particulière au canal, par exemple :
```plain text
channel_user
------------
id
channel_id
user_id
joined_at
```
Ou je la supprimerais complètement si laccès au canal est déduit des permissions et de lappartenance au serveur.
Pour les permissions, je préfère `user_permission`, qui couvre déjà :
- serveur ;
- catégorie ;
- canal.
Sinon tu risques davoir trois systèmes concurrents :
```plain text
server_user.permissions
channel_user.permissions
group.permissions
```
et le calcul deviendra difficile à maintenir.
## 8. Que faire des permissions de `channel` ?
Je supprimerais :
```plain text
default_channel_permissions
default_voice_permissions
```
Le rôle par défaut `Everyone` du serveur les remplace.
Si tu veux quun canal accorde des permissions supplémentaires, tu peux utiliser une table dédiée :
```plain text
channel_permission_grant
------------------------
channel_id
group_id nullable
user_id nullable
server_permissions
channel_permissions
voice_permissions
```
Mais je ne lajouterais pas immédiatement. Commence avec :
```plain text
permissions serveur
permissions de groupe
permissions individuelles
```
Puis ajoute les overrides de canal uniquement si le besoin apparaît réellement.
## Structure que je choisirais maintenant
```plain text
user
server
category
channel
server_user
group
group_member
user_permission
```
### `server_user`
Appartenance au serveur :
```plain text
user_id
server_id
joined_at
```
### `group`
Permissions dun rôle :
```plain text
id
server_id
name
is_default
server_permissions
channel_permissions
voice_permissions
```
### `group_member`
Attribution dun rôle avec périmètre :
```plain text
group_id
user_id
scope_type
scope_id
```
### `user_permission`
Permission individuelle avec périmètre :
```plain text
id
user_id
server_id
scope_type
scope_id
server_permissions
channel_permissions
voice_permissions
```
## Calcul des permissions
Pour un utilisateur et un canal donné :
```plain text
permissions effectives =
permissions des groupes applicables
| permissions individuelles applicables
```
Les groupes applicables sont ceux dont le scope est :
```plain text
serveur parent
catégorie parente
canal courant
```
Exemple :
```plain text
Alice
Everyone sur le serveur
Modérateur sur la catégorie Support
permission individuelle sur le canal Général
```
Dans un canal de `Support`, elle reçoit :
```plain text
permissions Everyone
| permissions Modérateur
| permissions individuelles du canal
```
## Problème important avec `is_admin`
Je supprimerais `is_admin` de `server_user`.
Un administrateur devient simplement un groupe :
```plain text
Administrateur
```
avec les permissions correspondantes.
En revanche, je garderais probablement :
```plain text
server.owner_id
```
car le propriétaire est une relation métier particulière, pas seulement un ensemble de permissions.
## À propos de `i64`
Puisque tu stockes des valeurs `u64` dans des colonnes `i64`, je ferais au minimum une abstraction claire côté Rust :
```rust
fn permissions_from_db(value: i64) -> u64 {
value as u64
}
```
Et j’éviterais de disperser les conversions dans les modèles.
Si tu ne comptes pas utiliser le bit de poids fort, tu peux aussi décider officiellement que seuls les 63 premiers bits
sont valides.
## Recommandation finale
Je modifierais ton système comme ceci :
```plain text
Supprimer :
permissions de server
permissions de channel
is_admin de server_user
permissions de channel_user
role texte de channel_user
Garder :
is_superuser sur user
owner_id sur server
trois bitflags sur group
Ajouter :
scope aux attributions de groupes
table user_permission pour les permissions individuelles
```
Le modèle deviendrait :
```plain text
User
├── appartient à des serveurs
├── reçoit des groupes
└── peut recevoir des permissions individuelles
Group
└── possède trois bitflags
GroupMember
└── associe un groupe à un utilisateur dans un scope
UserPermission
└── associe directement des bitflags à un utilisateur dans un scope
```
Cest le compromis que je choisirais : **bitflags conservés, groupes réutilisables, permissions individuelles possibles,
et modération limitée par scope**.
@@ -0,0 +1,49 @@
---
sessionId: session-260725-084752-m7a6
---
# Requirements
### Overview & Goals
The goal is to design `RequireServerPermission<const P: u64>` and `RequireChannelPermission<const P: u64>` Axum extractors using const generics with bitflags, and provide clear documentation and usage examples for developers adding permissions to view handlers.
### Scope
- **In Scope:**
- Designing `RequireServerPermission<const PERM: u64>` and `RequireChannelPermission<const PERM: u64>` using const generics with `ServerPermission` and `ChannelPermission` bitflags.
- Designing the path parameter extraction strategy for scope (extracting `server_id` or `channel_id` from request extensions / path parameters).
- Handling superuser bypass (`is_superuser`) automatically.
- Adding detailed documentation and usage examples (`src/http/permissions.rs` doc comments / guide).
- **Out of Scope:**
- Modifying existing view handlers or database/repository schemas.
### Functional Requirements
- **FR1:** The extractor must support const generic bitflags.
- **FR2:** The extractor must automatically extract `CurrentUser`, check `is_superuser` for bypass, and fetch the required scope (`server_id` or `channel_id`).
- **FR3:** Unauthorized requests are rejected with `403 Forbidden`, unauthenticated with `401 Unauthorized`.
# Technical Design
### Current Implementation
- `CurrentUser` and `Superuser` extractors in `src/http/context.rs` implement `FromRequestParts`.
- `ServerPermission` and `ChannelPermission` are defined as `bitflags!` in `src/permissions.rs`.
### Key Decisions
- **Decision 1: Const Generics for Permission Extractors**
- *Choice:* Use `RequireServerPermission<const PERM: u64>` and `RequireChannelPermission<const PERM: u64>`.
- *Rationale:* Allows clean, declarative handler annotations.
- **Decision 2: Providing the Scope (`server_id` / `channel_id`)**
- *Choice:* Extract path parameters (`server_id` / `channel_id` / `id`) dynamically via Axum path parameters / extensions.
### Proposed Changes
1. **Implement `src/http/permissions.rs`:**
- Define `RequireServerPermission<const PERM: u64>` and `RequireChannelPermission<const PERM: u64>`.
- Implement `FromRequestParts`.
- Add extensive inline documentation and code examples showing how to annotate route handlers with `RequireServerPermission::<{ ServerPermission::MANAGE_SERVER.bits() }>` and `RequireChannelPermission::<{ ChannelPermission::READ_CHANNEL.bits() }>`.
### File Structure Changes
- **New File:** `src/http/permissions.rs`
# Testing
### Validation Approach
- Write unit/mock tests for the permission extractors.
+114
View File
@@ -0,0 +1,114 @@
---
sessionId: session-260727-093242-giao
---
# Requirements
### Overview & Goals
The goal of this task is to add an "Add Server" button and creation functionality to the frontend application, allowing users to create new servers directly from the UI. Backend entrypoints (`POST /servers`) are already fully implemented in Rust.
### Scope
- **In Scope:**
- Update `frontend/src/stores/server.ts` to include a `createServer` action communicating with `POST /servers`.
- Update `frontend/src/layouts/AppLayout.vue` to add an "Add Server" button in the left rail navigation drawer alongside existing server avatars.
- Add a server creation modal dialog (`v-dialog`) with input fields for server name and optional password.
- Handle loading states, validation (server name required), error handling, and automatically select or route to the newly created server.
- **Out of Scope:**
- Modifying backend server logic or endpoints (already implemented).
- Server permission editing UI enhancements (unless directly related to server creation flow).
### User Stories
- **As a user**, I want to click an "Add Server" button in the sidebar so that I can create a new server.
- **As a user**, I want a modal dialog where I can enter the server name and optional password so that my server is properly configured upon creation.
- **As a user**, I want the server list in the sidebar to update immediately when a new server is created so that I can switch to it right away.
### Functional Requirements
- **Sidebar Button:** An intuitive '+' or add icon button rendered in the rail navigation drawer of `AppLayout.vue`.
- **Creation Dialog:** A Vuetify modal dialog (`v-dialog`) containing:
- Text field for server name (required).
- Optional password field (optional).
- Cancel and Create buttons with loading state handling.
- **Store Integration:** `useServerStore` must provide a `createServer` action that performs `POST /servers` with the appropriate payload (`{ name, password, is_default }`).
# Technical Design
### Current Implementation
- **Backend:** `POST /servers` is implemented in `src/routes/server/handlers.rs` and accepts `CreateServerRequest` (`name`, `password`, `is_default`), returning `ServerResponse`. Note that it requires `Superuser` authorization (`_admin: Superuser`).
- **Frontend Stores:** `frontend/src/stores/server.ts` currently fetches servers via `GET /servers` into `state.servers`.
- **Frontend Layout:** `frontend/src/layouts/AppLayout.vue` renders a rail navigation drawer listing existing servers as avatars with router links to `/server/${server.id}`.
### Key Decisions
- **Placement of Add Server Button:** Placed at the bottom or top of the server list inside the rail navigation drawer of `AppLayout.vue`, consistent with Discord-like and existing channel creation patterns (`New Channel` button in `frontend/src/pages/server/index.vue`).
- **Dialog Pattern:** Reusing the exact modal dialog pattern (`v-dialog`, `v-card`, `v-text-field`, `v-btn`) already established in `frontend/src/pages/server/index.vue` for creating channels.
### Proposed Changes
1. **Server Store (`frontend/src/stores/server.ts`):**
- Add `createServer` action:
```typescript
async createServer(payload: { name: string; password?: string | null; is_default?: boolean }) {
const api = useApi();
const response = await api.post("/servers", payload);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create server');
}
const newServer = await response.json();
this.servers.push(newServer);
return newServer;
}
```
2. **AppLayout (`frontend/src/layouts/AppLayout.vue`):**
- Add a state ref for dialog visibility (`showDialog`), form data (`formData`), and submission status (`isSubmitting`).
- Add an add server button (`v-avatar` with `icon="mdi-plus"` or `v-btn`) inside the rail drawer.
- Add `v-dialog` template with inputs for server name and password.
- Wire `handleSubmit` to invoke `serverStore.createServer()`.
### File Structure
- **Modified Files:**
- `frontend/src/stores/server.ts`
- `frontend/src/layouts/AppLayout.vue`
### Architecture Diagram
```mermaid
graph LR
User -->|Clicks Add Server| AppLayout
AppLayout -->|Opens Dialog| Dialog[v-dialog]
Dialog -->|Submits Form| ServerStore[useServerStore]
ServerStore -->|POST /servers| Backend[Rust Backend API]
Backend -->|Created Server| ServerStore
ServerStore -->|Updates servers list| AppLayout
```
# Testing
### Validation Approach
- **Verification:** Ensure the Vue application builds successfully with TypeScript and Vite.
- **Manual/UI Verification Scenarios:**
1. Open the application layout (`AppLayout.vue`).
2. Click the plus button in the sidebar.
3. Verify the "Create Server" dialog appears with name and password fields.
4. Submit a valid server name and verify that a POST request is sent to `/servers`, the dialog closes, and the new server avatar appears in the sidebar.
5. Verify validation prevents submission when the server name is empty.
### Key Scenarios
- **Successful Creation:** Server is successfully created and appended to the sidebar list.
- **Validation Error:** Empty server name disables the create button or shows an alert.
- **API Error Handling:** Catch and log API failures gracefully.
# Delivery Steps
### * Step 1: Extend server Pinia store with createServer action
Extend the server Pinia store with createServer action.
- Update `frontend/src/stores/server.ts` to include `createServer(payload: { name: string; password?: string | null; is_default?: boolean })`.
- Handle error states, API POST requests to `/servers`, and state updates to push the newly created server to `this.servers`.
### Step 2: Add Add Server UI button and creation dialog to AppLayout
Add 'Add Server' UI button and creation dialog to AppLayout.
- Update `frontend/src/layouts/AppLayout.vue` to add a '+' or 'Add Server' button in the rail navigation drawer.
- Implement a reactive modal dialog (`v-dialog`) with form fields for server name and optional password.
- Wire up form submission to call `serverStore.createServer()`, handle loading state, and close/reset the dialog on success.
### Step 3: Verify frontend integration and build
Verify build and test integration.
- Ensure TypeScript types, Vuetify components, and pinia store integration are correct.
- Verify frontend compilation without errors.
@@ -0,0 +1,129 @@
---
sessionId: session-260713-161459-1v9e
---
# Requirements
### Objectif
Créer dans `src/domain/` les payloads explicites correspondant à **tous les événements actuellement émis par
`src/repositories/`**, afin quils soient réutilisables par plusieurs endpoints et consommateurs sans dépendre des
modèles SeaORM, des repositories ou des DTO Gateway.
### Inclus
- Remplacer les payloads implicites des 20 émissions recensées dans `category.rs`, `channel.rs`, `role`, `message.rs`,
`server.rs` et `user.rs` par des contrats de domaine dédiés.
- Couvrir les événements de création, mise à jour, suppression et changement utilisateur/serveur existants.
- Prévoir des payloads dédiés pour les données complètes des créations/mises à jour et pour les identifiants nécessaires
aux suppressions ; ne pas utiliser de tuple anonyme.
- Conserver les contrats de permissions déjà demandés : `ServerUserChanged`, `RoleUserChanged`,
`ServerRolePermissionChanged`, `ServerUserPermissionChanged`, `ChannelRolePermissionChanged` et
`ChannelUserPermissionChanged`.
- Ajouter les contrats CRUD pour les ressources `Category`, `Channel`, `Group`, `Message`, `Server` et `User`, ainsi que
les payloads didentifiants de suppression et le payload `UserChanged` si nécessaire pour `user_changed`.
- Utiliser `Uuid` et des noms explicites (`category_id`, `channel_id`, `group_id`/`role_id`, `message_id`, `server_id`,
`user_id`) plutôt que `id` lorsque le domaine est connu.
- Dériver `Clone` et `Debug` sur chaque struct, avec une composition compatible avec `Send + Sync + 'static`.
- Exposer les contrats depuis `crate::domain` pour une utilisation multi-endpoint.
### Exclus
- Aucun branchement des nouveaux types dans les repositories et aucune modification de `Repositories`.
- Aucun changement de nom de topic, de logique CRUD, dordre mutation puis émission ou de comportement de suppression.
- Aucun abonnement ou traitement dans `src/core/permission_sync.rs`.
- Aucun pont avec les événements ou DTO de `src/routes/gateway/mod.rs`.
- Aucun partage direct des modèles SeaORM comme contrat de domaine.
# Technical Design
### Contexte actuel
- Les 20 appels `EventBus::emit` sont répartis entre six repositories ; `computed_permission.rs` ne produit actuellement
aucun événement.
- Les créations et mises à jour transmettent des modèles SeaORM, tandis que les suppressions transmettent généralement
un `Uuid`; `server_user_created` transmet un tuple `(server_id, user_id)`.
- `message_deleted` et `user_deleted` vérifient déjà `rows_affected` avant émission, alors que dautres suppressions
devront conserver leur comportement actuel dans cette étape.
- `src/routes/gateway/mod.rs` consomme certains événements de canal avec ses propres modèles ; les contrats de domaine
resteront indépendants.
### Organisation proposée
- Ajouter `src/domain/mod.rs` et un sous-module par famille émettrice :
- `category.rs`, `channel.rs`, `role`, `message.rs`, `server.rs` et `user.rs` pour les événements CRUD et les
changements de relation.
- `server_role_permission.rs`, `server_user_permission.rs`, `channel_role_permission.rs` et
`channel_user_permission.rs` pour les contrats de permissions.
- Définir dans chaque module les payloads spécifiques nécessaires aux topics de sa famille, par exemple
`CategoryChanged`/`CategoryDeleted`, `ChannelChanged`/`ChannelDeleted`, `MessageChanged`/`MessageDeleted`,
`ServerChanged`/`ServerDeleted` et `UserChanged`/`UserDeleted`.
- Utiliser des structs distinctes lorsque les événements de création/mise à jour transportent plusieurs propriétés et
lorsquune suppression ne nécessite que lidentifiant ; le contenu exact doit refléter les informations actuellement
véhiculées par les modèles, sans importer SeaORM dans `domain`.
- Conserver `ServerUserChanged` et `RoleUserChanged` comme contrats relationnels identifiés, avec les champs
`server_id`, `role_id` et `user_id`.
- Réexporter sélectivement tous les contrats depuis `crate::domain`; ajouter `pub mod domain;` dans `src/lib.rs`.
- Ne modifier ni `src/repositories/mod.rs`, ni `src/core/permission_sync.rs`, ni les routes Gateway.
### Contrats de données
Tous les contrats suivent ce style :
```rust
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct ServerUserChanged {
pub server_id: Uuid,
pub user_id: Uuid,
}
#[derive(Clone, Debug)]
pub struct ChannelDeleted {
pub channel_id: Uuid,
}
```
Les payloads de ressource complète reprennent explicitement les champs utiles du modèle courant ; les payloads de
suppression reprennent lidentifiant nommé de la ressource. Les six contrats de permissions utilisent respectivement
`server_id`, `role_id`, `channel_id` et `user_id` selon leur responsabilité, sans bitmask implicite ni modèle persisté.
### Risques et garde-fous
- Les contrats CRUD devront être suffisamment complets pour ne pas perdre dinformation lors du futur branchement des
repositories ; la liste de champs sera vérifiée contre `src/models/*.rs`.
- Le changement ultérieur des payloads de topics de canal pourra nécessiter une adaptation du Gateway ; cette
intégration est explicitement exclue.
- Les payloads ne doivent pas être centralisés dans `repositories` ni dépendre de SeaORM, afin de rester utilisables par
plusieurs endpoints.
# Testing
### Validation
- Ajouter des assertions ou tests de compilation pour instancier chaque contrat public et accéder à tous ses champs.
- Vérifier les familles couvrant chaque émission : `category_*`, `channel_*`, `group_*`, `message_*`, `server_*` et
`user_*`, y compris les suppressions et `server_user_created`.
- Vérifier `Clone`, `Debug`, `Send`, `Sync` et `'static` pour tous les payloads.
- Vérifier les imports via `crate::domain::{...}` et exécuter `cargo check` ainsi que les tests ciblés.
### Limites de validation
Les émissions réelles, les changements de topics, la consommation par `PermissionSyncService`, les recalculs de
permissions et lintégration Gateway restent hors périmètre ; le demandeur réalisera leur branchement ultérieurement.
### ✓ Step 1: Recenser les émissions et les modèles
- Vérifier les topics et les champs actuellement transmis par chaque repository.
- Vérifier les modèles correspondants pour définir les contrats complets.
### ✓ Step 2: Créer les modules et payloads de domaine
- Ajouter les modules `src/domain/` et les structs CRUD, relationnelles et de permissions.
- Exposer les contrats depuis `crate::domain` sans modifier les repositories.
### ✓ Step 3: Ajouter la validation des contrats
- Ajouter des assertions de compilation couvrant les structs et leurs champs.
- Exécuter `cargo check` et les tests ciblés.
@@ -0,0 +1,40 @@
---
sessionId: session-260727-090636-1x6i
---
# Requirements
### Overview & Goals
The goal of this task is to add an "Add Server" button and creation modal on the frontend (leveraging existing backend server creation endpoints), and investigate/address any server scoping issues with channels and categories when multiple servers exist.
### Scope
- **In Scope**:
- Adding an "Add Server" button in the frontend layout/navigation (e.g. in `AppLayout.vue` or sidebar) with a creation dialog supporting server name, password, and default flags.
- Verifying and ensuring that channel and category queries correctly filter and isolate data per server.
- **Out of Scope**:
- Major backend restructuring (existing CRUD and filter endpoints are already fully implemented).
### User Stories
- **As a user**, I want an "Add Server" button in the UI so that I can easily create a new server.
- **As a user**, when I switch between servers, I want to ensure that channels and categories are strictly scoped to the active server.
### Functional Requirements
1. **Add Server UI (`AppLayout.vue`)**:
- Provide a prominent button in the sidebar to open the "Create Server" dialog.
- Collect server details (`name`, optional `password`, `is_default`) and call `serverStore.createServer`.
- On successful creation, navigate to the new server's view (`/server/${newServer.id}`).
2. **Server Scoping Verification**:
- Ensure channels and categories fetched for a server correctly correspond to that server ID.
# Technical Design
### Current Implementation
- Backend server creation (`POST /servers`) and listing (`GET /servers`) are fully implemented.
- Frontend `useServerStore` has `createServer` and `fetchServers`.
- `AppLayout.vue` already includes a dialog structure for creating servers triggered by an `mdi-plus` icon in the navigation drawer.
### Proposed Changes
1. **`frontend/src/layouts/AppLayout.vue`**:
- Ensure the server creation dialog and form bindings are fully wired up and intuitive.
2. **Review Channel & Category Scoping**:
- Verify that `useChannelStore.fetchChannels(serverId)` and `useCategoryStore.fetchCategories(serverId)` correctly pass `server_id` and that the backend filters properly.
+72
View File
@@ -0,0 +1,72 @@
---
sessionId: session-260729-161736-1psq
---
# Requirements
### Overview & Goals
Migrate all DTOs (Data Transfer Objects) currently located in `src/routes/<module>/dto.rs` into the `src/domain/` directory. This aligns the project architecture by separating domain types and DTOs from HTTP routing and handler implementation details.
### Scope
- **In Scope**:
- Moving all DTO files from `src/routes/<module>/dto.rs` to `src/domain/<module>/dto.rs` (or equivalent domain submodules).
- Updating all imports across the codebase (`src/routes/...`, handlers, mappers, etc.) to reference the new domain locations.
- Re-exporting or organizing modules cleanly in `src/domain/mod.rs`.
- **Out of Scope**:
- Modifying business logic or changing DTO field definitions.
- Changing database models (`src/models/`).
### Functional Requirements
- Every DTO previously defined in `src/routes/*/dto.rs` must be accessible under `src/domain/`.
- All handlers, mappers, and services must compile successfully after updating their imports.
- OpenAPI schema generation via `utoipa` must continue to function correctly with the relocated DTOs.
# Technical Design
### Current Implementation
Currently, each feature module in `src/routes/<module>/` contains a `dto.rs` file (along with `handlers.rs`, `mapper.rs`, `routes.rs`, `service.rs`, `domain.rs`). Meanwhile, `src/domain/` currently only contains `events/` and `mod.rs`.
### Key Decisions
- **Domain DTO Folder Organization**: Centralize all DTOs into a dedicated `src/domain/dto/` folder (e.g., `src/domain/dto/auth.rs`, `src/domain/dto/user.rs`, etc., declared in `src/domain/dto/mod.rs` and re-exported or accessed via `crate::domain::dto::<module>::*`).
- **Import Path Updates**: Update all `use crate::routes::<module>::dto::*` imports to `use crate::domain::dto::<module>::*` (or via `crate::domain::dto::*`).
### Proposed Changes
1. Create a `src/domain/dto/` directory with individual module files (e.g., `auth.rs`, `user.rs`, etc.) and a `src/domain/dto/mod.rs`.
2. Move the contents of `src/routes/<module>/dto.rs` to `src/domain/dto/<module>.rs`.
3. Update `src/domain/mod.rs` to declare `pub mod dto;` and configure `src/domain/dto/mod.rs`.
4. Update all files referencing `src/routes/<module>::dto` to point to `src/domain::dto::<module>` (or `crate::domain::dto::<module>`).
5. Remove `dto.rs` from each `src/routes/<module>/` directory and update `src/routes/<module>/mod.rs`.
### File Structure Changes
- **Added**:
- `src/domain/dto/mod.rs`
- `src/domain/dto/auth.rs` (and other DTO files like user, channel, message, category, role, attachment, core, etc.)
- **Modified**:
- `src/domain/mod.rs`
- `src/routes/<module>/mod.rs` for each migrated module (removing `pub mod dto;`)
- All handler, mapper, and route files importing the old DTO paths.
- **Removed**:
- `src/routes/<module>/dto.rs` for all modules.
# Testing
### Validation Approach
- Run `cargo check` to verify that all type references and module paths compile correctly.
- Run `cargo test` to ensure tests pass and there are no runtime regressions.
- Inspect OpenAPI generation / documentation endpoints to ensure `utoipa` correctly registers all DTO schemas.
# Delivery Steps
### ✓ Step 1: Create domain DTO directory and module structure
- Create `src/domain/dto/` directory along with `src/domain/dto/mod.rs`.
- Set up module declarations for each DTO file (auth, user, channel, message, category, role, attachment, core, etc.) under `src/domain/dto/`.
- Expose `pub mod dto;` in `src/domain/mod.rs`.
### ✓ Step 2: Migrate DTO files to `src/domain/dto/` and update imports
- Move each `dto.rs` file from `src/routes/<module>/dto.rs` into `src/domain/dto/<module>.rs`.
- Update all import statements across handlers, mappers, services, and route files in `src/routes/` and elsewhere to reference `crate::domain::dto::<module>::*`.
- Remove the old `dto.rs` files from `src/routes/<module>/` and remove `pub mod dto;` from `src/routes/<module>/mod.rs`.
### ✓ Step 3: Verify compilation and test suite
- Run `cargo check` and `cargo test` to ensure all DTO types resolve correctly and there are no broken imports or compilation errors.
- Verify OpenAPI schema generation (`utoipa`) correctly picks up the migrated DTO schemas.
@@ -0,0 +1,106 @@
---
sessionId: session-260713-090509-126z
---
# Requirements
### Objectif
Migrer les entités SeaORM de `src/models` du format `Relation`/`DeriveRelation`/`Related` vers le format SeaORM 2.x basé sur `#[sea_orm::model]`, avec les relations `HasOne` et `HasMany`, sans modifier le schéma SQL ni les contrats applicatifs.
### Périmètre inclus
- Migrer les quatre modèles pilotes `attachment`, `category`, `channel` et `message`.
- Migrer ensuite les autres entités présentes sous `src/models` : `server`, `user`, `role`, `server_user`, `role_user`, `channel_user`, `channel_user_permission`, `channel_role_permission`, `server_user_permission`, `server_role_permission` et `computed_permission`.
- Préserver les noms de tables/colonnes, les types nullable, les clés primaires et les comportements `Cascade`, `SetNull` et `NoAction`.
- Conserver les implémentations `ActiveModelBehavior` et leurs valeurs par défaut (`Uuid::new_v4`/`Uuid::now_v7`, `is_default`, etc.).
- Utiliser la syntaxe effectivement supportée par `sea-orm 2.0.0-rc.42`.
### Hors périmètre
- Aucun changement dans `src/repositories`, `src/routes`, DTO, réponses HTTP ou frontend.
- Aucun changement dans `migration/src/m20220101_000001_create_table.rs` ou dans le schéma SQL.
- Aucun ajout de tests spécifiques demandé dans cette étape.
- Aucun mapping ORM artificiel pour `computed_permission.resource_id`, qui reste polymorphe entre serveur, catégorie et channel.
### Critères dacceptation
- Chaque entité migrée utilise un seul format SeaORM 2.x, sans ancien enum `Relation` ou `impl Related` résiduel inutile.
- Les relations pilotes compilent avec la release candidate déclarée.
- La relation auto-référente de `Message` expose un parent optionnel et plusieurs réponses, avec `SetNull` à la suppression du parent.
- Les champs relationnels ne deviennent pas des colonnes d`ActiveModel`.
- Les commandes de compilation intermédiaires et finales réussissent, sans exiger à ce stade ladaptation des repositories.
# Technical Design
### État actuel
- `Cargo.toml` déclare `sea-orm = 2.0.0-rc.42` avec les backends SQLite/Postgres/MySQL et `schema-sync`; le workspace utilise Rust édition 2024.
- Les fichiers `src/models/attachment.rs`, `category.rs`, `channel.rs` et `message.rs` utilisent actuellement `DeriveEntityModel`, un enum `Relation` avec `DeriveRelation`, puis des `impl Related<...>`.
- `message.rs` contient déjà les cas particuliers `reply_to_id: Option<Uuid>`, `ReplyTo` auto-référent avec `SetNull` et `Replies` via `via_rel`.
- `channel.rs` combine relations vers des champs nullable (`server_id`, `category_id`), une relation `HasMany` vers les messages et lenum SQL `ChannelType`.
- Les autres entités suivent le même patron généré; leurs relations existantes doivent être transposées sans élargir le périmètre fonctionnel.
- `src/repositories/server.rs` utilise `category::Entity::find().find_with_related(channel::Entity)`. Cet usage est explicitement laissé inchangé pour cette étape; la compatibilité de compilation sera constatée, et son adaptation fera lobjet dune étape ultérieure si nécessaire.
### Décisions
- **Migration incrémentale** : commencer par `attachment`, `category`, `channel`, `message`, lancer `cargo check`, puis migrer les entités restantes par groupes dépendants.
- **Relations dans `Model`** : représenter les relations parent/enfant avec les types SeaORM 2.x validés par la release candidate (`HasOne`/`HasMany` ou leur forme exacte requise), en conservant les cardinalités et actions SQL.
- **Schéma inchangé** : les attributs de relation refléteront les migrations existantes; aucune migration SQL ne sera ajoutée.
- **Polymorphisme explicite** : ne pas déclarer de relation sur `computed_permission.resource_id`.
- **ActiveModel séparé** : vérifier que les champs de relation sont ignorés par les insertions/mises à jour et que les constructeurs `new()` restent inchangés.
### Modifications proposées
- Dans chaque fichier `src/models/*.rs`, ajouter lattribut de modèle SeaORM 2.x et déplacer les relations du bloc `Relation` vers les champs relationnels de `Model`.
- Remplacer les `impl Related` et les enums `Relation` devenus inutiles, sans modifier les champs scalaires ni les dérivations nécessaires aux DTO/OpenAPI.
- Pour `message.rs`, représenter `channel`, `user`, `reply_to`, `attachments` et `replies`; préserver la relation inverse auto-référente et laction `on_delete = SetNull`.
- Pour `channel.rs`, conserver `Category`, `Server`, `Message` et `ChannelUser`, avec les relations optionnelles cohérentes avec `category_id` et `server_id`.
- Pour `server.rs`, `user.rs`, `role.rs` et les tables de jonction/permissions, transposer les relations présentes dans leurs enums actuels et vérifier les colonnes source/cible contre la migration.
- Ne pas modifier `src/models/mod.rs` ou `prelude.rs` sauf si la syntaxe SeaORM 2.x lexige pour résoudre les types dentités.
### Fichiers concernés
- Pilote : `src/models/attachment.rs`, `src/models/category.rs`, `src/models/channel.rs`, `src/models/message.rs`.
- Lots suivants : `src/models/server.rs`, `user.rs`, `role.rs`, `server_user.rs`, `role_user.rs`, `channel_user.rs`, `channel_user_permission.rs`, `channel_role_permission.rs`, `server_user_permission.rs`, `server_role_permission.rs`, `computed_permission.rs`.
- Référence en lecture seule : `migration/src/m20220101_000001_create_table.rs`.
- Explicitement non modifiés : `src/repositories/**`, `src/routes/**`, `frontend/**` et les migrations.
### Risques et garde-fous
- La syntaxe de `#[sea_orm::model]` et des relations auto-référentes peut différer entre la documentation stable et `rc.42`; valider la version verrouillée avant d’écrire les modèles.
- Les relations inverses et les relations multiples vers la même entité peuvent provoquer des ambiguïtés de nommage; utiliser des noms de champs distincts et compiler après chaque groupe.
- Une compilation peut révéler que `find_with_related` nécessite encore lancien trait `Related`; ne pas corriger les repositories dans cette étape, mais documenter précisément le blocage pour la suite.
- Comparer chaque relation avec la migration pour éviter de transformer une colonne polymorphe ou nullable en relation incorrecte.
# Testing
### Validation autorisée
La validation est volontairement limitée à la compilation, conformément au périmètre demandé.
- Après le groupe pilote, exécuter `cargo check`.
- Après chaque groupe dentités restant, exécuter `cargo check`.
- En fin de migration, exécuter `cargo fmt --all -- --check` puis `cargo check`.
- Vérifier par recherche statique que les entités migrées ne contiennent plus dancien enum `Relation` ou d`impl Related` inutile.
- Vérifier manuellement que les repositories et routes nont pas été modifiés; leurs adaptations et tests relationnels sont reportés.
# Delivery Steps
### ✓ Step 1: Migrer les quatre modèles pilotes
Les entités pilotes utilisent le format SeaORM 2.x et compilent avec la release candidate verrouillée.
- Adapter `src/models/attachment.rs`, `category.rs`, `channel.rs` et `message.rs`.
- Déclarer les relations dans `Model` avec les types et la syntaxe exacts supportés par `sea-orm 2.0.0-rc.42`.
- Préserver `ChannelType`, les champs optionnels et les actions `Cascade`, `SetNull` et `NoAction`.
- Représenter dans `message.rs` le parent `reply_to` et les `replies` sans relation sur une colonne polymorphe.
- Exécuter `cargo check` immédiatement après ce pilote.
### ✓ Step 2: Migrer les entités de base et de jonction
Les modèles de serveurs, utilisateurs, rôles et tables de jonction sont convertis sans changement de colonnes SQL.
- Migrer `server.rs`, `user.rs`, `role.rs`, `server_user.rs`, `role_user.rs` et `channel_user.rs`.
- Migrer les entités de permissions `channel_user_permission.rs`, `channel_role_permission.rs`, `server_user_permission.rs` et `server_role_permission.rs`.
- Transposer chaque relation existante avec ses colonnes source/cible et sa cardinalité.
- Conserver les implémentations `ActiveModelBehavior` et les valeurs par défaut propres à chaque modèle.
- Exécuter `cargo check` après ce groupe.
### ✓ Step 3: Finaliser le modèle polymorphe et nettoyer les anciens patrons
Toutes les entités de `src/models` utilisent exclusivement le format SeaORM 2.x, tandis que `computed_permission` conserve son champ polymorphe sans faux lien ORM.
- Migrer `src/models/computed_permission.rs` en conservant `resource_id` comme simple colonne.
- Supprimer dans les entités migrées les enums `Relation`, dérivations et `impl Related` devenus inutiles.
- Vérifier que les relations ne sont pas incluses dans les colonnes d`ActiveModel`.
- Contrôler les noms de champs relationnels et les éventuelles ambiguïtés de relations multiples.
- Exécuter `cargo fmt --all -- --check` puis `cargo check`.
- Ne modifier ni repositories, ni routes, ni migrations; relever séparément toute incompatibilité de `find_with_related` pour une future étape.
@@ -0,0 +1,110 @@
---
sessionId: session-260729-174324-a4dc
---
# Requirements
### Overview & Goals
Currently, write operations (mutations) and `EventBus` emissions take place inside `Repositories`. This design poses two main challenges:
1. **Lack of Transactional Atomicity**: Multi-table writes (e.g., creating a channel while simultaneously inserting its order record into `server_item_order`) cannot share an atomic database transaction.
2. **Premature / Ghost Events**: Events are emitted inside Repositories before confirming whether higher-level multi-step operations or surrounding DB transactions committed successfully.
To solve this, we are refactoring to a lightweight **Command/Query separation**:
- **Services (Commands / Write Operations)**: Perform write operations, manage SeaORM database transactions (`db.begin()`), and emit `EventBus` events strictly **after** transaction commits.
- **Repositories (Queries / Read Operations)**: Focus on complex reads, queries, and filters. Event bus emissions are completely removed from Repositories.
### Scope
- **In Scope**:
- Removing `events: Arc<EventBus>` from `RepositoryContext` and `Repositories::new`.
- Stripping all `.events.emit(...)` calls from `CategoryRepository`, `ChannelRepository`, `MessageRepository`, `RoleRepository`, `ServerRepository`, `ServerItemOrderRepository`, and `UserRepository`.
- Creating/expanding dedicated domain services under `src/services/` (`ChannelService`, `ServerService`, `CategoryService`, `MessageService`, `UserService`, `RoleService`) with SeaORM transaction support and post-commit event emissions.
- Registering all domain services in `src/services/mod.rs` and `Services`.
- Updating Axum HTTP route handlers in `src/routes/` to delegate write operations (POST, PUT, DELETE) to `state.services` while keeping read operations (GET) on `state.repositories`.
- **Out of Scope**:
- Changing API DTO contracts or client-facing response schemas.
- Modifying underlying SeaORM database entities or table schemas.
### Functional Requirements
- **FR1**: Repositories must be strictly read/query-focused and contain zero event emissions or `EventBus` references.
- **FR2**: Write mutations (create, update, delete) and permission management must be executed inside domain Services.
- **FR3**: Multi-step writes (such as creating a server/channel and updating `server_item_order`) must execute within an atomic SeaORM transaction (`db.begin().await?`).
- **FR4**: `EventBus` events must only be emitted after the database transaction successfully commits.
- **FR5**: Axum route handlers must invoke service methods for all state-mutating requests (POST, PUT, DELETE) and repository methods for read requests (GET).
# Technical Design
### Current Implementation
- `RepositoryContext` in `src/repositories/mod.rs` holds both `db: DatabaseConnection` and `events: Arc<EventBus>`.
- Repositories (`ChannelRepository`, `ServerRepository`, `CategoryRepository`, `MessageRepository`, `UserRepository`, `RoleRepository`, `ServerItemOrderRepository`) execute `active.insert()`, `active.update()`, and delete operations directly and emit events immediately inside repository methods.
- Axum route handlers in `src/routes/*/handlers.rs` call repository write methods directly.
### Key Decisions
1. **Command / Query Responsibility Segregation**:
- Repositories handle data access, queries, filters, and read models.
- Services handle business logic, transactional boundaries (`db.begin()`), and event dispatching.
2. **Post-Commit Event Emission**:
- Events are only emitted after `txn.commit().await?` succeeds, preventing ghost/premature events on transaction rollback.
### Proposed Changes & Affected Files
1. **`src/repositories/mod.rs` & Repository Modules**:
- Modify `RepositoryContext` to remove `events: Arc<EventBus>`.
- Remove `.events.emit(...)` calls from:
- `src/repositories/category.rs`
- `src/repositories/channel.rs`
- `src/repositories/message.rs`
- `src/repositories/role.rs`
- `src/repositories/server.rs`
- `src/repositories/server_item_order.rs`
- `src/repositories/user.rs`
2. **`src/services/` Modules**:
- Expand `src/services/` with new service files:
- `channel.rs` (`ChannelService`)
- `server.rs` (`ServerService`)
- `category.rs` (`CategoryService`)
- `message.rs` (`MessageService`)
- `user.rs` (`UserService`)
- `role.rs` (`RoleService`)
3. **`src/services/mod.rs`**:
- Update `Services` struct and `Services::new` to initialize and expose all domain services.
4. **`src/routes/` Handlers**:
- Update write handlers across `src/routes/{channel,server,category,message,user,role}/handlers.rs` to invoke `state.services.*`.
### Architecture Diagram
```mermaid
graph LR
HTTP[Axum Handlers] -->|Write mutations| Services[Domain Services]
HTTP[Read requests] -->|Query/Filter| Repositories[Read Repositories]
Services -->|Transaction & DB mutations| DB[(SeaORM Database)]
Services -->|Post-commit emit| Events[EventBus]
Repositories -->|Read query| DB
```
# Delivery Steps
### ✓ Step 1: Clean up Repositories and Remove Write Event Emissions
Clean up Repositories and RepositoryContext
- Remove `events: Arc<EventBus>` from `RepositoryContext` in `src/repositories/mod.rs` and update `Repositories::new`.
- Strip write event emissions (`self.context.events.emit(...)`) from all repositories (`CategoryRepository`, `ChannelRepository`, `MessageRepository`, `RoleRepository`, `ServerRepository`, `ServerItemOrderRepository`, `UserRepository`).
- Ensure repository write methods operate purely on database connections/ActiveModels without triggering event bus emissions.
### ✓ Step 2: Create Domain Services with Transactional Atomicity and Post-Commit Events
Create Domain Services for Write Operations
- Create domain services in `src/services/` for channels, servers, categories, messages, users, and roles (e.g., `ChannelService`, `ServerService`, `CategoryService`, `MessageService`, `UserService`, `RoleService`).
- Implement SeaORM transaction management (`db.begin().await?`) in service write methods.
- Ensure `EventBus` events are emitted strictly **after** transaction commits.
- Handle multi-table transactional writes such as creating a channel while inserting into `server_item_order`.
### ✓ Step 3: Register Services in Services Container
Register Services in Services Container and Update State/Context
- Update `src/services/mod.rs` to include and initialize the new services (`channel`, `server`, `category`, `message`, `user`, `role`) within the `Services` struct and `ServicesContext`.
- Expose the updated `Services` container via `AppState` / `ServicesContext`.
### ✓ Step 4: Refactor Axum HTTP Route Handlers to Use Services
Refactor Axum HTTP Route Handlers
- Update write endpoints (POST, PUT, DELETE) across `src/routes/` to invoke service methods on `state.services` instead of repositories directly.
- Keep read endpoints (GET) using repositories for complex queries, filters, and tree generation.
### ✓ Step 5: Verification and Testing
Verification and Testing
- Run cargo build/check to ensure clean compilation across all modules.
- Verify integration checks: channel creation populates `server_item_order`, events are only triggered upon successful DB transaction commit.
Generated
+208 -209
View File
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -11,33 +11,33 @@ crate-type = ["rlib"]
members = [".", "migration", "event_bus"]
[dependencies]
tokio = { version = "1.52.3", features = ["full"] }
tokio = { version = "1.53.1", features = ["full"] }
axum = { version = "0.8", features = ["ws"] }
axum-extra = { version = "0.12.6", features = ["cookie"] }
config = "0.15.25"
sea-orm = { version = "2.0.0-rc.42", features = ["sqlx-sqlite", "sqlx-postgres", "sqlx-mysql", "runtime-tokio", "with-chrono", "with-uuid", "with-json", "schema-sync"] }
sea-orm = { version = "2.0.0", features = ["sqlx-sqlite", "sqlx-postgres", "sqlx-mysql", "runtime-tokio", "with-chrono", "with-uuid", "with-json", "schema-sync"] }
migration = { path = "migration" }
event_bus = { path = "event_bus" }
parking_lot = "0.12.5"
serde = "1.0.228"
serde_json = "1.0.150"
toml = "1.1.2"
uuid = { version = "1.23.4", features = ["v4", "v7", "fast-rng", "serde"] }
serde = "1.0.229"
serde_json = "1.0.151"
toml = "1.1.4"
uuid = { version = "1.24.0", features = ["v4", "v7", "fast-rng", "serde"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "time"] }
thiserror = "2"
utoipa = { version = "5", features = ["uuid", "chrono"] }
utoipa-swagger-ui = { version = "9", features = ["axum"] }
log = "0.4"
bitflags = "2.13.0"
bitflags = "2.13.1"
argon2 = { version = "0.6.0-rc.8", features = ["password-hash"] }
jsonwebtoken = { version = "10.4.0", features = ["aws_lc_rs"] }
jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] }
tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.7.0", features = ["catch-panic", "cors", "trace"] }
chrono = "0.4.45"
validator = { version = "0.20.0", features = ["derive"] }
async-trait = "0.1.89"
anyhow = "1.0.103"
validator = { version = "0.21.0", features = ["derive"] }
async-trait = "0.1.91"
anyhow = "1.0.104"
futures-util = "0.3"
form_urlencoded = "1.2.2"
time = "0.3.53"
time = "0.3.54"
+3 -3
View File
@@ -13,11 +13,11 @@ name = "event_bus_throughput"
harness = false
[dependencies]
tokio = { version = "1.52.3", default-features = false, features = ["rt", "sync"] }
glob = "0.3.3"
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
parking_lot = "0.12.5"
tracing = "0.1"
uuid = { version = "1.24.0", features = ["v4"] }
[dev-dependencies]
tokio = { version = "1.52.3", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time", "sync"] }
tokio = { version = "1.53.1", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time", "sync"] }
criterion = { version = "0.8.2", features = ["async_tokio"] }
-134
View File
@@ -9,7 +9,6 @@ use event_bus::EventBus;
use tokio::runtime::Runtime;
const TOPIC: &str = "bench-topic";
const PATTERN: &str = "bench-*";
#[derive(Clone)]
struct SmallEvent {
@@ -473,82 +472,6 @@ fn bench_typed_callback(c: &mut Criterion) {
group.finish();
}
fn bench_pattern_callback(c: &mut Criterion) {
let rt = runtime();
let mut group = c.benchmark_group("event_bus/pattern_callback");
group.throughput(Throughput::Elements(1));
group.bench_function("small_struct", |b| {
b.to_async(&rt).iter_custom(|iters| async move {
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
let received = Arc::new(AtomicU64::new(0));
let handler_count = Arc::clone(&received);
let subscription = bus.on_pattern::<SmallEvent, _>(PATTERN, move |topic, event| {
let _ = topic;
let _ = event.value;
handler_count.fetch_add(1, Ordering::Relaxed);
});
let start = Instant::now();
for i in 0..iters {
bus.emit(TOPIC, SmallEvent { value: i });
}
wait_until_received(&received, iters).await;
let elapsed = start.elapsed();
subscription.abort();
elapsed
});
});
group.bench_function("arc_payload_1kb", |b| {
b.to_async(&rt).iter_custom(|iters| async move {
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
let payload: Arc<[u8]> = Arc::from(vec![7_u8; 1024].into_boxed_slice());
let received = Arc::new(AtomicU64::new(0));
let handler_count = Arc::clone(&received);
let subscription =
bus.on_pattern::<ArcPayloadEvent, _>(PATTERN, move |topic, event| {
let _ = topic;
let _ = event.id;
let _ = event.payload.len();
handler_count.fetch_add(1, Ordering::Relaxed);
});
let start = Instant::now();
for i in 0..iters {
bus.emit(
TOPIC,
ArcPayloadEvent {
id: i,
payload: Arc::clone(&payload),
},
);
}
wait_until_received(&received, iters).await;
let elapsed = start.elapsed();
subscription.abort();
elapsed
});
});
group.finish();
}
fn bench_multiple_subscribers(c: &mut Criterion) {
let rt = runtime();
@@ -597,69 +520,12 @@ fn bench_multiple_subscribers(c: &mut Criterion) {
group.finish();
}
fn bench_multiple_patterns(c: &mut Criterion) {
let rt = runtime();
let mut group = c.benchmark_group("event_bus/multiple_patterns");
group.throughput(Throughput::Elements(1));
for pattern_count in [1_u64, 4, 16, 64, 256] {
group.bench_function(format!("{pattern_count}_patterns_one_match"), |b| {
b.to_async(&rt).iter_custom(|iters| async move {
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
let received = Arc::new(AtomicU64::new(0));
let mut subscriptions = Vec::with_capacity(pattern_count as usize);
for index in 0..pattern_count {
let pattern = if index == 0 {
PATTERN.to_string()
} else {
format!("unused-{index}-*")
};
let handler_count = Arc::clone(&received);
let subscription =
bus.on_pattern::<SmallEvent, _>(&pattern, move |topic, event| {
let _ = topic;
let _ = event.value;
handler_count.fetch_add(1, Ordering::Relaxed);
});
subscriptions.push(subscription);
}
let start = Instant::now();
for i in 0..iters {
bus.emit(TOPIC, SmallEvent { value: i });
}
wait_until_received(&received, iters).await;
let elapsed = start.elapsed();
for subscription in subscriptions {
subscription.abort();
}
elapsed
});
});
}
group.finish();
}
criterion_group!(
benches,
bench_emit_no_subscriber,
bench_raw_subscriber,
bench_typed_callback,
bench_pattern_callback,
bench_multiple_subscribers,
bench_multiple_patterns,
);
criterion_main!(benches);
+90 -161
View File
@@ -2,12 +2,14 @@ use std::any::Any;
use std::future::Future;
use std::sync::Arc;
use glob::Pattern;
use parking_lot::RwLock;
use std::collections::HashMap;
use std::iter;
use tokio::sync::broadcast;
use tokio::task::JoinHandle;
// use tracing::log::kv::{Key, Value};
use tracing::{debug, trace, warn};
use uuid::Uuid;
/// Raw event type: an atomic reference-counted pointer to any value.
pub type AnyEvent = Arc<dyn Any + Send + Sync>;
@@ -15,6 +17,51 @@ pub type AnyEvent = Arc<dyn Any + Send + Sync>;
/// Default buffer capacity for each broadcast channel.
const DEFAULT_CAPACITY: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScopeValue {
String(String),
Uuid(Uuid),
}
impl ScopeValue {
fn into_string(self) -> String {
match self {
Self::String(value) => value,
Self::Uuid(value) => value.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Scope {
pub key: String,
pub value: ScopeValue,
}
impl Scope {
pub fn new(key: impl Into<String>, value: ScopeValue) -> Self {
Self {
key: key.into(),
value,
}
}
pub fn uuid(key: impl Into<String>, value: Uuid) -> Self {
Self::new(key, ScopeValue::Uuid(value))
}
pub fn string(key: impl Into<String>, value: impl Into<String>) -> Self {
Self::new(key, ScopeValue::String(value.into()))
}
}
impl IntoIterator for Scope {
type Item = Scope;
type IntoIter = iter::Once<Scope>;
fn into_iter(self) -> Self::IntoIter {
iter::once(self)
}
}
/// The central event bus.
///
/// Share it via `Arc<EventBus>` across modules.
@@ -60,37 +107,10 @@ const DEFAULT_CAPACITY: usize = 64;
/// # tokio::time::sleep(std::time::Duration::from_millis(10)).await;
/// # });
/// ```
///
/// # Example — glob pattern with topic
/// ```rust,no_run
/// use std::sync::Arc;
/// use oxspeak_server_lib::event_bus::EventBus;
///
/// #[derive(Clone, Debug)]
/// struct User { name: String }
///
/// # tokio_test::block_on(async {
/// let bus = Arc::new(EventBus::new());
///
/// bus.on_pattern::<User, _>("user-*", |topic, user| {
/// match topic.as_str() {
/// "user-created" => println!("Created : {:?}", user),
/// "user-deleted" => println!("Deleted : {:?}", user),
/// other => println!("{}: {:?}", other, user),
/// }
/// });
///
/// bus.emit("user-created", User { name: "Alice".into() });
/// bus.emit("user-deleted", User { name: "Bob".into() });
/// # tokio::time::sleep(std::time::Duration::from_millis(10)).await;
/// # });
/// ```
#[derive(Debug)]
pub struct EventBus {
/// Channels indexed by exact topic.
channels: RwLock<HashMap<String, broadcast::Sender<AnyEvent>>>,
/// Channels for glob-pattern subscriptions.
patterns: RwLock<Vec<(Pattern, broadcast::Sender<(String, AnyEvent)>)>>,
capacity: usize,
}
@@ -103,7 +123,6 @@ impl EventBus {
);
Self {
channels: RwLock::new(HashMap::new()),
patterns: RwLock::new(Vec::new()),
capacity: DEFAULT_CAPACITY,
}
}
@@ -113,7 +132,6 @@ impl EventBus {
debug!("EventBus created with capacity {}", capacity);
Self {
channels: RwLock::new(HashMap::new()),
patterns: RwLock::new(Vec::new()),
capacity,
}
}
@@ -151,7 +169,6 @@ impl EventBus {
/// Emits an event on a topic.
///
/// - Pushes the event into the exact-topic channel (if subscribers exist).
/// - Pushes the event into all glob-pattern channels that match the topic.
/// - If nobody is listening, the event is silently dropped.
///
/// # Example
@@ -167,30 +184,37 @@ impl EventBus {
trace!(topic, "Emitting event");
let event: AnyEvent = Arc::new(event);
// Exact-topic subscribers
if let Some(tx) = self.channels.read().get(topic) {
let receiver_count = tx.receiver_count();
let _ = tx.send(Arc::clone(&event));
trace!(
topic,
receiver_count, "Event delivered to exact-topic channel"
);
self.emit_arc(topic, event);
}
// Glob-pattern subscribers
let patterns = self.patterns.read();
for (pattern, tx) in patterns.iter() {
if pattern.matches(topic) {
let receiver_count = tx.receiver_count();
let _ = tx.send((topic.to_string(), Arc::clone(&event)));
trace!(
topic,
pattern = pattern.as_str(),
receiver_count,
"Event delivered to pattern channel"
);
// todo : undocumented...
pub fn emit_scoped<T>(&self, topic: &str, scopes: impl IntoIterator<Item = Scope>, event: T)
where
T: Any + Send + Sync + 'static,
{
let event: AnyEvent = Arc::new(event);
// Émission sur le topic général.
self.emit_arc(topic, Arc::clone(&event));
// Émission sur chaque topic scoped.
for scope in scopes {
let scoped_topic = format!("{}:{}:{}", scope.key, scope.value.into_string(), topic);
self.emit_arc(&scoped_topic, Arc::clone(&event));
}
}
// todo : undocumented...
fn emit_arc(&self, topic: &str, event: AnyEvent) {
trace!(topic, "Emitting event");
if let Some(tx) = self.channels.read().get(topic) {
let receiver_count = tx.receiver_count();
let _ = tx.send(event);
trace!(topic, receiver_count, "Event delivered to channel");
}
}
// ─────────────────────────────────────────────────────────────────────────
@@ -307,135 +331,40 @@ impl EventBus {
})
}
/// Subscribes to all topics matching a glob pattern.
///
/// The handler receives `(topic, value)` — the topic name is included to
/// distinguish `user-created` from `user-deleted`, for example.
///
/// **No pre-registration required**: future topics are automatically covered.
/// Supports glob syntax: `*` (any sequence), `?` (one character),
/// `[abc]` (character class).
///
/// Returns a [`JoinHandle`] to cancel the subscription if needed.
///
/// # Example
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use oxspeak_server_lib::event_bus::EventBus;
/// # #[derive(Clone, Debug)] struct User { name: String }
/// # let bus = Arc::new(EventBus::new());
/// bus.on_pattern::<User, _>("user-*", |topic, user| {
/// match topic.as_str() {
/// "user-created" => println!("Created : {:?}", user),
/// "user-deleted" => println!("Deleted : {:?}", user),
/// other => println!("{}: {:?}", other, user),
/// }
/// });
///
/// bus.emit("user-created", User { name: "Alice".into() });
/// bus.emit("user-deleted", User { name: "Bob".into() });
/// ```
pub fn on_pattern<T, F>(&self, pattern: &str, handler: F) -> JoinHandle<()>
// todo : Undocumented
pub fn on_async_with<T, C, F, Fut>(&self, topic: &str, context: C, handler: F) -> JoinHandle<()>
where
T: Any + Send + Sync + Clone + 'static,
F: Fn(String, T) + Send + Sync + 'static,
{
let glob = Pattern::new(pattern).expect("invalid glob pattern");
let (tx, mut rx) = broadcast::channel(self.capacity);
self.patterns.write().push((glob, tx));
let pattern_owned = pattern.to_string();
debug!(pattern, "Sync pattern subscriber registered");
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok((topic, evt)) => {
if let Some(typed) = evt.downcast_ref::<T>() {
trace!(
topic,
pattern = pattern_owned,
"Sync pattern handler invoked"
);
handler(topic, typed.clone());
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(
pattern = pattern_owned,
skipped = n,
"Pattern subscriber lagged, messages dropped"
);
}
Err(broadcast::error::RecvError::Closed) => {
debug!(
pattern = pattern_owned,
"Channel closed, sync pattern subscriber exiting"
);
break;
}
}
}
})
}
/// Subscribes to all topics matching a glob pattern, with an **async** handler.
///
/// Returns a [`JoinHandle`] to cancel the subscription if needed.
///
/// # Example
/// ```rust,no_run
/// # use std::sync::Arc;
/// # use oxspeak_server_lib::event_bus::EventBus;
/// # #[derive(Clone, Debug)] struct User { name: String }
/// # let bus = Arc::new(EventBus::new());
/// bus.on_pattern_async::<User, _, _>("user-*", |topic, user| async move {
/// match topic.as_str() {
/// "user-created" => println!("(async) Created : {:?}", user),
/// "user-deleted" => println!("(async) Deleted : {:?}", user),
/// other => println!("(async) {}: {:?}", other, user),
/// }
/// });
///
/// bus.emit("user-created", User { name: "Alice".into() });
/// ```
pub fn on_pattern_async<T, F, Fut>(&self, pattern: &str, handler: F) -> JoinHandle<()>
where
T: Any + Send + Sync + Clone + 'static,
F: Fn(String, T) -> Fut + Send + Sync + 'static,
C: Clone + Send + Sync + 'static,
F: Fn(C, T) -> Fut + Send + Sync + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
let glob = Pattern::new(pattern).expect("invalid glob pattern");
let (tx, mut rx) = broadcast::channel(self.capacity);
self.patterns.write().push((glob, tx));
let mut rx = self.get_or_create_sender(topic).subscribe();
let topic_owned = topic.to_string();
let pattern_owned = pattern.to_string();
debug!(pattern, "Async pattern subscriber registered");
debug!(topic, "Async subscriber registered");
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok((topic, evt)) => {
Ok(evt) => {
if let Some(typed) = evt.downcast_ref::<T>() {
trace!(
topic,
pattern = pattern_owned,
"Async pattern handler invoked"
);
handler(topic, typed.clone()).await;
trace!(topic = topic_owned, "Async handler invoked");
handler(context.clone(), typed.clone()).await;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(
pattern = pattern_owned,
topic = topic_owned,
skipped = n,
"Pattern subscriber lagged, messages dropped"
"Subscriber lagged, messages dropped"
);
}
Err(broadcast::error::RecvError::Closed) => {
debug!(
pattern = pattern_owned,
"Channel closed, async pattern subscriber exiting"
topic = topic_owned,
"Channel closed, async subscriber exiting"
);
break;
}
+2 -115
View File
@@ -1,115 +1,3 @@
//! # Event Bus
//!
//! An asynchronous event bus for routing typed messages between modules
//! without direct coupling.
//!
//! ## Features
//!
//! - **Key → event mapping**: each topic (`&str`) is independent
//! - **Type-unrestricted**: any `T: Any + Send + Sync + Clone`
//! - **Targeted wake-up**: only subscribers of the matching topic are woken up
//! - **Callback API**: JavaScript-style — `bus.on("topic", |payload| { ... })`
//! - **Glob pattern**: `on_pattern("user-*", |topic, payload| { ... })`
//! - **Async handlers**: `on_async` and `on_pattern_async`
//!
//! ## Example — sync callback
//!
//! ```rust,no_run
//! use std::sync::Arc;
//! use oxspeak_server_lib::event_bus::EventBus;
//!
//! #[derive(Clone, Debug)]
//! struct User { name: String }
//!
//! # tokio_test::block_on(async {
//! let bus = Arc::new(EventBus::new());
//!
//! bus.on::<User>("user-connected", |user| {
//! println!("Connected: {:?}", user);
//! });
//!
//! bus.emit("user-connected", User { name: "Alice".into() });
//! # tokio::time::sleep(std::time::Duration::from_millis(10)).await;
//! # });
//! ```
//!
//! ## Example — async callback
//!
//! ```rust,no_run
//! use std::sync::Arc;
//! use oxspeak_server_lib::event_bus::EventBus;
//!
//! #[derive(Clone, Debug)]
//! struct User { name: String }
//!
//! # tokio_test::block_on(async {
//! let bus = Arc::new(EventBus::new());
//!
//! bus.on_async::<User, _, _>("user-connected", |user| async move {
//! println!("(async) Connected: {:?}", user);
//! });
//!
//! bus.emit("user-connected", User { name: "Bob".into() });
//! # tokio::time::sleep(std::time::Duration::from_millis(10)).await;
//! # });
//! ```
//!
//! ## Example — glob pattern (topic included in the callback)
//!
//! ```rust,no_run
//! use std::sync::Arc;
//! use oxspeak_server_lib::event_bus::EventBus;
//!
//! #[derive(Clone, Debug)]
//! struct User { name: String }
//!
//! # tokio_test::block_on(async {
//! let bus = Arc::new(EventBus::new());
//!
//! bus.on_pattern::<User, _>("user-*", |topic, user| {
//! match topic.as_str() {
//! "user-created" => println!("Created : {:?}", user),
//! "user-deleted" => println!("Deleted : {:?}", user),
//! other => println!("{}: {:?}", other, user),
//! }
//! });
//!
//! bus.emit("user-created", User { name: "Alice".into() });
//! bus.emit("user-deleted", User { name: "Bob".into() });
//! # tokio::time::sleep(std::time::Duration::from_millis(10)).await;
//! # });
//! ```
//!
//! ## Example — multi-type with `match_event!` (advanced)
//!
//! ```rust,no_run
//! use std::sync::Arc;
//! use oxspeak_server_lib::event_bus::EventBus;
//! use oxspeak_server_lib::match_event;
//!
//! #[derive(Clone, Debug)] struct User { name: String }
//! #[derive(Clone, Debug)] struct UdpMetric { value: f32 }
//!
//! # tokio_test::block_on(async {
//! let bus = Arc::new(EventBus::new());
//! let mut rx = bus.on_raw("mixed-topic");
//!
//! bus.emit("mixed-topic", User { name: "Alice".into() });
//!
//! if let Ok(evt) = rx.recv().await {
//! match_event!(evt,
//! User => |u| println!("User: {:?}", u),
//! UdpMetric => |m| println!("Metric: {:?}", m),
//! );
//! }
//! # });
//! ```
mod bus;
// Public re-exports
pub use bus::{AnyEvent, EventBus};
/// Downcasts an [`AnyEvent`] to one or more concrete types and executes
/// the matching closure if the type matches.
///
@@ -154,9 +42,8 @@ macro_rules! match_event {
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
mod bus;
pub use bus::{AnyEvent, EventBus, Scope, ScopeValue};
#[cfg(test)]
mod tests;
+1 -102
View File
@@ -1,6 +1,7 @@
use crate::{match_event, EventBus};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
#[derive(Clone, Debug, PartialEq)]
struct User {
name: String,
@@ -126,108 +127,6 @@ async fn test_on_async_callback() {
assert!(received.load(Ordering::SeqCst));
}
// ── on_pattern ────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_on_pattern_callback_receives_topic_and_payload() {
let bus = Arc::new(EventBus::new());
let created = Arc::new(AtomicBool::new(false));
let deleted = Arc::new(AtomicBool::new(false));
let c = Arc::clone(&created);
let d = Arc::clone(&deleted);
bus.on_pattern::<User, _>("user-*", move |topic, user| match topic.as_str() {
"user-created" if user.name == "Dave" => c.store(true, Ordering::SeqCst),
"user-deleted" if user.name == "Eve" => d.store(true, Ordering::SeqCst),
_ => {}
});
bus.emit(
"user-created",
User {
name: "Dave".into(),
},
);
bus.emit("user-deleted", User { name: "Eve".into() });
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
assert!(created.load(Ordering::SeqCst));
assert!(deleted.load(Ordering::SeqCst));
}
#[tokio::test]
async fn test_on_pattern_does_not_match_other_topics() {
let bus = Arc::new(EventBus::new());
let called = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&called);
bus.on_pattern::<User, _>("user-*", move |_, _| {
flag.store(true, Ordering::SeqCst);
});
bus.emit(
"server-created",
User {
name: "Ghost".into(),
},
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert!(!called.load(Ordering::SeqCst));
}
#[tokio::test]
async fn test_on_pattern_no_pre_registration_needed() {
// Le pattern est enregistré avant que le topic n'existe
let bus = Arc::new(EventBus::new());
let received = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&received);
bus.on_pattern::<User, _>("user-*", move |topic, user| {
if topic == "user-new-topic" && user.name == "Frank" {
flag.store(true, Ordering::SeqCst);
}
});
bus.emit(
"user-new-topic",
User {
name: "Frank".into(),
},
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert!(received.load(Ordering::SeqCst));
}
// ── on_pattern_async ──────────────────────────────────────────────────────
#[tokio::test]
async fn test_on_pattern_async_callback() {
let bus = Arc::new(EventBus::new());
let received = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&received);
bus.on_pattern_async::<User, _, _>("user-*", move |topic, user| {
let f = Arc::clone(&flag);
async move {
if topic == "user-created" && user.name == "Hank" {
f.store(true, Ordering::SeqCst);
}
}
});
bus.emit(
"user-created",
User {
name: "Hank".into(),
},
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert!(received.load(Ordering::SeqCst));
}
// ── on_raw + match_event! ─────────────────────────────────────────────────
#[tokio::test]
+1
View File
@@ -1 +1,2 @@
node_modules/
dist/
+1
View File
@@ -17,6 +17,7 @@
"dependencies": {
"@fontsource/roboto": "^5.2.10",
"@mdi/font": "7.4.47",
"highlight.js": "^11.11.1",
"markdown-it": "^14.3.0",
"pinia": "^3.0.4",
"vue": "^3.5.30",
+8 -1
View File
@@ -1,7 +1,14 @@
<template>
<router-view />
<v-snackbar v-model="notification.visible" :timeout="5000" location="bottom right">
<div class="font-weight-bold">{{ notification.title }}</div>
<div>{{ notification.message }}</div>
</v-snackbar>
</template>
<script lang="ts" setup>
//
import {useNotificationStore} from "@/stores/notification.ts";
const notification = useNotificationStore();
</script>
+33
View File
@@ -0,0 +1,33 @@
<script lang="ts" setup>
import {computed} from 'vue'
import {useContextMenu} from '@/composables/useContextMenu'
const {isOpen, position, items, closeContextMenu} = useContextMenu()
// Cible virtuelle pour positionner Vuetify v-menu à la position X, Y exacte
const targetPosition = computed(() => [position.value.x, position.value.y])
</script>
<template>
<v-menu
v-model="isOpen"
:close-on-content-click="true"
:target="targetPosition as any"
location="bottom start"
>
<v-list class="py-1" density="compact" min-width="180">
<v-list-item
v-for="(item, index) in items"
:key="index"
:base-color="item.color"
:disabled="item.disabled"
@click="item.action"
>
<template v-if="item.icon" #prepend>
<v-icon :icon="item.icon" size="small"/>
</template>
<v-list-item-title>{{ item.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
-103
View File
@@ -1,103 +0,0 @@
<template>
<v-container class="fill-height d-flex flex-column justify-center" max-width="1100">
<div>
<v-img
class="mb-4 font-weight-bold"
height="150"
src="@/assets/logo.png"
/>
<div class="mb-8 text-center">
<div class="text-body-medium font-weight-light mb-n1">Welcome to</div>
<div class="text-display-medium font-weight-bold">Vuetify</div>
</div>
<v-row>
<v-col cols="12">
<v-card
class="py-4"
color="surface-variant"
image="https://cdn.vuetifyjs.com/docs/images/one/create/feature.png"
rounded="lg"
variant="tonal"
>
<template #prepend>
<v-avatar class="ml-2 mr-4" icon="mdi-rocket-launch-outline" size="60" variant="tonal"/>
</template>
<template #image>
<v-img position="top right"/>
</template>
<template #title>
<div class="my-title my-uppercase text-headline-medium font-weight-bold">Get started</div>
</template>
<template #subtitle>
<div class="text-body-large">
Change this page by updating
<v-kbd>{{
`
<HelloWorld/>
` }}
</v-kbd>
in
<v-kbd>components/HelloWorld.vue</v-kbd>
.
</div>
</template>
</v-card>
</v-col>
<v-col v-for="link in links" :key="link.href" cols="6">
<v-card
:href="link.href"
:subtitle="link.subtitle"
:title="link.title"
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
rel="noopener noreferrer"
rounded="lg"
target="_blank"
variant="tonal"
>
<template #prepend>
<v-avatar :icon="link.icon" class="ml-2 mr-4" size="60" variant="tonal"/>
</template>
</v-card>
</v-col>
</v-row>
</div>
</v-container>
</template>
<script lang="ts" setup>
const links = [
{
href: 'https://vuetifyjs.com/',
icon: 'mdi-text-box-outline',
subtitle: 'Learn about all things Vuetify in our documentation.',
title: 'Documentation',
},
{
href: 'https://vuetifyjs.com/introduction/why-vuetify/#feature-guides',
icon: 'mdi-star-circle-outline',
subtitle: 'Explore available framework Features.',
title: 'Features',
},
{
href: 'https://vuetifyjs.com/components/all',
icon: 'mdi-widgets-outline',
subtitle: 'Discover components in the API Explorer.',
title: 'Components',
},
{
href: 'https://discord.vuetifyjs.com',
icon: 'mdi-account-group-outline',
subtitle: 'Connect with Vuetify developers.',
title: 'Community',
},
]
</script>
-35
View File
@@ -1,35 +0,0 @@
# Components
Vue template files in this folder are automatically imported.
## 🚀 Usage
Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it.
The following example assumes a component located at `src/components/MyComponent.vue`:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
//
</script>
```
When your template is rendered, the component's import will automatically be inlined, which renders to this:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
import MyComponent from '@/components/MyComponent.vue'
</script>
```
@@ -0,0 +1,63 @@
<script lang="ts" setup>
import {storeToRefs} from 'pinia'
import {useUserStore} from '@/stores/user'
defineProps<{
modelValue: boolean
}>()
const userStore = useUserStore()
const {users} = storeToRefs(userStore)
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
}>()
const getUserInitials = (username: string): string => {
if (!username) return '?'
return username.trim().slice(0, 2).toUpperCase()
}
const close = () => emit('update:modelValue', false)
</script>
<template>
<v-navigation-drawer
:model-value="modelValue"
location="right"
temporary
width="280"
@update:model-value="emit('update:modelValue', $event)"
>
<v-toolbar density="compact" flat>
<v-toolbar-title>Utilisateurs</v-toolbar-title>
<v-btn
aria-label="Fermer la liste des utilisateurs"
icon="mdi-close"
size="small"
variant="text"
@click="close"
></v-btn>
</v-toolbar>
<v-divider></v-divider>
<v-list v-if="users.length" density="compact">
<v-list-item
v-for="user in users"
:key="user.id"
:title="user.username"
>
<template #prepend>
<v-avatar color="primary" size="32">
<span class="text-caption font-weight-medium">{{ getUserInitials(user.username) }}</span>
</v-avatar>
</template>
</v-list-item>
</v-list>
<div v-else class="pa-4 text-medium-emphasis text-body-2">
Aucun utilisateur à afficher.
</div>
</v-navigation-drawer>
</template>
@@ -0,0 +1,90 @@
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {useCategoryStore} from '@/stores/category'
const props = defineProps<{
modelValue: boolean
serverId: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'created', category: any): void
}>()
const categoryStore = useCategoryStore()
const name = ref('')
const isSubmitting = ref(false)
const resetForm = () => {
name.value = ''
}
const handleClose = () => {
emit('update:modelValue', false)
resetForm()
}
const handleSubmit = async () => {
if (!name.value.trim()) {
alert('Category name is required')
return
}
isSubmitting.value = true
try {
const newCategory = await categoryStore.createCategory({
name: name.value.trim(),
server_id: props.serverId
})
emit('created', newCategory)
handleClose()
} catch (error) {
console.error('Failed to create category:', error)
} finally {
isSubmitting.value = false
}
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
resetForm()
}
}
)
</script>
<template>
<v-dialog :model-value="modelValue" width="400" @update:model-value="handleClose">
<v-card>
<v-card-title>Create Category</v-card-title>
<v-card-text>
<v-text-field
v-model="name"
autofocus
density="compact"
label="Category Name"
outlined
@keyup.enter="handleSubmit"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn :disabled="isSubmitting" variant="text" @click="handleClose">
Cancel
</v-btn>
<v-btn
:disabled="!name.trim()"
:loading="isSubmitting"
color="primary"
variant="tonal"
@click="handleSubmit"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,130 @@
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {useChannelStore} from '@/stores/channel'
const props = defineProps<{
modelValue: boolean
serverId: string
categoryId?: string | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'created', channel: any): void
}>()
const channelStore = useChannelStore()
const formData = ref({
name: '',
channel_type: 'text',
position: 0
})
const isSubmitting = ref(false)
const channelTypeOptions = [
{title: 'Text', value: 'text'},
{title: 'Voice', value: 'voice'},
{title: 'DM', value: 'dm'}
]
const resetForm = () => {
formData.value = {
name: '',
channel_type: 'text',
position: 0
}
}
const handleClose = () => {
emit('update:modelValue', false)
resetForm()
}
const handleSubmit = async () => {
if (!formData.value.name.trim()) {
alert('Channel name is required')
return
}
isSubmitting.value = true
try {
const newChannel = await channelStore.createChannel({
name: formData.value.name,
channel_type: formData.value.channel_type,
server_id: props.serverId,
category_id: props.categoryId ?? null,
position: formData.value.position
})
emit('created', newChannel)
handleClose()
} catch (error) {
console.error('Failed to create channel:', error)
} finally {
isSubmitting.value = false
}
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
resetForm()
}
}
)
</script>
<template>
<v-dialog :model-value="modelValue" width="400" @update:model-value="handleClose">
<v-card>
<v-card-title>Create Channel</v-card-title>
<v-card-text>
<div class="mt-4 space-y-4">
<v-text-field
v-model="formData.name"
density="compact"
label="Channel Name"
outlined
@keyup.enter="handleSubmit"
></v-text-field>
<v-select
v-model="formData.channel_type"
:items="channelTypeOptions"
density="compact"
label="Channel Type"
outlined
></v-select>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
:disabled="isSubmitting"
variant="text"
@click="handleClose"
>
Cancel
</v-btn>
<v-btn
:disabled="!formData.name.trim()"
:loading="isSubmitting"
color="primary"
variant="tonal"
@click="handleSubmit"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.space-y-4 {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>
@@ -0,0 +1,181 @@
<!--
Exemple d'utilisation :
<script lang="ts" setup>
import { ref } from 'vue'
import ChannelPermissionEditor from '@/components/permissions/ChannelPermissionEditor.vue'
import {
toChannelPermissionMask,
type ChannelPermissionMask,
} from '@/types/permissions'
const permissions = ref<ChannelPermissionMask>(
toChannelPermissionMask(0),
)
function saveChannelPermissions(
value: ChannelPermissionMask,
) {
permissions.value = value
// Appel usePermissions().setChannelRolePermission(...)
}
</script>
<template>
<ChannelPermissionEditor
v-model="permissions"
@save="saveChannelPermissions"
/>
</template>
-->
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {
CHANNEL_PERMISSION_DEFINITIONS,
type ChannelPermissionMask,
grantChannelPermission,
revokeChannelPermission,
} from '@/types/permissions'
const props = withDefaults(defineProps<{
modelValue: ChannelPermissionMask
loading?: boolean
readonly?: boolean
title?: string
}>(), {
loading: false,
readonly: false,
title: 'Permissions du canal',
})
const emit = defineEmits<{
'update:modelValue': [permissions: ChannelPermissionMask]
save: [permissions: ChannelPermissionMask]
}>()
const permissions = ref<ChannelPermissionMask>(props.modelValue)
watch(
() => props.modelValue,
(value) => {
permissions.value = value
},
)
const permissionGroups = computed(() => {
const groups = new Map<
string,
typeof CHANNEL_PERMISSION_DEFINITIONS
>()
for (const permission of CHANNEL_PERMISSION_DEFINITIONS) {
const group = groups.get(permission.category) ?? []
group.push(permission)
groups.set(permission.category, group)
}
const labels: Record<string, string> = {
chat: 'Discussion',
management: 'Gestion',
voice: 'Vocal',
}
return Array.from(groups.entries()).map(([key, items]) => ({
key,
label: labels[key] ?? key,
permissions: items,
}))
})
function isEnabled(bit: ChannelPermissionMask): boolean {
return (permissions.value & bit) === bit
}
function updatePermission(
bit: ChannelPermissionMask,
enabled: boolean,
): void {
if (props.readonly || props.loading) {
return
}
permissions.value = enabled
? grantChannelPermission(permissions.value, bit)
: revokeChannelPermission(permissions.value, bit)
emit('update:modelValue', permissions.value)
}
function save(): void {
if (props.readonly || props.loading) {
return
}
emit('save', permissions.value)
}
</script>
<template>
<v-card>
<v-card-title>{{ title }}</v-card-title>
<v-divider/>
<v-card-text>
<v-expansion-panels multiple>
<v-expansion-panel
v-for="group in permissionGroups"
:key="group.key"
:title="group.label"
>
<v-expansion-panel-text>
<v-list lines="two">
<v-list-item
v-for="permission in group.permissions"
:key="permission.key"
class="px-0"
>
<template #prepend>
<v-checkbox
:disabled="readonly || loading"
:model-value="isEnabled(permission.bit)"
color="primary"
hide-details
@update:model-value="
updatePermission(permission.bit, Boolean($event))
"
/>
</template>
<v-list-item-title>
{{ permission.label }}
</v-list-item-title>
<v-list-item-subtitle
v-if="permission.description"
>
{{ permission.description }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
<v-divider/>
<v-card-actions>
<v-spacer/>
<v-btn
:disabled="readonly || loading"
:loading="loading"
color="primary"
@click="save"
>
Enregistrer
</v-btn>
</v-card-actions>
</v-card>
</template>
@@ -0,0 +1,205 @@
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useUserStore} from '@/stores/user'
import {useApi} from '@/composables/useApi'
import {usePermissions} from '@/composables/usePermissions'
import ChannelPermissionEditor from './ChannelPermissionEditor.vue'
import {toChannelPermissionMask, type ChannelPermissionMask, type ChannelPermissions} from '@/types/permissions'
interface Role { id: string; server_id: string; name: string; is_default: boolean }
interface Channel { id: string; name?: string | null }
type TargetType = 'role' | 'user'
const props = defineProps<{ modelValue: boolean; channel: Channel | null; serverId: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const users = useUserStore()
const api = useApi()
const permissionsApi = usePermissions()
const roles = ref<Role[]>([])
const configured = ref<ChannelPermissions>({users: [], roles: []})
const targetType = ref<TargetType>('role')
const targetId = ref<string | null>(null)
const addTargetId = ref<string | null>(null)
const mask = ref<ChannelPermissionMask>(toChannelPermissionMask(0))
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const configuredEntries = computed(() => targetType.value === 'role' ? configured.value.roles : configured.value.users)
type PermissionEntry = ChannelPermissions['roles'][number] | ChannelPermissions['users'][number]
function entryTargetId(entry: PermissionEntry): string {
return 'role_id' in entry ? entry.role_id : entry.user_id
}
function entryName(entry: PermissionEntry): string {
const id = entryTargetId(entry)
return targetType.value === 'role'
? roles.value.find(role => role.id === id)?.name || id
: users.users.find(user => user.id === id)?.username || id
}
const availableTargets = computed(() => {
const used = new Set(configuredEntries.value.map(entryTargetId))
return targetType.value === 'role'
? roles.value.filter(role => !used.has(role.id))
: users.users.filter(user => !used.has(user.id))
})
const selectedName = computed(() => {
if (!targetId.value) return ''
if (targetType.value === 'role') return roles.value.find(role => role.id === targetId.value)?.name || targetId.value
return users.users.find(user => user.id === targetId.value)?.username || targetId.value
})
watch(() => props.modelValue, async open => {
if (open) await load()
else reset()
})
watch(targetType, () => { targetId.value = null; addTargetId.value = null; mask.value = toChannelPermissionMask(0); error.value = null })
watch(addTargetId, id => {
if (!id) return
targetId.value = id
mask.value = toChannelPermissionMask(0)
addTargetId.value = null
})
async function load() {
if (!props.channel) return
loading.value = true; error.value = null
try {
const [roleResponse] = await Promise.all([api.get(`/roles?server_id=${props.serverId}`), users.fetchUsers(props.serverId)])
if (!roleResponse.ok) throw new Error('Impossible de charger les rôles')
roles.value = (await roleResponse.json() as Role[]).filter(role => role.server_id === props.serverId)
configured.value = await permissionsApi.getChannelPermissions(props.channel.id)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur lors du chargement des permissions'
} finally { loading.value = false }
}
function selectExisting(id: string) {
targetId.value = id
const entry = configuredEntries.value.find(item => entryTargetId(item) === id)
mask.value = entry?.permissions ?? toChannelPermissionMask(0)
error.value = null
}
async function save(value: ChannelPermissionMask) {
if (!props.channel || !targetId.value) return
saving.value = true; error.value = null
try {
if (targetType.value === 'role') await permissionsApi.setChannelRolePermission(props.channel.id, targetId.value, value)
else await permissionsApi.setChannelUserPermission(props.channel.id, targetId.value, value)
configured.value = await permissionsApi.getChannelPermissions(props.channel.id)
mask.value = value
} catch (e) { error.value = e instanceof Error ? e.message : 'Erreur lors de la sauvegarde' }
finally { saving.value = false }
}
async function resetPermission() {
if (!props.channel || !targetId.value) return
saving.value = true; error.value = null
try {
if (targetType.value === 'role') await permissionsApi.removeChannelRolePermission(props.channel.id, targetId.value)
else await permissionsApi.removeChannelUserPermission(props.channel.id, targetId.value)
configured.value = await permissionsApi.getChannelPermissions(props.channel.id)
targetId.value = null
mask.value = toChannelPermissionMask(0)
} catch (e) {
const status = e instanceof Error ? (e as Error & {status?: number}).status : undefined
if (status !== 404) error.value = e instanceof Error ? e.message : 'Erreur lors de la réinitialisation'
} finally { saving.value = false }
}
function close() { emit('update:modelValue', false) }
function reset() { configured.value = {users: [], roles: []}; targetId.value = null; addTargetId.value = null; mask.value = toChannelPermissionMask(0); error.value = null }
</script>
<template>
<v-dialog :model-value="modelValue" max-width="760" @update:model-value="emit('update:modelValue', $event)">
<v-card>
<v-card-title>Permissions {{ channel?.name || 'Canal' }}</v-card-title>
<v-card-text>
<v-alert v-if="error" class="mb-4" type="error" density="compact">{{ error }}</v-alert>
<v-tabs v-model="targetType" class="mb-4">
<v-tab value="role">Rôles</v-tab>
<v-tab value="user">Membres</v-tab>
</v-tabs>
<v-row class="permission-layout" dense>
<v-col cols="12" md="4" class="permission-sidebar">
<div class="text-subtitle-2 mb-2">Cibles configurées</div>
<v-list v-if="!loading && configuredEntries.length" density="compact" lines="one" border class="permission-list">
<v-list-item
v-for="entry in configuredEntries"
:key="entry.id"
:active="targetId === entryTargetId(entry)"
@click="selectExisting(entryTargetId(entry))"
>
<v-list-item-title>{{ entryName(entry) }}</v-list-item-title>
<template #append><v-chip size="x-small" variant="tonal">{{ entry.permissions.toString(2).replace(/^0+/, '').length || 0 }}</v-chip></template>
</v-list-item>
</v-list>
<div v-else-if="!loading" class="text-medium-emphasis text-body-2 py-4">Aucune permission directe configurée.</div>
<v-skeleton-loader v-else type="list-item-two-line" />
<v-select
v-model="addTargetId"
:items="availableTargets"
:item-title="targetType === 'role' ? 'name' : 'username'"
item-value="id"
:label="targetType === 'role' ? 'Ajouter un rôle' : 'Ajouter un membre'"
:loading="loading"
clearable
class="mt-3"
hide-details
/>
</v-col>
<v-col cols="12" md="8">
<div v-if="targetId" class="permission-editor">
<v-chip class="mb-3" color="primary" size="small">{{ selectedName }}</v-chip>
<ChannelPermissionEditor
v-model="mask"
:loading="saving"
title="Permissions du canal"
@save="save"
/>
<v-btn class="mt-3" color="error" variant="text" :loading="saving" @click="resetPermission">Réinitialiser les permissions directes</v-btn>
</div>
<v-sheet v-else class="empty-editor d-flex align-center justify-center text-center text-medium-emphasis" rounded border>
Sélectionnez une cible à gauche ou ajoutez-en une pour modifier ses permissions.
</v-sheet>
</v-col>
</v-row>
</v-card-text>
<v-card-actions><v-spacer/><v-btn variant="text" @click="close">Fermer</v-btn></v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.permission-layout {
min-height: 390px;
}
.permission-sidebar {
border-right: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.permission-list {
max-height: 280px;
overflow-y: auto;
}
.empty-editor {
min-height: 360px;
padding: 2rem;
}
@media (max-width: 959px) {
.permission-sidebar {
border-right: 0;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
padding-bottom: 1rem;
}
.empty-editor {
min-height: 180px;
}
}
</style>
@@ -0,0 +1,175 @@
<!--
Exemple d'utilisation :
<script lang="ts" setup>
import { ref } from 'vue'
import ServerPermissionEditor from '@/components/permissions/ServerPermissionEditor.vue'
import {
toServerPermissionMask,
type ServerPermissionMask,
} from '@/types/permissions'
const permissions = ref<ServerPermissionMask>(
toServerPermissionMask(0),
)
function saveServerPermissions(
value: ServerPermissionMask,
) {
permissions.value = value
// Appel usePermissions().setServerRolePermission(...)
}
</script>
<template>
<ServerPermissionEditor
v-model="permissions"
@save="saveServerPermissions"
/>
</template>
-->
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {
grantServerPermission,
revokeServerPermission,
SERVER_PERMISSION_DEFINITIONS,
type ServerPermissionMask,
} from '@/types/permissions'
const props = withDefaults(defineProps<{
modelValue: ServerPermissionMask
loading?: boolean
readonly?: boolean
title?: string
}>(), {
loading: false,
readonly: false,
title: 'Permissions du serveur',
})
const emit = defineEmits<{
'update:modelValue': [permissions: ServerPermissionMask]
save: [permissions: ServerPermissionMask]
}>()
const permissions = ref<ServerPermissionMask>(props.modelValue)
watch(
() => props.modelValue,
(value) => {
permissions.value = value
},
)
const permissionGroups = computed(() => {
const groups = new Map<
string,
typeof SERVER_PERMISSION_DEFINITIONS
>()
for (const permission of SERVER_PERMISSION_DEFINITIONS) {
const group = groups.get(permission.category) ?? []
group.push(permission)
groups.set(permission.category, group)
}
return Array.from(groups.entries()).map(([key, items]) => ({
key,
label: key === 'management' ? 'Gestion' : 'Membres',
permissions: items,
}))
})
function isEnabled(bit: ServerPermissionMask): boolean {
return (permissions.value & bit) === bit
}
function updatePermission(
bit: ServerPermissionMask,
enabled: boolean,
): void {
if (props.readonly || props.loading) {
return
}
permissions.value = enabled
? grantServerPermission(permissions.value, bit)
: revokeServerPermission(permissions.value, bit)
emit('update:modelValue', permissions.value)
}
function save(): void {
if (props.readonly || props.loading) {
return
}
emit('save', permissions.value)
}
</script>
<template>
<v-card>
<v-card-title>{{ title }}</v-card-title>
<v-divider/>
<v-card-text>
<v-expansion-panels multiple>
<v-expansion-panel
v-for="group in permissionGroups"
:key="group.key"
:title="group.label"
>
<v-expansion-panel-text>
<v-list lines="two">
<v-list-item
v-for="permission in group.permissions"
:key="permission.key"
class="px-0"
>
<template #prepend>
<v-checkbox
:disabled="readonly || loading"
:model-value="isEnabled(permission.bit)"
color="primary"
hide-details
@update:model-value="
updatePermission(permission.bit, Boolean($event))
"
/>
</template>
<v-list-item-title>
{{ permission.label }}
</v-list-item-title>
<v-list-item-subtitle
v-if="permission.description"
>
{{ permission.description }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
<v-divider/>
<v-card-actions>
<v-spacer/>
<v-btn
:disabled="readonly || loading"
:loading="loading"
color="primary"
@click="save"
>
Enregistrer
</v-btn>
</v-card-actions>
</v-card>
</template>
@@ -0,0 +1,230 @@
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {storeToRefs} from 'pinia'
import {useServerStore} from '@/stores/server'
import {useRoleStore} from '@/stores/role'
import {useUserStore} from '@/stores/user'
import {usePermissions} from '@/composables/usePermissions'
import {toServerPermissionMask, type ServerPermissionMask, type ServerUserPermission} from '@/types/permissions'
import ServerPermissionEditor from '@/components/permissions/ServerPermissionEditor.vue'
const props = defineProps<{modelValue: boolean; serverId: string; serverName: string}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const serverStore = useServerStore()
const roleStore = useRoleStore()
const userStore = useUserStore()
const permissionsApi = usePermissions()
const {roles} = storeToRefs(roleStore)
const activeTab = ref('general')
const selectedRoleId = ref<string | null>(null)
const selectedUserId = ref<string | null>(null)
const name = ref('')
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const roleForm = ref('')
const rolePermissions = ref<ServerPermissionMask>(toServerPermissionMask(0))
const memberPermissions = ref<ServerPermissionMask>(toServerPermissionMask(0))
const userPermissions = ref<Record<string, ServerUserPermission>>({})
const selectedRole = computed(() => roles.value.find(role => role.id === selectedRoleId.value) || null)
const selectedMembers = computed(() => selectedRoleId.value ? roleStore.members[selectedRoleId.value] || [] : [])
const availableUsers = computed(() => userStore.users.filter(user => !selectedMembers.value.some(member => member.id === user.id)))
const memberToAdd = ref<string | null>(null)
const selectedUser = computed(() => userStore.users.find(user => user.id === selectedUserId.value) || null)
watch(() => props.modelValue, async open => {
if (open) await load()
}, {immediate: true})
watch(selectedRoleId, async roleId => {
if (!roleId) return
roleForm.value = selectedRole.value?.name || ''
rolePermissions.value = toServerPermissionMask(0)
try {
await roleStore.fetchMembers(roleId)
const permission = await permissionsApi.getServerRolePermission(props.serverId, roleId)
rolePermissions.value = permission.permissions
} catch (e) {
if ((e as Error & {status?: number}).status !== 404) error.value = e instanceof Error ? e.message : 'Erreur de chargement'
}
})
watch(selectedUserId, userId => {
memberPermissions.value = userId && userPermissions.value[userId]
? userPermissions.value[userId].permissions
: toServerPermissionMask(0)
})
async function load() {
loading.value = true
error.value = null
name.value = props.serverName
try {
const [server, , , permissions] = await Promise.all([
serverStore.fetchServer(props.serverId),
roleStore.fetchRoles(props.serverId),
userStore.fetchUsers(props.serverId),
permissionsApi.listServerUserPermissions(props.serverId),
])
name.value = server.name
userPermissions.value = Object.fromEntries(permissions.map(permission => [permission.user_id, permission]))
} catch (e) {
name.value = ''
error.value = e instanceof Error ? e.message : 'Erreur de chargement'
}
finally { loading.value = false }
}
async function saveServer() {
if (!name.value.trim()) return
saving.value = true
try { await serverStore.updateServer(props.serverId, {name: name.value.trim()}) }
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de sauvegarde' }
finally { saving.value = false }
}
async function createRole() {
if (!roleForm.value.trim()) return
try { const role = await roleStore.createRole({server_id: props.serverId, name: roleForm.value.trim()}); selectedRoleId.value = role.id; roleForm.value = '' }
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de création' }
}
async function saveRole() {
if (!selectedRoleId.value || !roleForm.value.trim()) return
try {
await roleStore.updateRole(selectedRoleId.value, {name: roleForm.value.trim()})
await permissionsApi.setServerRolePermission(props.serverId, selectedRoleId.value, rolePermissions.value)
} catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de sauvegarde' }
}
async function deleteRole() {
if (!selectedRoleId.value || selectedRole.value?.is_default) return
try { await roleStore.deleteRole(selectedRoleId.value); selectedRoleId.value = null }
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de suppression' }
}
async function addMember() {
if (!selectedRoleId.value || !memberToAdd.value) return
try { await roleStore.addMember(selectedRoleId.value, memberToAdd.value); memberToAdd.value = null }
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur dajout' }
}
async function savePermissions(value: ServerPermissionMask) {
if (!selectedRoleId.value) return
try { await permissionsApi.setServerRolePermission(props.serverId, selectedRoleId.value, value); rolePermissions.value = value }
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de permissions' }
}
async function saveMemberPermissions(value: ServerPermissionMask) {
if (!selectedUserId.value) return
try {
const permission = await permissionsApi.setServerUserPermission(props.serverId, selectedUserId.value, value)
userPermissions.value[selectedUserId.value] = permission
memberPermissions.value = value
} catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de permissions' }
}
async function resetMemberPermissions() {
if (!selectedUserId.value) return
try {
await permissionsApi.removeServerUserPermission(props.serverId, selectedUserId.value)
delete userPermissions.value[selectedUserId.value]
memberPermissions.value = toServerPermissionMask(0)
} catch (e) {
const status = (e as Error & {status?: number}).status
if (status !== 404) error.value = e instanceof Error ? e.message : 'Erreur de réinitialisation'
}
}
</script>
<template>
<v-dialog :model-value="modelValue" max-width="980" @update:model-value="emit('update:modelValue', $event)">
<v-card min-height="620">
<v-card-title>Paramètres du serveur</v-card-title>
<v-card-text>
<v-alert v-if="error" type="error" density="compact" class="mb-4">{{ error }}</v-alert>
<v-row class="settings-layout" no-gutters>
<v-col cols="12" md="3" class="settings-sidebar">
<v-list density="compact" nav>
<v-list-item title="Général" prepend-icon="mdi-cog" :active="activeTab === 'general'" @click="activeTab = 'general'" />
<v-list-item title="Rôles" prepend-icon="mdi-shield-account" :active="activeTab === 'roles'" @click="activeTab = 'roles'" />
<v-list-item title="Membres" prepend-icon="mdi-account-cog" :active="activeTab === 'members'" @click="activeTab = 'members'" />
</v-list>
</v-col>
<v-col cols="12" md="9" class="pa-5">
<v-progress-linear v-if="loading" indeterminate class="mb-4" />
<template v-if="activeTab === 'general'">
<div class="text-h6 mb-4">Général</div>
<v-text-field v-model="name" label="Nom du serveur" :disabled="loading || saving" />
<v-btn color="primary" :loading="saving" :disabled="loading || !name.trim()" @click="saveServer">Enregistrer</v-btn>
</template>
<template v-else-if="activeTab === 'roles'">
<div class="text-h6 mb-4">Rôles</div>
<v-row>
<v-col cols="12" md="4">
<v-list border density="compact" class="role-list">
<v-list-item v-for="role in roles" :key="role.id" :title="role.name" :active="role.id === selectedRoleId" @click="selectedRoleId = role.id">
<template #append><v-icon v-if="role.is_default" icon="mdi-star" size="small" /></template>
</v-list-item>
</v-list>
<v-text-field v-model="roleForm" class="mt-3" label="Nouveau rôle" hide-details @keyup.enter="createRole" />
<v-btn class="mt-2" block color="primary" variant="tonal" @click="createRole">Ajouter un rôle</v-btn>
</v-col>
<v-col cols="12" md="8">
<template v-if="selectedRole">
<v-text-field v-model="roleForm" label="Nom du rôle" />
<div class="d-flex ga-2 mb-4"><v-btn color="primary" @click="saveRole">Enregistrer</v-btn><v-btn v-if="!selectedRole.is_default" color="error" variant="text" @click="deleteRole">Supprimer</v-btn></div>
<v-select v-model="memberToAdd" :items="availableUsers" item-title="username" item-value="id" label="Ajouter un membre" clearable @update:model-value="addMember" />
<v-list density="compact" border class="mb-4">
<v-list-item v-for="member in selectedMembers" :key="member.id" :title="member.username">
<template #append><v-btn icon="mdi-close" size="small" variant="text" @click="roleStore.removeMember(selectedRoleId!, member.id)" /></template>
</v-list-item>
</v-list>
<ServerPermissionEditor v-model="rolePermissions" title="Permissions du rôle" @save="savePermissions" />
</template>
<v-sheet v-else class="empty-selection d-flex align-center justify-center text-medium-emphasis" border rounded>Sélectionnez un rôle.</v-sheet>
</v-col>
</v-row>
</template>
<template v-else>
<div class="text-h6 mb-4">Permissions des membres</div>
<v-row>
<v-col cols="12" md="4">
<v-list border density="compact" class="role-list">
<v-list-item
v-for="user in userStore.users"
:key="user.id"
:title="user.username"
:active="user.id === selectedUserId"
@click="selectedUserId = user.id"
>
<template #append>
<v-icon v-if="userPermissions[user.id]" icon="mdi-shield-check" size="small" />
</template>
</v-list-item>
</v-list>
</v-col>
<v-col cols="12" md="8">
<template v-if="selectedUser">
<v-chip class="mb-4" color="primary" size="small">{{ selectedUser.username }}</v-chip>
<ServerPermissionEditor
v-model="memberPermissions"
title="Permissions directes du membre"
@save="saveMemberPermissions"
/>
<v-btn class="mt-3" color="error" variant="text" @click="resetMemberPermissions">
Réinitialiser les permissions directes
</v-btn>
</template>
<v-sheet v-else class="empty-selection d-flex align-center justify-center text-medium-emphasis" border rounded>
Sélectionnez un membre.
</v-sheet>
</v-col>
</v-row>
</template>
</v-col>
</v-row>
</v-card-text>
<v-card-actions><v-spacer /><v-btn variant="text" @click="emit('update:modelValue', false)">Fermer</v-btn></v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.settings-layout { min-height: 500px; }
.settings-sidebar { border-right: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); }
.role-list { max-height: 250px; overflow-y: auto; }
.empty-selection { min-height: 400px; }
@media (max-width: 959px) { .settings-sidebar { border-right: 0; border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); } }
</style>
@@ -0,0 +1,36 @@
import {ref} from 'vue'
export interface MenuItem {
label: string
icon?: string
color?: string
disabled?: boolean
action: () => void
}
const isOpen = ref(false)
const position = ref({x: 0, y: 0})
const items = ref<MenuItem[]>([])
export function useContextMenu() {
function openContextMenu(event: MouseEvent, menuItems: MenuItem[]) {
event.preventDefault()
event.stopPropagation()
position.value = {x: event.clientX, y: event.clientY}
items.value = menuItems
isOpen.value = true
}
function closeContextMenu() {
isOpen.value = false
}
return {
isOpen,
position,
items,
openContextMenu,
closeContextMenu,
}
}
+30
View File
@@ -0,0 +1,30 @@
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
// Déclaré hors de la fonction = instancié une seule fois pour toute l'application
const md = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
breaks: true,
})
md.set({
highlight: (str: string, lang: string): string => {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs"><code>${hljs.highlight(str, {
language: lang,
ignoreIllegals: true
}).value}</code></pre>`
} catch (__) {
}
}
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`
}
})
export function useMarkdown() {
const renderMarkdown = (content: string) => md.render(content)
return {renderMarkdown}
}
+255
View File
@@ -0,0 +1,255 @@
import {useApi} from '@/composables/useApi'
import {
type ChannelRolePermission,
type ChannelPermissions,
type ChannelPermissionsDto,
channelPermissionsFromDto,
type ChannelRolePermissionDto,
channelRolePermissionFromDto,
type ChannelUserPermission,
type ChannelUserPermissionDto,
channelUserPermissionFromDto,
type PermissionMaskInput,
permissionMaskToJson,
type ServerRolePermission,
type ServerRolePermissionDto,
serverRolePermissionFromDto,
type ServerUserPermission,
type ServerUserPermissionDto,
serverUserPermissionFromDto,
} from '@/types/permissions'
export function usePermissions() {
const api = useApi()
async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => null)
const exception = new Error(
error?.message || 'Erreur lors de la gestion des permissions',
) as Error & { status?: number }
exception.status = response.status
throw exception
}
if (response.status === 204) {
return undefined as T
}
return response.json() as Promise<T>
}
// ---------------------------------------------------------------------------
// Permissions serveur - utilisateur
// ---------------------------------------------------------------------------
async function getServerUserPermission(
serverId: string,
userId: string,
): Promise<ServerUserPermission> {
const response = await api.get(
`/servers/${serverId}/permissions/users/${userId}`,
)
const dto = await parseResponse<ServerUserPermissionDto>(response)
return serverUserPermissionFromDto(dto)
}
async function listServerUserPermissions(
serverId: string,
): Promise<ServerUserPermission[]> {
const response = await api.get(`/servers/${serverId}/permissions/users`)
const dtos = await parseResponse<ServerUserPermissionDto[]>(response)
return dtos.map(serverUserPermissionFromDto)
}
async function setServerUserPermission(
serverId: string,
userId: string,
permissions: PermissionMaskInput,
): Promise<ServerUserPermission> {
const response = await api.put(
`/servers/${serverId}/permissions/users/${userId}`,
{
permissions: permissionMaskToJson(permissions),
},
)
const dto = await parseResponse<ServerUserPermissionDto>(response)
return serverUserPermissionFromDto(dto)
}
async function removeServerUserPermission(
serverId: string,
userId: string,
): Promise<void> {
const response = await api.delete(
`/servers/${serverId}/permissions/users/${userId}`,
)
await parseResponse<void>(response)
}
// ---------------------------------------------------------------------------
// Permissions serveur - rôle
// ---------------------------------------------------------------------------
async function getServerRolePermission(
serverId: string,
roleId: string,
): Promise<ServerRolePermission> {
const response = await api.get(
`/servers/${serverId}/permissions/roles/${roleId}`,
)
const dto = await parseResponse<ServerRolePermissionDto>(response)
return serverRolePermissionFromDto(dto)
}
async function setServerRolePermission(
serverId: string,
roleId: string,
permissions: PermissionMaskInput,
): Promise<ServerRolePermission> {
const response = await api.put(
`/servers/${serverId}/permissions/roles/${roleId}`,
{
permissions: permissionMaskToJson(permissions),
},
)
const dto = await parseResponse<ServerRolePermissionDto>(response)
return serverRolePermissionFromDto(dto)
}
async function removeServerRolePermission(
serverId: string,
roleId: string,
): Promise<void> {
const response = await api.delete(
`/servers/${serverId}/permissions/roles/${roleId}`,
)
await parseResponse<void>(response)
}
// ---------------------------------------------------------------------------
// Permissions canal - utilisateur
// ---------------------------------------------------------------------------
async function getChannelPermissions(channelId: string): Promise<ChannelPermissions> {
const response = await api.get(`/channels/${channelId}/permissions`)
const dto = await parseResponse<ChannelPermissionsDto>(response)
return channelPermissionsFromDto(dto)
}
async function getChannelUserPermission(
channelId: string,
userId: string,
): Promise<ChannelUserPermission> {
const response = await api.get(
`/channels/${channelId}/permissions/users/${userId}`,
)
const dto = await parseResponse<ChannelUserPermissionDto>(response)
return channelUserPermissionFromDto(dto)
}
async function setChannelUserPermission(
channelId: string,
userId: string,
permissions: PermissionMaskInput,
): Promise<ChannelUserPermission> {
const response = await api.put(
`/channels/${channelId}/permissions/users/${userId}`,
{
permissions: permissionMaskToJson(permissions),
},
)
const dto = await parseResponse<ChannelUserPermissionDto>(response)
return channelUserPermissionFromDto(dto)
}
async function removeChannelUserPermission(
channelId: string,
userId: string,
): Promise<void> {
const response = await api.delete(
`/channels/${channelId}/permissions/users/${userId}`,
)
await parseResponse<void>(response)
}
// ---------------------------------------------------------------------------
// Permissions canal - rôle
// ---------------------------------------------------------------------------
async function getChannelRolePermission(
channelId: string,
roleId: string,
): Promise<ChannelRolePermission> {
const response = await api.get(
`/channels/${channelId}/permissions/roles/${roleId}`,
)
const dto = await parseResponse<ChannelRolePermissionDto>(response)
return channelRolePermissionFromDto(dto)
}
async function setChannelRolePermission(
channelId: string,
roleId: string,
permissions: PermissionMaskInput,
): Promise<ChannelRolePermission> {
const response = await api.put(
`/channels/${channelId}/permissions/roles/${roleId}`,
{
permissions: permissionMaskToJson(permissions),
},
)
const dto = await parseResponse<ChannelRolePermissionDto>(response)
return channelRolePermissionFromDto(dto)
}
async function removeChannelRolePermission(
channelId: string,
roleId: string,
): Promise<void> {
const response = await api.delete(
`/channels/${channelId}/permissions/roles/${roleId}`,
)
await parseResponse<void>(response)
}
return {
getChannelPermissions,
getServerUserPermission,
listServerUserPermissions,
setServerUserPermission,
removeServerUserPermission,
getServerRolePermission,
setServerRolePermission,
removeServerRolePermission,
getChannelUserPermission,
setChannelUserPermission,
removeChannelUserPermission,
getChannelRolePermission,
setChannelRolePermission,
removeChannelRolePermission,
}
}
+207 -8
View File
@@ -1,18 +1,111 @@
<script lang="ts" setup>
import {storeToRefs} from 'pinia'
import {useServerStore} from '@/stores/server'
import {useServerStore, type Server} from '@/stores/server'
import {computed, ref, watch} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import ContextMenu from "@/components/ContextMenu.vue";
import UserListDrawer from '@/components/UserListDrawer.vue'
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
import {useContextMenu} from '@/composables/useContextMenu'
const serverStore = useServerStore()
const route = useRoute()
const router = useRouter()
const {openContextMenu} = useContextMenu()
const {servers} = storeToRefs(serverStore)
console.log(servers.value)
const showUsersDrawer = ref(false)
const showServerSettings = ref(false)
const selectedServerId = ref<string | null>(null)
const selectedServerName = ref('')
const isServerContext = computed(() => Boolean(route.params.serverId))
watch(isServerContext, (isActive) => {
if (!isActive) showUsersDrawer.value = false
})
const showDialog = ref(false)
const formData = ref({
name: '',
password: '',
is_default: false
})
const isSubmitting = ref(false)
const resetForm = () => {
formData.value = {
name: '',
password: '',
is_default: false
}
}
const handleSubmit = async () => {
if (!formData.value.name.trim()) {
alert('Server name is required');
return;
}
isSubmitting.value = true;
try {
const newServer = await serverStore.createServer({
name: formData.value.name,
password: formData.value.password || null,
is_default: formData.value.is_default
});
showDialog.value = false;
resetForm();
router.push(`/server/${newServer.id}`);
} catch (error) {
console.error('Failed to create server:', error);
} finally {
isSubmitting.value = false;
}
}
const handleCancel = () => {
showDialog.value = false;
resetForm();
}
const getServerInitials = (name: string): string => {
if (!name) return ''
const words = name.trim().split(/\s+/)
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase()
}
return name.slice(0, 2).toUpperCase()
}
const getServerColor = (str: string): string => {
if (!str) return 'hsl(0, 0%, 50%)'
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
const hue = Math.abs(hash) % 360
return `hsl(${hue}, 60%, 45%)`
}
function onServerContextMenu(event: MouseEvent, server: Server) {
openContextMenu(event, [{
label: 'Gérer le serveur',
icon: 'mdi-cog',
action: () => {
selectedServerId.value = server.id
selectedServerName.value = server.name
showServerSettings.value = true
},
}])
}
</script>
<template>
<v-app>
<v-app @contextmenu.prevent>
<!--Top bar-->
<v-system-bar>
<v-spacer></v-spacer>
<v-icon>mdi-square</v-icon>
@@ -20,6 +113,15 @@ console.log(servers.value)
<v-icon>mdi-circle</v-icon>
<v-icon>mdi-triangle</v-icon>
<v-btn
v-if="isServerContext"
aria-label="Afficher les utilisateurs"
icon="mdi-account-group"
size="small"
variant="text"
@click="showUsersDrawer = !showUsersDrawer"
></v-btn>
</v-system-bar>
<v-navigation-drawer
@@ -41,20 +143,117 @@ console.log(servers.value)
v-for="server in servers"
:key="server.id"
:to="`/server/${server.id}`"
@contextmenu="onServerContextMenu($event, server)"
>
<v-badge
class="server-badge d-flex mx-auto mb-9"
:content="server.unread_count"
:model-value="(server.unread_count ?? 0) > 0"
color="primary"
location="bottom right"
offset-x="2"
offset-y="2"
>
<v-avatar
class="d-block text-center mx-auto mb-9"
color="grey-lighten-1"
size="28"
></v-avatar>
:style="{ backgroundColor: getServerColor(server.name) }"
class="d-flex align-center justify-center font-weight-bold text-caption text-white"
size="36"
>
{{ getServerInitials(server.name) }}
</v-avatar>
</v-badge>
</router-link>
<v-btn
class="d-block mx-auto mb-9"
color="grey-lighten-2"
icon="mdi-plus"
size="28"
variant="flat"
@click="showDialog = true"
></v-btn>
</v-navigation-drawer>
<UserListDrawer
v-model="showUsersDrawer"
/>
<ServerSettingsDialog
v-if="selectedServerId"
v-model="showServerSettings"
:server-id="selectedServerId"
:server-name="selectedServerName"
/>
<router-view/>
<!-- Menu contextuel global -->
<ContextMenu/>
<v-dialog v-model="showDialog" width="400">
<v-card>
<v-card-title>Create Server</v-card-title>
<v-card-text>
<div class="mt-4 space-y-4">
<v-text-field
v-model="formData.name"
density="compact"
label="Server Name"
outlined
@keyup.enter="handleSubmit"
></v-text-field>
<v-text-field
v-model="formData.password"
density="compact"
label="Password (optional)"
outlined
type="password"
@keyup.enter="handleSubmit"
></v-text-field>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
:disabled="isSubmitting"
variant="text"
@click="handleCancel"
>
Cancel
</v-btn>
<v-btn
:disabled="!formData.name.trim()"
:loading="isSubmitting"
color="primary"
variant="tonal"
@click="handleSubmit"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-app>
</template>
<style scoped>
.space-y-4 {
display: flex;
flex-direction: column;
gap: 1rem;
}
.server-badge {
height: 36px;
width: 36px;
}
.server-badge :deep(.v-badge__badge) {
min-width: 22px;
height: 22px;
padding: 0 5px;
font-size: 0.7rem;
line-height: 22px;
}
</style>
+32 -4
View File
@@ -1,11 +1,39 @@
<script lang="ts" setup>
import {ref} from 'vue'
import ServerPermissionEditor from '@/components/permissions/ServerPermissionEditor.vue'
import ChannelPermissionEditor from '@/components/permissions/ChannelPermissionEditor.vue'
import {
type ChannelPermissionMask,
type ServerPermissionMask,
toChannelPermissionMask,
toServerPermissionMask,
} from '@/types/permissions'
const serverPermissions = ref<ServerPermissionMask>(
toServerPermissionMask(0),
)
const channelPermissions = ref<ChannelPermissionMask>(
toChannelPermissionMask(0),
)
</script>
<template>
<v-container>
<v-row>
<v-col cols="12" md="6">
<ServerPermissionEditor
v-model="serverPermissions"
title="Test des permissions serveur"
/>
</v-col>
<v-col cols="12" md="6">
<ChannelPermissionEditor
v-model="channelPermissions"
title="Test des permissions canal"
/>
</v-col>
</v-row>
</v-container>
</template>
<style scoped>
</style>
+7 -5
View File
@@ -1,7 +1,9 @@
<template>
<HelloWorld />
</template>
<script lang="ts" setup>
import HelloWorld from '@/components/HelloWorld.vue'
</script>
<template>
<div>
Hello
</div>
</template>
+222 -40
View File
@@ -1,9 +1,12 @@
<script lang="ts" setup>
import MarkdownIt from 'markdown-it'
import {computed, nextTick, onMounted, ref, watch} from 'vue';
import {useRoute} from 'vue-router';
import 'highlight.js/styles/github-dark.css'
import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue';
import {storeToRefs} from 'pinia';
import {useMessageStore} from '@/stores/message';
import {useServerStore} from '@/stores/server';
import {useUserStore} from "@/stores/user.ts";
import {useMarkdown} from '@/composables/useMarkdown'
import {onReloadAll} from '@/plugins/events.ts'
const props = defineProps<{
serverId: string
@@ -11,32 +14,154 @@ const props = defineProps<{
}>();
const channelId = computed(() => props.channelId);
const route = useRoute();
const messageStore = useMessageStore();
const serverStore = useServerStore();
const userStore = useUserStore();
const {renderMarkdown} = useMarkdown()
// Référence vers l'élément scrollable
const messageContainer = ref<HTMLElement | null>(null);
// "messages" ici est une référence réactive liée au store
const {messages, loading} = storeToRefs(messageStore);
const {messages, loading, loadingBefore, loadingAfter, hasMoreAfter, isAtBottom, newestId} = storeToRefs(messageStore);
const newMessage = ref('');
const md = new MarkdownIt({
html: false, // Désactive le HTML pur pour la sécurité
linkify: true, // Convertit automatiquement les URLs en liens
typographer: true,
breaks: true // Convertit les retours à la ligne en <br> (comportement type chat)
})
const renderMarkdown = (content: string) => {
return md.render(content);
const SCROLL_LOAD_THRESHOLD = 120;
const SCROLL_BOTTOM_TOLERANCE = 4;
const paginationLock = ref<'before' | 'after' | null>(null);
const paginationLockScrollTop = ref(0);
const lastScrollTop = ref(0);
const markedMessageByChannel = new Map<string, string>();
const markCurrentChannelRead = async (targetChannelId: string) => {
if (messageStore.activeChannelId !== targetChannelId || !newestId.value) return;
const messageId = newestId.value;
if (markedMessageByChannel.get(targetChannelId) === messageId) return;
try {
const readState = await messageStore.markChannelRead(targetChannelId, messageId);
if (messageStore.activeChannelId !== targetChannelId) return;
markedMessageByChannel.set(targetChannelId, messageId);
serverStore.applyChannelReadState(props.serverId, targetChannelId, readState.unread_count);
} catch (error) {
console.error('Erreur lors de la mise à jour de la lecture:', error);
}
};
const showRecentMessagesButton = computed(() =>
!loading.value && (hasMoreAfter.value || !isAtBottom.value),
);
interface ScrollAnchor {
id: string;
top: number;
}
const scrollToBottom = async () => {
await nextTick();
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
messageStore.setAtBottom(true);
paginationLock.value = null;
lastScrollTop.value = messageContainer.value.scrollTop;
}
};
const getMessageElements = () => Array.from(
messageContainer.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? [],
);
const captureAnchor = (edge: 'top' | 'bottom'): ScrollAnchor | null => {
const container = messageContainer.value;
if (!container) return null;
const containerRect = container.getBoundingClientRect();
const visible = getMessageElements().filter(element => {
const rect = element.getBoundingClientRect();
return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
});
const element = edge === 'top' ? visible[0] : visible[visible.length - 1];
if (!element?.dataset.messageId) return null;
return {
id: element.dataset.messageId,
top: element.getBoundingClientRect().top,
};
};
const restoreAnchor = async (anchor: ScrollAnchor | null) => {
if (!anchor || !messageContainer.value) return;
await nextTick();
const element = getMessageElements().find(item => item.dataset.messageId === anchor.id);
if (element) {
messageContainer.value.scrollTop += element.getBoundingClientRect().top - anchor.top;
}
};
const updateScrollState = () => {
const container = messageContainer.value;
if (!container) return;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
messageStore.setAtBottom(distanceFromBottom <= SCROLL_BOTTOM_TOLERANCE);
};
const setPaginationLock = (direction: 'before' | 'after') => {
if (!messageContainer.value) return;
paginationLock.value = direction;
paginationLockScrollTop.value = messageContainer.value.scrollTop;
lastScrollTop.value = messageContainer.value.scrollTop;
};
const loadBefore = async () => {
const anchor = captureAnchor('top');
const change = await messageStore.fetchBefore(channelId.value);
if (change) {
await restoreAnchor(anchor);
setPaginationLock('before');
}
};
const loadAfter = async () => {
const anchor = captureAnchor('bottom');
const change = await messageStore.fetchAfter(channelId.value);
if (change) {
await restoreAnchor(anchor);
setPaginationLock('after');
}
};
const handleScroll = async () => {
const container = messageContainer.value;
if (!container) return;
const currentScrollTop = container.scrollTop;
const scrollDelta = currentScrollTop - lastScrollTop.value;
lastScrollTop.value = currentScrollTop;
if (paginationLock.value === 'after') {
if (scrollDelta < -1 || currentScrollTop > paginationLockScrollTop.value + 2) {
paginationLock.value = null;
} else {
return;
}
} else if (paginationLock.value === 'before') {
if (scrollDelta > 1 || currentScrollTop < paginationLockScrollTop.value - 2) {
paginationLock.value = null;
} else {
return;
}
}
updateScrollState();
if (container.scrollTop <= SCROLL_LOAD_THRESHOLD && !loadingBefore.value) {
await loadBefore();
} else {
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
if (distanceFromBottom <= SCROLL_LOAD_THRESHOLD && !loadingAfter.value) {
await loadAfter();
}
}
};
@@ -44,47 +169,78 @@ const sendMessage = async () => {
if (!newMessage.value.trim()) return;
const content = newMessage.value;
const wasAtBottom = messageStore.isAtBottom;
try {
await messageStore.sendMessage(channelId.value, content);
newMessage.value = ''; // On vide le champ après succès
await scrollToBottom();
newMessage.value = '';
if (wasAtBottom) await scrollToBottom();
} catch (e) {
// Gérer l'erreur (ex: notification toast)
console.error('Erreur lors de l\'envoi du message:', e);
}
};
const returnToRecentMessages = async () => {
paginationLock.value = null;
await messageStore.fetchMessages(channelId.value);
await scrollToBottom();
await markCurrentChannelRead(channelId.value);
};
let stopReloadAll: (() => void) | null = null;
onMounted(() => {
if (channelId.value) {
messageStore.fetchMessages(channelId.value);
stopReloadAll = onReloadAll(() => messageStore.fetchMessages(channelId.value));
});
onUnmounted(() => {
stopReloadAll?.();
});
// Only explicit initial loads and realtime messages received while at the
// bottom request an automatic scroll. Pagination restores its own anchor.
watch(messages, async () => {
if (messageStore.consumeScrollToBottomRequest()) {
await scrollToBottom();
}
}, {deep: true, flush: 'post'});
watch(isAtBottom, async (atBottom) => {
if (atBottom) {
await markCurrentChannelRead(channelId.value);
}
});
watch(channelId, (newChannelId) => {
watch(channelId, async (newChannelId) => {
if (newChannelId) {
messageStore.fetchMessages(newChannelId);
await messageStore.fetchMessages(newChannelId);
await scrollToBottom();
await markCurrentChannelRead(newChannelId);
}
}, {immediate: true})
// Scroll automatique quand la liste des messages change (nouveaux messages reçus)
watch(messages, () => {
scrollToBottom();
}, {deep: true});
</script>
<template>
<!-- Conteneur principal prenant toute la hauteur -->
<v-container class="pa-0 fill-height d-flex flex-column" fluid>
<!-- Zone des messages (scrollable) -->
<v-container class="pa-0 fill-height d-flex flex-column channel-layout" fluid>
<div
ref="messageContainer"
class="flex-grow-1 overflow-y-auto w-100 message-container"
@scroll.passive="handleScroll"
>
<v-progress-linear v-if="loadingBefore" color="primary" indeterminate />
<v-progress-circular
v-if="loading && !messages.length"
class="d-block mx-auto mt-4"
color="primary"
indeterminate
/>
<v-list bg-color="transparent" lines="three">
<v-list-item
v-for="msg in messages"
:key="msg.id"
:data-message-id="msg.id"
class="px-4 py-1"
>
<template v-slot:prepend>
@@ -94,18 +250,35 @@ watch(messages, () => {
</template>
<v-list-item-title class="d-flex align-center">
<span class="font-weight-bold text-subtitle-1 mr-2">{{ msg.user_id }}</span>
<span class="font-weight-bold text-subtitle-1 mr-2">{{ userStore.usersById[msg.user_id]?.username }}</span>
<span class="text-caption text-grey">{{ msg.created_at }}</span>
</v-list-item-title>
<v-list-item-subtitle class="text-body-1 text-high-emphasis opacity-100">
<div class="text-body-1 text-high-emphasis opacity-100 mt-1">
<div class="markdown-content" v-html="renderMarkdown(msg.content)"></div>
</v-list-item-subtitle>
</div>
</v-list-item>
</v-list>
<v-progress-linear v-if="loadingAfter" color="primary" indeterminate />
</div>
<div v-if="showRecentMessagesButton" class="recent-messages-button">
<v-tooltip location="top" text="Revenir aux messages récents">
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="tooltipProps"
aria-label="Revenir aux messages récents"
color="primary"
elevation="4"
icon="mdi-arrow-down-bold"
:loading="loading"
@click="returnToRecentMessages"
/>
</template>
</v-tooltip>
</div>
<!-- Zone de saisie fixe en bas -->
<v-sheet class="pa-4 flex-shrink-0" width="100%">
<v-textarea
v-model="newMessage"
@@ -131,16 +304,25 @@ watch(messages, () => {
</template>
</v-textarea>
</v-sheet>
</v-container>
</template>
<style scoped>
.message-container {
/* Assure que la zone gère son scroll indépendamment */
height: 0;
}
.channel-layout {
position: relative;
}
.recent-messages-button {
position: absolute;
right: 16px;
bottom: 92px;
z-index: 2;
}
.markdown-content :deep(p) {
margin-bottom: 0;
}
+225 -119
View File
@@ -1,157 +1,263 @@
<script lang="ts" setup>
import {storeToRefs} from 'pinia'
import {useChannelStore} from '@/stores/channel'
import {ref} from 'vue'
import {useCategoryStore} from '@/stores/category'
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
import {useRoute} from 'vue-router'
import CreateChannelDialog from '@/components/channel/CreateChannelDialog.vue'
import CreateCategoryDialog from '@/components/category/CreateCategoryDialog.vue'
import {type MenuItem, useContextMenu} from '@/composables/useContextMenu'
import {useUserStore} from "@/stores/user.ts";
import {useServerStore} from "@/stores/server.ts";
import ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue'
import {useAuthStore} from '@/stores/auth'
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
import {onReloadAll} from '@/plugins/events.ts'
const props = defineProps<{
serverId: string
channelId?: string
}>();
const serverId = props.serverId
}>()
const route = useRoute()
const channelStore = useChannelStore()
const {channels} = storeToRefs(channelStore)
const categoryStore = useCategoryStore()
const userStore = useUserStore()
const serverStore = useServerStore()
const {currentTree} = storeToRefs(serverStore)
const {openContextMenu} = useContextMenu()
const authStore = useAuthStore()
const showPermissionsDialog = ref(false)
const selectedChannel = ref<any | null>(null)
const showServerSettings = ref(false)
const serverName = computed(() => serverStore.servers.find(server => server.id === props.serverId)?.name || 'Serveur')
const showDialog = ref(false)
const formData = ref({
name: '',
channel_type: 'text',
server_id: null,
category_id: null,
position: 0
})
const isSubmitting = ref(false)
const channelTypeOptions = [
{title: 'Text', value: 'text'},
{title: 'Voice', value: 'voice'},
{title: 'DM', value: 'dm'}
]
const resetForm = () => {
formData.value = {
name: '',
channel_type: 'text',
server_id: null,
category_id: null,
position: 0
}
}
const handleSubmit = async () => {
if (!formData.value.name.trim()) {
alert('Channel name is required');
return;
}
isSubmitting.value = true;
const loadServerData = async (targetServerId: string) => {
if (!targetServerId) return
channelStore.reset()
categoryStore.reset()
userStore.reset()
try {
await channelStore.createChannel({
name: formData.value.name,
channel_type: formData.value.channel_type,
server_id: formData.value.server_id,
category_id: formData.value.category_id,
position: formData.value.position
});
showDialog.value = false;
resetForm();
await Promise.all([
userStore.fetchUsers(targetServerId),
serverStore.fetchServerTree(targetServerId)
])
syncOpenedCategories()
} catch (error) {
console.error('Failed to create channel:', error);
} finally {
isSubmitting.value = false;
console.error('Failed to load server-scoped channels and categories:', error)
}
}
const handleCancel = () => {
showDialog.value = false;
resetForm();
let stopReloadAll: (() => void) | null = null
onMounted(() => {
stopReloadAll = onReloadAll(() => loadServerData(props.serverId))
})
onUnmounted(() => {
stopReloadAll?.()
})
watch(
() => props.serverId,
async (newServerId) => {
if (newServerId) {
await loadServerData(newServerId)
}
},
{immediate: true}
)
const showChannelDialog = ref(false)
const showCategoryDialog = ref(false)
const selectedCategoryId = ref<string | null>(null)
const openedCategories = ref<string[]>([])
function syncOpenedCategories() {
openedCategories.value = currentTree.value
.filter((item) => 'Category' in item)
.map((item) => item.Category[0].id)
}
async function refreshServerTree() {
await serverStore.fetchServerTree(props.serverId)
syncOpenedCategories()
}
// Right click menu (sidebar)
function onSidebarContextMenu(event: MouseEvent) {
const menuItems: MenuItem[] = [
{
label: 'Nouveau canal',
icon: 'mdi-plus',
action: () => {
selectedCategoryId.value = null
showChannelDialog.value = true
},
},
{
label: 'Nouvelle catégorie',
icon: 'mdi-folder-plus',
action: () => { showCategoryDialog.value = true },
}
]
openContextMenu(event, menuItems);
}
function onCategoryContextMenu(event: MouseEvent, category: any) {
const menuItems: MenuItem[] = [
{
label: 'Nouveau canal',
icon: 'mdi-plus',
action: () => {
selectedCategoryId.value = category.id
showChannelDialog.value = true
},
},
]
openContextMenu(event, menuItems)
}
// Right click menu (channel)
async function openEditDialog(channel: any) {
console.log("edit dialog clicked")
}
async function deleteChannel(channelId: string) {
console.log("delete channel clicked")
}
function onChannelContextMenu(event: MouseEvent, channel: any) {
const menuItems: MenuItem[] = [
...(authStore.isAdmin ? [{
label: 'Gérer les permissions',
icon: 'mdi-shield-key',
action: () => {
selectedChannel.value = channel
showPermissionsDialog.value = true
},
}] : []),
{
label: 'Marquer comme lu',
icon: 'mdi-check',
action: () => console.log('Marqué comme lu', channel.id),
},
{
label: 'Modifier le canal',
icon: 'mdi-pencil',
action: () => openEditDialog(channel),
},
{
label: 'Supprimer le canal',
icon: 'mdi-delete',
color: 'error',
action: () => deleteChannel(channel.id),
},
]
openContextMenu(event, menuItems);
}
</script>
<template>
<v-navigation-drawer width="244">
<v-sheet
color="grey-lighten-5"
height="128"
width="100%"
></v-sheet>
<v-navigation-drawer width="244" @contextmenu="onSidebarContextMenu">
<v-sheet color="grey-lighten-5" height="128" width="100%" class="pa-3">
<v-btn block variant="text" prepend-icon="mdi-cog" @click="showServerSettings = true">Gérer le serveur</v-btn>
</v-sheet>
<v-btn
block
class="ma-2"
prepend-icon="mdi-plus"
variant="text"
@click="showDialog = true"
<v-list
v-model:opened="openedCategories"
open-strategy="multiple"
density="compact"
>
New Channel
</v-btn>
<v-list>
<template v-for="(item, index) in currentTree" :key="index">
<!-- Catégorie et ses canaux enfants -->
<v-list-group v-if="'Category' in item" :value="item.Category[0].id">
<template #activator="{ props: groupProps }">
<v-list-item
v-for="channel in channels"
:title="item.Category[0].name"
v-bind="groupProps"
@contextmenu="onCategoryContextMenu($event, item.Category[0])"
/>
</template>
<v-list-item
v-for="channel in item.Category[1]"
:key="channel.id"
:title="channel.name"
:to="`/server/${serverId}/channel/${channel.id}`"
:class="{ 'font-weight-bold': (channel.unread_count ?? 0) > 0 }"
link
></v-list-item>
@contextmenu="onChannelContextMenu($event, channel)"
>
<template #append>
<v-chip
v-if="(channel.unread_count ?? 0) > 0"
color="primary"
density="compact"
size="small"
variant="flat"
>
{{ channel.unread_count }}
</v-chip>
</template>
</v-list-item>
</v-list-group>
<!-- Canal orphelin (racine) -->
<v-list-item
v-else-if="'Channel' in item"
:key="item.Channel.id"
:title="item.Channel.name"
:to="`/server/${serverId}/channel/${item.Channel.id}`"
:class="{ 'font-weight-bold': (item.Channel.unread_count ?? 0) > 0 }"
link
@contextmenu="onChannelContextMenu($event, item.Channel)"
>
<template #append>
<v-chip
v-if="(item.Channel.unread_count ?? 0) > 0"
color="primary"
density="compact"
size="small"
variant="flat"
>
{{ item.Channel.unread_count }}
</v-chip>
</template>
</v-list-item>
</template>
</v-list>
</v-navigation-drawer>
<v-dialog v-model="showDialog" width="400">
<v-card>
<v-card-title>Create Channel</v-card-title>
<v-card-text>
<div class="mt-4 space-y-4">
<v-text-field
v-model="formData.name"
density="compact"
label="Channel Name"
outlined
@keyup.enter="handleSubmit"
></v-text-field>
<CreateChannelDialog
v-model="showChannelDialog"
:category-id="selectedCategoryId"
:server-id="serverId"
@created="refreshServerTree"
/>
<v-select
v-model="formData.channel_type"
:items="channelTypeOptions"
density="compact"
label="Channel Type"
outlined
></v-select>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
:disabled="isSubmitting"
variant="text"
@click="handleCancel"
>
Cancel
</v-btn>
<v-btn
:disabled="!formData.name.trim()"
:loading="isSubmitting"
color="primary"
variant="tonal"
@click="handleSubmit"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<CreateCategoryDialog
v-model="showCategoryDialog"
:server-id="serverId"
@created="refreshServerTree"
/>
<ChannelPermissionsDialog
v-model="showPermissionsDialog"
:channel="selectedChannel"
:server-id="serverId"
/>
<ServerSettingsDialog
v-model="showServerSettings"
:server-id="serverId"
:server-name="serverName"
/>
<v-main>
<router-view/>
</v-main>
</template>
<style scoped>
.space-y-4 {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>
+21
View File
@@ -1,5 +1,9 @@
export const bus = new EventTarget();
type ReloadAllHandler = () => void | Promise<void>;
const reloadAllHandlers = new Set<ReloadAllHandler>();
export function emitGatewayEvent(namespace: string, action: string, content: any) {
// On construit le nom de l'événement de manière cohérente : gateway:message
const eventName = `gateway:${namespace.toLowerCase()}`;
@@ -17,3 +21,20 @@ export function onGatewayEvent(namespace: string, callback: (payload: { action:
// Retourne une fonction pour se désabonner facilement si besoin
return () => bus.removeEventListener(eventName, wrapper);
}
export function onReloadAll(handler: ReloadAllHandler) {
reloadAllHandlers.add(handler);
return () => reloadAllHandlers.delete(handler);
}
export async function emitReloadAll() {
const results = await Promise.allSettled(
Array.from(reloadAllHandlers, handler => Promise.resolve().then(handler)),
);
for (const result of results) {
if (result.status === 'rejected') {
console.error('Reload after WebSocket reconnection failed:', result.reason);
}
}
}
+41 -3
View File
@@ -1,18 +1,56 @@
import {defineStore} from 'pinia'
import {useApi} from "@/composables/useApi.ts";
interface Category {
id: string
name: string
server_id?: string | null
created_at: string
updated_at: string
}
export const useCategoryStore = defineStore("category", {
state: () => ({
categories: []
categories: [] as Category[],
loading: false,
error: null as string | null
}),
actions: {
async fetchCategories() {
async fetchCategories(serverId?: string) {
let api = useApi();
let response = await api.get("/categories");
let url = "/categories";
if (serverId) {
url += `?server_id=${serverId}`;
}
let response = await api.get(url);
this.categories = await response.json();
},
async createCategory(payload: { server_id: string; name: string }) {
this.loading = true;
this.error = null;
try {
const api = useApi();
const response = await api.post("/categories", payload);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create category');
}
const newCategory = await response.json();
this.categories.push(newCategory);
return newCategory;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Unknown error';
throw err;
} finally {
this.loading = false;
}
},
reset() {
this.categories = [];
this.loading = false;
this.error = null;
}
}
});
+7 -3
View File
@@ -7,9 +7,9 @@ interface Channel {
channel_type: string
server_id?: string | null
category_id?: string | null
position: number
created_at: string
updated_at: string
unread_count?: number
}
/*
@@ -23,9 +23,13 @@ export const useChannelStore = defineStore('channel', {
error: null as string | null
}),
actions: {
async fetchChannels() {
async fetchChannels(serverId?: string) {
let api = useApi();
let response = await api.get("/channels");
let url = "/channels";
if (serverId) {
url += `?server_id=${serverId}`;
}
let response = await api.get(url);
this.channels = await response.json();
},
async createChannel(payload: {
+14 -3
View File
@@ -1,6 +1,6 @@
import {defineStore} from 'pinia';
import {useAppStore} from "@/stores/app.ts";
import {emitGatewayEvent} from "@/plugins/events.ts";
import {emitGatewayEvent, emitReloadAll} from "@/plugins/events.ts";
type GatewayStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
@@ -10,6 +10,7 @@ export const useGatewayStore = defineStore('gateway', {
status: 'disconnected' as GatewayStatus,
reconnectAttempts: 0,
shouldReconnect: false,
reloadOnConnect: false,
reconnectTimer: null as number | null,
}),
@@ -34,22 +35,31 @@ export const useGatewayStore = defineStore('gateway', {
const socket = new WebSocket(wsUrl)
socket.onopen = () => {
if (this.socket !== socket) return
const shouldReload = this.reloadOnConnect
this.status = 'connected'
this.reconnectAttempts = 0
this.reloadOnConnect = false
if (shouldReload) {
void emitReloadAll()
}
}
socket.onclose = () => {
if (this.socket !== socket) return
this.status = 'disconnected'
if (this.socket === socket) {
this.socket = null
}
if (this.shouldReconnect) {
this.reloadOnConnect = true
this.scheduleReconnect()
}
}
socket.onerror = () => {
if (this.socket !== socket) return
this.status = 'error'
}
@@ -70,6 +80,7 @@ export const useGatewayStore = defineStore('gateway', {
this.socket = null
this.status = 'disconnected'
this.reconnectAttempts = 0
this.reloadOnConnect = false
},
async send(payload: object) {
+305 -39
View File
@@ -1,9 +1,18 @@
import {defineStore} from "pinia";
import {useApi} from "@/composables/useApi.ts";
import {onGatewayEvent} from "@/plugins/events.ts";
import {useServerStore} from "@/stores/server.ts";
import {useAuthStore} from "@/stores/auth.ts";
import {useNotificationStore} from "@/stores/notification.ts";
interface Message {
// Change this value to adjust the maximum number of messages kept in the DOM.
// Directional loads automatically use half of this window.
export const MESSAGE_WINDOW_SIZE = 50;
export const MESSAGE_SHIFT_SIZE = Math.max(1, Math.floor(MESSAGE_WINDOW_SIZE / 2));
export interface Message {
id: string;
server_id: string | null;
channel_id: string;
user_id: string;
content: string;
@@ -12,78 +21,335 @@ interface Message {
reply_to_id: string | null;
}
export interface ReadStateResponse {
channel_id: string;
last_read_message_id: string | null;
updated_at: string | null;
unread_count: number;
}
interface MessagePage {
messages: Message[];
oldest_id: string | null;
newest_id: string | null;
has_more_before: boolean;
has_more_after: boolean;
}
interface WindowChange {
addedIds: string[];
removedIds: string[];
}
function compareMessages(left: Message, right: Message): number {
if (left.id < right.id) return -1;
if (left.id > right.id) return 1;
return 0;
}
function mergeMessages(messages: Message[]): Message[] {
const byId = new Map<string, Message>();
for (const message of messages) {
byId.set(message.id, message);
}
return Array.from(byId.values()).sort(compareMessages);
}
async function requestPage(
channelId: string,
params: { limit: number; before_id?: string; after_id?: string },
): Promise<MessagePage> {
const query = new URLSearchParams({
channel_id: channelId,
limit: String(params.limit),
});
if (params.before_id) query.set("before_id", params.before_id);
if (params.after_id) query.set("after_id", params.after_id);
const response = await useApi().get(`/messages?${query.toString()}`);
if (!response.ok) {
throw new Error(`Message loading failed (${response.status})`);
}
return response.json() as Promise<MessagePage>;
}
export const useMessageStore = defineStore("message", {
state: () => ({
messages: [] as Message[],
activeChannelId: null as string | null,
oldestId: null as string | null,
newestId: null as string | null,
hasMoreBefore: false,
hasMoreAfter: false,
loading: false,
loadingBefore: false,
loadingAfter: false,
isAtBottom: true,
scrollToBottomRequested: false,
requestVersion: 0,
seenRealtimeMessageIds: new Set<string>(),
}),
actions: {
async fetchMessages(channel_id: string) {
updateBoundaries(page: MessagePage) {
this.oldestId = page.oldest_id ?? this.messages[0]?.id ?? null;
this.newestId = page.newest_id ?? this.messages[this.messages.length - 1]?.id ?? null;
this.hasMoreBefore = page.has_more_before;
this.hasMoreAfter = page.has_more_after;
},
updateLocalBoundaries() {
this.oldestId = this.messages[0]?.id ?? null;
this.newestId = this.messages[this.messages.length - 1]?.id ?? null;
},
async fetchMessages(channelId: string) {
const requestVersion = ++this.requestVersion;
this.activeChannelId = channelId;
this.messages = [];
this.oldestId = null;
this.newestId = null;
this.hasMoreBefore = false;
this.hasMoreAfter = false;
this.isAtBottom = true;
this.loading = true;
// Query params
let params = new URLSearchParams();
params.append("channel_id", channel_id);
const queryString = params.toString();
try {
const api = useApi();
// Utilisation du paramètre pour cibler le channel
const response = await api.get(`/messages${queryString ? `?${queryString}` : ""}`);
this.messages = await response.json();
const page = await requestPage(channelId, {limit: MESSAGE_WINDOW_SIZE});
if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return;
this.messages = mergeMessages(page.messages).slice(-MESSAGE_WINDOW_SIZE);
this.updateBoundaries(page);
this.scrollToBottomRequested = true;
} catch (error) {
if (requestVersion === this.requestVersion) {
console.error("Erreur lors du chargement des messages:", error);
}
} finally {
if (requestVersion === this.requestVersion) {
this.loading = false;
}
}
},
async sendMessage(channelId: string, content: string) {
const api = useApi();
console.log("channelId", channelId);
try {
// Envoi au serveur pour persistance
const response = await api.post('/messages', {
channel_id: channelId,
content: content,
reply_to_id: null
});
const newMessage = await response.json();
// Ajout local immédiat (optimistic update)
// this.messages.push(newMessage);
async fetchBefore(channelId: string): Promise<WindowChange | null> {
if (
this.activeChannelId !== channelId ||
!this.oldestId ||
!this.hasMoreBefore ||
this.loadingBefore ||
this.loadingAfter
) return null;
const requestVersion = this.requestVersion;
const previousIds = new Set(this.messages.map(message => message.id));
this.loadingBefore = true;
try {
const page = await requestPage(channelId, {
limit: MESSAGE_SHIFT_SIZE,
before_id: this.oldestId,
});
if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return null;
const incoming = mergeMessages(page.messages);
const merged = mergeMessages([...incoming, ...this.messages]);
this.messages = merged.slice(0, MESSAGE_WINDOW_SIZE);
this.updateBoundaries(page);
this.oldestId = this.messages[0]?.id ?? null;
this.newestId = this.messages[this.messages.length - 1]?.id ?? null;
return {
addedIds: incoming.filter(message => !previousIds.has(message.id)).map(message => message.id),
removedIds: merged.slice(0, -MESSAGE_WINDOW_SIZE).map(message => message.id),
};
} catch (error) {
console.error("Erreur lors du chargement des messages précédents:", error);
return null;
} finally {
if (requestVersion === this.requestVersion) {
this.loadingBefore = false;
}
}
},
async fetchAfter(channelId: string): Promise<WindowChange | null> {
if (
this.activeChannelId !== channelId ||
!this.newestId ||
!this.hasMoreAfter ||
this.loadingBefore ||
this.loadingAfter
) return null;
const requestVersion = this.requestVersion;
const previousIds = new Set(this.messages.map(message => message.id));
this.loadingAfter = true;
try {
const page = await requestPage(channelId, {
limit: MESSAGE_SHIFT_SIZE,
after_id: this.newestId,
});
if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return null;
const incoming = mergeMessages(page.messages);
const merged = mergeMessages([...this.messages, ...incoming]);
this.messages = merged.slice(-MESSAGE_WINDOW_SIZE);
this.updateBoundaries(page);
this.oldestId = this.messages[0]?.id ?? null;
this.newestId = this.messages[this.messages.length - 1]?.id ?? null;
return {
addedIds: incoming.filter(message => !previousIds.has(message.id)).map(message => message.id),
removedIds: merged.slice(0, Math.max(0, merged.length - MESSAGE_WINDOW_SIZE)).map(message => message.id),
};
} catch (error) {
console.error("Erreur lors du chargement des messages suivants:", error);
return null;
} finally {
if (requestVersion === this.requestVersion) {
this.loadingAfter = false;
}
}
},
async sendMessage(channelId: string, content: string) {
try {
const response = await useApi().post("/messages", {
channel_id: channelId,
content,
reply_to_id: null,
});
if (!response.ok) {
throw new Error(`Message sending failed (${response.status})`);
}
const newMessage = await response.json() as Message;
this.addRealtimeMessage(newMessage);
return newMessage;
} catch (error) {
console.error("Erreur lors de l'envoi du message:", error);
throw error;
}
},
async markChannelRead(channelId: string, messageId: string): Promise<ReadStateResponse> {
const response = await useApi().put(`/channels/${channelId}/read-state`, {
last_read_message_id: messageId,
});
if (!response.ok) {
throw new Error(`Read state update failed (${response.status})`);
}
return await response.json() as ReadStateResponse;
},
addRealtimeMessage(message: Message, fromGateway = false) {
const serverStore = useServerStore();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
if (fromGateway) {
if (this.seenRealtimeMessageIds.has(message.id)) return;
this.seenRealtimeMessageIds.add(message.id);
if (this.seenRealtimeMessageIds.size > 1000) {
const oldest = this.seenRealtimeMessageIds.values().next().value;
if (oldest) this.seenRealtimeMessageIds.delete(oldest);
}
}
const isOwnMessage = authStore.currentUser?.id === message.user_id;
const isActiveChannel = message.channel_id === this.activeChannelId;
if (!isActiveChannel) {
if (fromGateway && !isOwnMessage) {
serverStore.applyIncomingMessage(message.server_id, message.channel_id);
notificationStore.show("Nouveau message", "Un nouveau message est arrivé dans un autre canal.");
}
return;
}
const existingIndex = this.messages.findIndex(current => current.id === message.id);
if (existingIndex !== -1) {
this.messages[existingIndex] = message;
return;
}
if (!this.isAtBottom && this.newestId && message.id > this.newestId) {
this.hasMoreAfter = true;
if (fromGateway && !isOwnMessage) {
serverStore.applyIncomingMessage(message.server_id, message.channel_id);
}
return;
}
this.messages = mergeMessages([...this.messages, message]).slice(-MESSAGE_WINDOW_SIZE);
this.updateLocalBoundaries();
this.hasMoreAfter = false;
if (this.isAtBottom) {
this.scrollToBottomRequested = true;
} else if (fromGateway && !isOwnMessage) {
serverStore.applyIncomingMessage(message.server_id, message.channel_id);
}
},
updateMessage(message: Message) {
if (message.channel_id !== this.activeChannelId) return;
const index = this.messages.findIndex(current => current.id === message.id);
if (index !== -1) this.messages[index] = message;
},
removeMessage(id: string) {
const index = this.messages.findIndex(message => message.id === id);
if (index === -1) return;
this.messages.splice(index, 1);
this.updateLocalBoundaries();
},
setAtBottom(value: boolean) {
this.isAtBottom = value;
},
consumeScrollToBottomRequest() {
const requested = this.scrollToBottomRequested;
this.scrollToBottomRequested = false;
return requested;
},
reset() {
this.requestVersion += 1;
this.messages = [];
}
}
this.activeChannelId = null;
this.oldestId = null;
this.newestId = null;
this.hasMoreBefore = false;
this.hasMoreAfter = false;
this.loading = false;
this.loadingBefore = false;
this.loadingAfter = false;
this.isAtBottom = true;
this.scrollToBottomRequested = false;
this.seenRealtimeMessageIds.clear();
},
},
});
onGatewayEvent("Message", (payload) => {
const store = useMessageStore();
switch (payload.action) {
case "add":
const exists = store.messages.some(m => m.id === payload.content.id);
if (!exists) {
store.messages.push(payload.content);
}
store.addRealtimeMessage(payload.content as Message, true);
break;
case "update":
const updateIndex = store.messages.findIndex(m => m.id === payload.content.id);
if (updateIndex !== -1) {
store.messages[updateIndex] = payload.content;
}
store.updateMessage(payload.content as Message);
break;
case "remove":
const removeIndex = store.messages.findIndex(m => m.id === payload.content);
if (removeIndex !== -1) {
store.messages.splice(removeIndex, 1);
}
store.removeMessage(String(payload.content));
break;
default:
console.warn("Action non gérée :", payload.action);
}
})
});
+19
View File
@@ -0,0 +1,19 @@
import {defineStore} from "pinia";
export const useNotificationStore = defineStore("notification", {
state: () => ({
visible: false,
title: "",
message: "",
}),
actions: {
show(title: string, message: string) {
this.title = title;
this.message = message;
this.visible = true;
},
hide() {
this.visible = false;
},
},
});
+62
View File
@@ -0,0 +1,62 @@
import {defineStore} from 'pinia'
import {useApi} from '@/composables/useApi'
import type {Role} from '@/types/role'
import type {User} from '@/types/user'
export const useRoleStore = defineStore('role', {
state: () => ({
roles: [] as Role[],
members: {} as Record<string, User[]>,
loading: false,
}),
actions: {
async fetchRoles(serverId: string) {
const response = await useApi().get(`/roles?server_id=${serverId}`)
if (!response.ok) throw new Error('Impossible de charger les rôles')
this.roles = await response.json()
return this.roles
},
async createRole(payload: {server_id: string; name: string; is_default?: boolean}) {
const response = await useApi().post('/roles', payload)
if (!response.ok) throw new Error('Impossible de créer le rôle')
const role = await response.json()
this.roles.push(role)
return role
},
async updateRole(id: string, payload: {name: string; is_default?: boolean}) {
const response = await useApi().put(`/roles/${id}`, payload)
if (!response.ok) throw new Error('Impossible de modifier le rôle')
const role = await response.json()
const index = this.roles.findIndex(item => item.id === id)
if (index >= 0) this.roles[index] = role
return role
},
async deleteRole(id: string) {
const response = await useApi().delete(`/roles/${id}`)
if (!response.ok) throw new Error('Impossible de supprimer le rôle')
this.roles = this.roles.filter(role => role.id !== id)
delete this.members[id]
},
async fetchMembers(roleId: string) {
const response = await useApi().get(`/roles/${roleId}/members`)
if (!response.ok) throw new Error('Impossible de charger les membres')
this.members[roleId] = await response.json()
return this.members[roleId]
},
async addMember(roleId: string, userId: string) {
const response = await useApi().put(`/roles/${roleId}/members/${userId}`)
if (!response.ok) throw new Error('Impossible dajouter le membre')
await this.fetchMembers(roleId)
},
async removeMember(roleId: string, userId: string) {
const response = await useApi().delete(`/roles/${roleId}/members/${userId}`)
if (!response.ok) throw new Error('Impossible de retirer le membre')
await this.fetchMembers(roleId)
},
reset() {
this.roles = []
this.members = {}
this.loading = false
},
},
})
+135 -2
View File
@@ -1,13 +1,23 @@
import {defineStore} from "pinia";
import {useApi} from "@/composables/useApi.ts";
import {useChannelStore} from "@/stores/channel.ts";
import {useCategoryStore} from "@/stores/category.ts";
interface Server {
export interface Server {
id: string
name: string
is_default: boolean
created_at: string
updated_at: string
unread_count?: number
}
export const useServerStore = defineStore("server", {
state: () => ({
servers: [] as Server[]
servers: [] as Server[],
loading: false,
error: null as string | null,
currentTree: [] as any[],
}),
actions: {
async fetchServers() {
@@ -15,8 +25,131 @@ export const useServerStore = defineStore("server", {
const response = await api.get("/servers");
this.servers = await response.json();
},
async fetchServer(serverId: string) {
const response = await useApi().get(`/servers/${serverId}`);
if (!response.ok) {
const error = await response.json().catch(() => null);
throw new Error(error?.message || 'Failed to load server');
}
const server: Server = await response.json();
const index = this.servers.findIndex(item => item.id === server.id);
if (index >= 0) {
server.unread_count ??= this.servers[index].unread_count ?? 0;
this.servers[index] = server;
}
else this.servers.push(server);
return server;
},
async createServer(payload: { name: string; password?: string | null; is_default?: boolean }) {
this.loading = true;
this.error = null;
try {
const api = useApi();
const response = await api.post("/servers", payload);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create server');
}
const newServer = await response.json();
this.servers.push(newServer);
return newServer;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Unknown error';
throw err;
} finally {
this.loading = false;
}
},
async updateServer(serverId: string, payload: { name: string; is_default?: boolean }) {
const api = useApi();
const response = await api.put(`/servers/${serverId}`, {
name: payload.name,
password: null,
is_default: payload.is_default ?? false,
});
if (!response.ok) {
const error = await response.json().catch(() => null);
throw new Error(error?.message || 'Failed to update server');
}
const updated = await response.json();
const index = this.servers.findIndex(server => server.id === serverId);
if (index >= 0) {
updated.unread_count ??= this.servers[index].unread_count ?? 0;
this.servers[index] = updated;
}
return updated;
},
async fetchServerTree(serverId: string) {
const api = useApi();
const channelStore = useChannelStore();
const categoryStore = useCategoryStore();
const response = await api.get(`/servers/${serverId}/tree`);
const tree = await response.json(); // { items: [...] }
this.currentTree = tree.items;
// Extraction à plat pour alimenter les stores spécialisés
const extractedCategories: any[] = [];
const extractedChannels: any[] = [];
for (const item of tree.items) {
if ("Category" in item) {
const [category, channels] = item.Category;
extractedCategories.push(category);
extractedChannels.push(...channels);
} else if ("Channel" in item) {
extractedChannels.push(item.Channel);
}
}
// Population / Synchro des stores individuels
categoryStore.categories = extractedCategories;
channelStore.channels = extractedChannels;
return tree.items;
},
applyChannelReadState(serverId: string, channelId: string, unreadCount: number) {
let previousUnreadCount = 0;
for (const item of this.currentTree) {
const channels = "Category" in item ? item.Category[1] : "Channel" in item ? [item.Channel] : [];
const channel = channels.find((candidate: { id: string }) => candidate.id === channelId);
if (channel) {
previousUnreadCount = channel.unread_count ?? 0;
channel.unread_count = unreadCount;
break;
}
}
const server = this.servers.find(candidate => candidate.id === serverId);
if (server) {
server.unread_count = Math.max(
0,
(server.unread_count ?? 0) - previousUnreadCount + unreadCount,
);
}
},
applyIncomingMessage(serverId: string | null, channelId: string) {
if (!serverId) return;
for (const item of this.currentTree) {
const channels = "Category" in item ? item.Category[1] : "Channel" in item ? [item.Channel] : [];
const channel = channels.find((candidate: { id: string }) => candidate.id === channelId);
if (channel) {
channel.unread_count = (channel.unread_count ?? 0) + 1;
break;
}
}
const server = this.servers.find(candidate => candidate.id === serverId);
if (server) server.unread_count = (server.unread_count ?? 0) + 1;
},
reset() {
this.servers = [];
this.loading = false;
this.error = null;
}
}
});
+17
View File
@@ -5,8 +5,10 @@ import {useServerStore} from '@/stores/server.ts'
import {useCategoryStore} from '@/stores/category.ts'
import {useChannelStore} from '@/stores/channel.ts'
import {useMessageStore} from '@/stores/message.ts'
import {onReloadAll} from '@/plugins/events.ts'
let bootstrapPromise: Promise<void> | null = null
let reloadServersPromise: Promise<void> | null = null
export const useSessionStore = defineStore('session', {
state: () => ({
@@ -77,6 +79,19 @@ export const useSessionStore = defineStore('session', {
])
},
async reloadServers() {
if (reloadServersPromise) {
return reloadServersPromise
}
const serverStore = useServerStore()
reloadServersPromise = serverStore.fetchServers().finally(() => {
reloadServersPromise = null
})
return reloadServersPromise
},
async login(username: string, password: string) {
const authStore = useAuthStore()
@@ -94,3 +109,5 @@ export const useSessionStore = defineStore('session', {
},
},
})
onReloadAll(() => useSessionStore().reloadServers())
+31
View File
@@ -0,0 +1,31 @@
import {defineStore} from 'pinia'
import {useApi} from "@/composables/useApi.ts";
import type {User} from "@/types/user";
export const useUserStore = defineStore('user', {
state: () => ({
users: [] as User[]
}),
getters: {
usersById: (state): Record<string, User> => {
return state.users.reduce((acc, user) => {
acc[user.id] = user;
return acc;
}, {} as Record<string, User>);
}
},
actions: {
async fetchUsers(serverId?: string) {
let api = useApi();
let url = "/users";
if (serverId) {
url += `?server_id=${serverId}`;
}
const response = await api.get(url);
this.users = await response.json();
},
reset() {
this.users = [];
}
}
});
+464
View File
@@ -0,0 +1,464 @@
export type PermissionMaskInput = bigint | number | string
export type ServerPermissionMask = bigint & {
readonly __brand: 'ServerPermissionMask'
}
export type ChannelPermissionMask = bigint & {
readonly __brand: 'ChannelPermissionMask'
}
export interface ServerPermissionDefinition {
key: string
label: string
description?: string
bit: ServerPermissionMask
category: 'management' | 'members'
}
export interface ChannelPermissionDefinition {
key: string
label: string
description?: string
bit: ChannelPermissionMask
category: 'chat' | 'voice' | 'management'
}
export interface ServerUserPermission {
id: string
server_id: string
user_id: string
permissions: ServerPermissionMask
}
export interface ServerRolePermission {
id: string
server_id: string
role_id: string
permissions: ServerPermissionMask
}
export interface ChannelUserPermission {
id: string
channel_id: string
user_id: string
permissions: ChannelPermissionMask
}
export interface ChannelRolePermission {
id: string
channel_id: string
role_id: string
permissions: ChannelPermissionMask
}
export interface ChannelPermissions {
users: ChannelUserPermission[]
roles: ChannelRolePermission[]
}
export interface ServerUserPermissionDto {
id: string
server_id: string
user_id: string
permissions: number | string
}
export interface ServerRolePermissionDto {
id: string
server_id: string
role_id: string
permissions: number | string
}
export interface ChannelUserPermissionDto {
id: string
channel_id: string
user_id: string
permissions: number | string
}
export interface ChannelRolePermissionDto {
id: string
channel_id: string
role_id: string
permissions: number | string
}
export interface ChannelPermissionsDto {
users: ChannelUserPermissionDto[]
roles: ChannelRolePermissionDto[]
}
function asServerPermissionMask(value: bigint): ServerPermissionMask {
return value as ServerPermissionMask
}
function asChannelPermissionMask(value: bigint): ChannelPermissionMask {
return value as ChannelPermissionMask
}
function toBigInt(value: PermissionMaskInput): bigint {
if (typeof value === 'bigint') {
return value
}
if (typeof value === 'number') {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`Invalid permission mask: ${value}`)
}
return BigInt(value)
}
if (!/^\d+$/.test(value)) {
throw new Error(`Invalid permission mask: ${value}`)
}
return BigInt(value)
}
export function toServerPermissionMask(
value: PermissionMaskInput,
): ServerPermissionMask {
return asServerPermissionMask(toBigInt(value))
}
export function toChannelPermissionMask(
value: PermissionMaskInput,
): ChannelPermissionMask {
return asChannelPermissionMask(toBigInt(value))
}
export const SERVER_PERMISSIONS = {
MANAGE_SERVER: asServerPermissionMask(1n << 0n),
MANAGE_ROLES: asServerPermissionMask(1n << 1n),
MANAGE_CATEGORIES: asServerPermissionMask(1n << 2n),
MANAGE_CHANNELS: asServerPermissionMask(1n << 3n),
KICK_MEMBERS: asServerPermissionMask(1n << 4n),
BAN_MEMBERS: asServerPermissionMask(1n << 5n),
MANAGE_MEMBERS: asServerPermissionMask(1n << 6n),
VIEW_MEMBERS: asServerPermissionMask(1n << 7n),
} as const satisfies Record<string, ServerPermissionMask>
export const CHANNEL_PERMISSIONS = {
READ_CHANNEL: asChannelPermissionMask(1n << 0n),
SEND_MESSAGE: asChannelPermissionMask(1n << 1n),
EDIT_OWN_MESSAGE: asChannelPermissionMask(1n << 2n),
DELETE_OWN_MESSAGE: asChannelPermissionMask(1n << 3n),
EDIT_OTHERS_MESSAGES: asChannelPermissionMask(1n << 4n),
DELETE_OTHERS_MESSAGES: asChannelPermissionMask(1n << 5n),
ADD_REACTIONS: asChannelPermissionMask(1n << 6n),
ATTACH_FILES: asChannelPermissionMask(1n << 7n),
MANAGE_CHANNEL: asChannelPermissionMask(1n << 8n),
MANAGE_MESSAGES: asChannelPermissionMask(1n << 10n),
JOIN_VOICE: asChannelPermissionMask(1n << 30n),
SPEAK: asChannelPermissionMask(1n << 31n),
STREAM: asChannelPermissionMask(1n << 32n),
MUTE_SELF: asChannelPermissionMask(1n << 33n),
MUTE_OTHERS: asChannelPermissionMask(1n << 34n),
MOVE_OTHERS: asChannelPermissionMask(1n << 35n),
DISCONNECT_OTHERS: asChannelPermissionMask(1n << 36n),
MANAGE_VOICE_CHANNEL: asChannelPermissionMask(1n << 37n),
} as const satisfies Record<string, ChannelPermissionMask>
export const SERVER_PERMISSION_DEFINITIONS: ServerPermissionDefinition[] = [
{
key: 'manage_server',
label: 'Gérer le serveur',
description: 'Modifier les paramètres généraux du serveur.',
bit: SERVER_PERMISSIONS.MANAGE_SERVER,
category: 'management',
},
{
key: 'manage_roles',
label: 'Gérer les rôles',
description: 'Créer, modifier et supprimer des rôles.',
bit: SERVER_PERMISSIONS.MANAGE_ROLES,
category: 'management',
},
{
key: 'manage_categories',
label: 'Gérer les catégories',
description: 'Créer, modifier et supprimer des catégories.',
bit: SERVER_PERMISSIONS.MANAGE_CATEGORIES,
category: 'management',
},
{
key: 'manage_channels',
label: 'Gérer les canaux',
description: 'Créer, modifier et supprimer des canaux.',
bit: SERVER_PERMISSIONS.MANAGE_CHANNELS,
category: 'management',
},
{
key: 'kick_members',
label: 'Expulser des membres',
description: 'Expulser un membre du serveur.',
bit: SERVER_PERMISSIONS.KICK_MEMBERS,
category: 'members',
},
{
key: 'ban_members',
label: 'Bannir des membres',
description: 'Bannir un membre du serveur.',
bit: SERVER_PERMISSIONS.BAN_MEMBERS,
category: 'members',
},
{
key: 'manage_members',
label: 'Gérer les membres',
description: 'Modifier le profil ou les rôles dun membre.',
bit: SERVER_PERMISSIONS.MANAGE_MEMBERS,
category: 'members',
},
{
key: 'view_members',
label: 'Voir les membres',
description: 'Voir la liste des membres du serveur.',
bit: SERVER_PERMISSIONS.VIEW_MEMBERS,
category: 'members',
},
]
export const CHANNEL_PERMISSION_DEFINITIONS: ChannelPermissionDefinition[] = [
{
key: 'read_channel',
label: 'Lire le canal',
description: 'Voir le canal et son contenu.',
bit: CHANNEL_PERMISSIONS.READ_CHANNEL,
category: 'chat',
},
{
key: 'send_message',
label: 'Envoyer des messages',
bit: CHANNEL_PERMISSIONS.SEND_MESSAGE,
category: 'chat',
},
{
key: 'edit_own_message',
label: 'Modifier ses messages',
bit: CHANNEL_PERMISSIONS.EDIT_OWN_MESSAGE,
category: 'chat',
},
{
key: 'delete_own_message',
label: 'Supprimer ses messages',
bit: CHANNEL_PERMISSIONS.DELETE_OWN_MESSAGE,
category: 'chat',
},
{
key: 'edit_others_messages',
label: 'Modifier les messages des autres',
bit: CHANNEL_PERMISSIONS.EDIT_OTHERS_MESSAGES,
category: 'chat',
},
{
key: 'delete_others_messages',
label: 'Supprimer les messages des autres',
bit: CHANNEL_PERMISSIONS.DELETE_OTHERS_MESSAGES,
category: 'chat',
},
{
key: 'add_reactions',
label: 'Ajouter des réactions',
bit: CHANNEL_PERMISSIONS.ADD_REACTIONS,
category: 'chat',
},
{
key: 'attach_files',
label: 'Joindre des fichiers',
bit: CHANNEL_PERMISSIONS.ATTACH_FILES,
category: 'chat',
},
{
key: 'manage_channel',
label: 'Gérer le canal',
description: 'Modifier les paramètres du canal.',
bit: CHANNEL_PERMISSIONS.MANAGE_CHANNEL,
category: 'management',
},
{
key: 'manage_messages',
label: 'Gérer les messages',
bit: CHANNEL_PERMISSIONS.MANAGE_MESSAGES,
category: 'management',
},
{
key: 'join_voice',
label: 'Rejoindre le vocal',
bit: CHANNEL_PERMISSIONS.JOIN_VOICE,
category: 'voice',
},
{
key: 'speak',
label: 'Parler',
bit: CHANNEL_PERMISSIONS.SPEAK,
category: 'voice',
},
{
key: 'stream',
label: 'Diffuser',
bit: CHANNEL_PERMISSIONS.STREAM,
category: 'voice',
},
{
key: 'mute_self',
label: 'Se mettre en sourdine',
bit: CHANNEL_PERMISSIONS.MUTE_SELF,
category: 'voice',
},
{
key: 'mute_others',
label: 'Rendre les autres muets',
bit: CHANNEL_PERMISSIONS.MUTE_OTHERS,
category: 'voice',
},
{
key: 'move_others',
label: 'Déplacer les autres',
bit: CHANNEL_PERMISSIONS.MOVE_OTHERS,
category: 'voice',
},
{
key: 'disconnect_others',
label: 'Déconnecter les autres',
bit: CHANNEL_PERMISSIONS.DISCONNECT_OTHERS,
category: 'voice',
},
{
key: 'manage_voice_channel',
label: 'Gérer le canal vocal',
bit: CHANNEL_PERMISSIONS.MANAGE_VOICE_CHANNEL,
category: 'management',
},
]
export function hasServerPermission(
mask: ServerPermissionMask,
required: ServerPermissionMask,
): boolean {
return (mask & required) === required
}
export function grantServerPermission(
mask: ServerPermissionMask,
permission: ServerPermissionMask,
): ServerPermissionMask {
return asServerPermissionMask(mask | permission)
}
export function revokeServerPermission(
mask: ServerPermissionMask,
permission: ServerPermissionMask,
): ServerPermissionMask {
return asServerPermissionMask(mask & ~permission)
}
export function toggleServerPermission(
mask: ServerPermissionMask,
permission: ServerPermissionMask,
enabled: boolean,
): ServerPermissionMask {
return enabled
? grantServerPermission(mask, permission)
: revokeServerPermission(mask, permission)
}
export function hasChannelPermission(
mask: ChannelPermissionMask,
required: ChannelPermissionMask,
): boolean {
return (mask & required) === required
}
export function grantChannelPermission(
mask: ChannelPermissionMask,
permission: ChannelPermissionMask,
): ChannelPermissionMask {
return asChannelPermissionMask(mask | permission)
}
export function revokeChannelPermission(
mask: ChannelPermissionMask,
permission: ChannelPermissionMask,
): ChannelPermissionMask {
return asChannelPermissionMask(mask & ~permission)
}
export function toggleChannelPermission(
mask: ChannelPermissionMask,
permission: ChannelPermissionMask,
enabled: boolean,
): ChannelPermissionMask {
return enabled
? grantChannelPermission(mask, permission)
: revokeChannelPermission(mask, permission)
}
export function permissionMaskToJson(
mask: PermissionMaskInput,
): number | string {
const value = toBigInt(mask)
if (value < 0n) {
throw new Error('Permission mask cannot be negative')
}
return value <= BigInt(Number.MAX_SAFE_INTEGER)
? Number(value)
: value.toString()
}
export function serverUserPermissionFromDto(
dto: ServerUserPermissionDto,
): ServerUserPermission {
return {
id: dto.id,
server_id: dto.server_id,
user_id: dto.user_id,
permissions: toServerPermissionMask(dto.permissions),
}
}
export function serverRolePermissionFromDto(
dto: ServerRolePermissionDto,
): ServerRolePermission {
return {
id: dto.id,
server_id: dto.server_id,
role_id: dto.role_id,
permissions: toServerPermissionMask(dto.permissions),
}
}
export function channelUserPermissionFromDto(
dto: ChannelUserPermissionDto,
): ChannelUserPermission {
return {
id: dto.id,
channel_id: dto.channel_id,
user_id: dto.user_id,
permissions: toChannelPermissionMask(dto.permissions),
}
}
export function channelRolePermissionFromDto(
dto: ChannelRolePermissionDto,
): ChannelRolePermission {
return {
id: dto.id,
channel_id: dto.channel_id,
role_id: dto.role_id,
permissions: toChannelPermissionMask(dto.permissions),
}
}
export function channelPermissionsFromDto(
dto: ChannelPermissionsDto,
): ChannelPermissions {
return {
users: dto.users.map(channelUserPermissionFromDto),
roles: dto.roles.map(channelRolePermissionFromDto),
}
}
+7
View File
@@ -0,0 +1,7 @@
export interface Role {
id: string
server_id: string
name: string
is_default: boolean
created_at: string
}
+8
View File
@@ -0,0 +1,8 @@
export interface User {
id: string
username: string
pub_key: string | null
is_superuser: boolean
created_at: string
updated_at: string
}
+5
View File
@@ -1660,6 +1660,11 @@ has-flag@^4.0.0:
resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
highlight.js@^11.11.1:
version "11.11.1"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.11.1.tgz#fca06fa0e5aeecf6c4d437239135fabc15213585"
integrity sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==
hookable@^5.5.3:
version "5.5.3"
resolved "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz"
+1 -1
View File
@@ -12,7 +12,7 @@ path = "src/lib.rs"
async-std = { version = "1", features = ["attributes", "tokio1"] }
[dependencies.sea-orm-migration]
version = "2.0.0-rc.42"
version = "2.0.0"
features = [
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Generate test messages directly in the project's SQLite database."""
# python3 scripts/generate_messages.py \
# --db oxspeak.db \
# --channel-id 672e7757-b7df-401b-8e47-8c62e1fb9d7d \
# --user-id d327a80b-83d4-4a53-9c0b-140f60cc0caa \
# --count 1000 \
# --min-words 10 \
# --max-words 500
from __future__ import annotations
import argparse
import random
import sqlite3
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
WORD_POOL = (
"message", "canal", "serveur", "utilisateur", "test", "donnee", "histoire",
"discussion", "contenu", "generation", "curseur", "fenetre", "lecture",
"chargement", "conversation", "exemple", "texte", "systeme", "application",
"client", "serveur", "base", "requete", "resultat", "information", "session",
"connexion", "fonction", "version", "contenu", "rapide", "simple", "aleatoire",
"important", "nouveau", "ancien", "prochain", "precedent", "visible", "local",
"distant", "stable", "chronologique", "variable", "longueur", "performance",
"validation", "operation", "transaction", "historique", "position", "defilement",
)
MESSAGE_MARKER_FORMAT = "[{number:04d}]"
def positive_int(value: str) -> int:
parsed = int(value)
if parsed <= 0:
raise argparse.ArgumentTypeError("must be greater than zero")
return parsed
def parse_uuid(value: str, option_name: str) -> uuid.UUID:
try:
return uuid.UUID(value)
except ValueError as error:
raise argparse.ArgumentTypeError(f"{option_name} is not a valid UUID: {value}") from error
def next_uuid(previous: uuid.UUID | None) -> uuid.UUID:
"""Return a UUID v7 strictly greater than the previous generated ID."""
generated = uuid.uuid7()
if previous is not None and generated.int <= previous.int:
generated = uuid.UUID(int=previous.int + 1)
return generated
def random_message(
rng: random.Random,
min_words: int,
max_words: int,
marker: str,
) -> str:
# The marker itself counts as one word in the requested range.
body_count = rng.randint(max(0, min_words - 1), max_words - 1)
body = " ".join(rng.choices(WORD_POOL, k=body_count))
return f"{marker} {body}".rstrip()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--db",
type=Path,
default=Path("oxspeak.db"),
help="SQLite database path (default: oxspeak.db)",
)
parser.add_argument("--channel-id", required=True, help="target channel UUID")
parser.add_argument("--user-id", required=True, help="author user UUID")
parser.add_argument(
"--count",
required=True,
type=positive_int,
help="number of messages to insert",
)
parser.add_argument(
"--min-words",
type=positive_int,
default=10,
help="minimum number of words per message (default: 10)",
)
parser.add_argument(
"--max-words",
type=positive_int,
default=500,
help="maximum number of words per message (default: 500)",
)
parser.add_argument(
"--seed",
type=int,
default=None,
help="optional seed to reproduce generated contents",
)
parser.add_argument(
"--batch-size",
type=positive_int,
default=500,
help="number of rows inserted per batch (default: 500)",
)
return parser
def ensure_target_exists(
connection: sqlite3.Connection,
table: str,
identifier: bytes,
label: str,
) -> None:
row = connection.execute(
f'SELECT 1 FROM "{table}" WHERE id = ? LIMIT 1',
(identifier,),
).fetchone()
if row is None:
raise ValueError(f"{label} does not exist in the database")
def generate_messages(
database: Path,
channel_id: uuid.UUID,
user_id: uuid.UUID,
count: int,
batch_size: int,
min_words: int,
max_words: int,
seed: int | None,
) -> None:
started_at = time.monotonic()
connection = sqlite3.connect(database)
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA busy_timeout = 5000")
try:
ensure_target_exists(connection, "channel", channel_id.bytes, "channel")
ensure_target_exists(connection, "user", user_id.bytes, "user")
previous_id: uuid.UUID | None = None
inserted = 0
rng = random.Random(seed)
connection.execute("BEGIN")
try:
while inserted < count:
current_batch_size = min(batch_size, count - inserted)
rows = []
for offset in range(current_batch_size):
message_id = next_uuid(previous_id)
previous_id = message_id
message_number = inserted + offset + 1
marker = MESSAGE_MARKER_FORMAT.format(number=message_number)
content = random_message(rng, min_words, max_words, marker)
created_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
rows.append(
(
message_id.bytes,
channel_id.bytes,
user_id.bytes,
content,
created_at,
None,
None,
)
)
connection.executemany(
"""
INSERT INTO message
(id, channel_id, user_id, content, created_at, updated_at, reply_to_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
inserted += current_batch_size
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
elapsed = time.monotonic() - started_at
print(f"Inserted {count} messages into {database} in {elapsed:.2f}s")
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.max_words < args.min_words:
parser.error("--max-words must be greater than or equal to --min-words")
try:
channel_id = parse_uuid(args.channel_id, "--channel-id")
user_id = parse_uuid(args.user_id, "--user-id")
generate_messages(
database=args.db,
channel_id=channel_id,
user_id=user_id,
count=args.count,
batch_size=args.batch_size,
min_words=args.min_words,
max_words=args.max_words,
seed=args.seed,
)
except (OSError, sqlite3.Error, ValueError) as error:
parser.error(str(error))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+11 -3
View File
@@ -3,9 +3,10 @@ pub mod state;
use crate::config::AppConfig;
use crate::database::Database;
use crate::http::server::HttpServer;
use crate::metrics::{reporter, AppMetrics};
use crate::metrics::{AppMetrics, reporter};
use crate::repositories::Repositories;
use crate::routes::gateway::GatewayManager;
use crate::services::Services;
use crate::udp::server::UdpServer;
use event_bus::EventBus;
use migration::{Migrator, MigratorTrait};
@@ -31,10 +32,9 @@ impl App {
let event_bus = Arc::new(EventBus::with_capacity(1024));
// Initialize shared repositories
let repositories = Repositories::new(db.clone(), event_bus.clone());
let repositories = Arc::new(Repositories::new(db.clone()));
// Initialize gateway manager
let gateway = Arc::new(GatewayManager::default());
// Init one server if no one exist
let default_server = match repositories.server.get_default().await? {
@@ -66,6 +66,13 @@ impl App {
let metrics = AppMetrics::new();
let services = Arc::new(Services::new(repositories.clone(), event_bus.clone()));
services.permission_sync.start_listen_event().await;
services.realtime_registry.initialize(&repositories).await?;
services.realtime_registry.start_listening(repositories.clone(), event_bus.clone());
let gateway = Arc::new(GatewayManager::new(services.clone()));
gateway.start(event_bus.clone());
let state = AppState {
db,
config: Arc::new(config),
@@ -75,6 +82,7 @@ impl App {
metrics,
gateway,
event_bus,
services,
};
Ok(Self { state })
+3 -1
View File
@@ -3,6 +3,7 @@ use crate::metrics::AppMetrics;
use crate::models::server;
use crate::repositories::Repositories;
use crate::routes::gateway::GatewayManager;
use crate::services::Services;
use event_bus::EventBus;
use sea_orm::DatabaseConnection;
use std::sync::{Arc, RwLock};
@@ -11,12 +12,13 @@ use std::sync::{Arc, RwLock};
pub struct AppState {
pub db: DatabaseConnection,
pub config: Arc<AppConfig>,
pub repositories: Repositories,
pub repositories: Arc<Repositories>,
pub init_token: Arc<RwLock<Option<uuid::Uuid>>>,
pub default_server: Arc<server::Model>,
pub metrics: AppMetrics,
pub gateway: Arc<GatewayManager>,
pub event_bus: Arc<EventBus>,
pub services: Arc<Services>,
}
impl AppState {}
@@ -1,4 +1,4 @@
use crate::routes::user::dto::UserResponse;
use crate::domain::dto::user::UserResponse;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -3,20 +3,28 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema, utoipa::IntoParams)]
pub struct CategoryQueryParams {
pub server_id: Option<Uuid>,
}
impl Default for CategoryQueryParams {
fn default() -> Self {
Self { server_id: None }
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateCategoryRequest {
pub server_id: Uuid,
#[schema(example = "Discussion")]
pub name: String,
#[serde(default)]
pub position: i32,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateCategoryRequest {
#[schema(example = "Discussion (Maj)")]
pub name: String,
pub position: i32,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -24,7 +32,11 @@ pub struct CategoryResponse {
pub id: Uuid,
pub server_id: Uuid,
pub name: String,
pub position: i32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// None : contexte sans permissions (champ ignoré dans le JSON).
/// Some(value) : valeur de computed_permission (0 si absente).
#[serde(skip_serializing_if = "Option::is_none")]
pub permission: Option<u64>,
}
+94
View File
@@ -0,0 +1,94 @@
use crate::models::channel::ChannelType;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema, utoipa::IntoParams)]
pub struct ChannelQueryParams {
pub server_id: Option<Uuid>,
}
impl Default for ChannelQueryParams {
fn default() -> Self {
Self { server_id: None }
}
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateChannelRequest {
pub server_id: Option<Uuid>,
pub category_id: Option<Uuid>,
pub channel_type: ChannelType,
#[schema(example = "général")]
pub name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateChannelRequest {
pub server_id: Option<Uuid>,
pub category_id: Option<Uuid>,
pub channel_type: ChannelType,
pub name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ChannelResponse {
pub id: Uuid,
pub server_id: Option<Uuid>,
pub category_id: Option<Uuid>,
pub channel_type: ChannelType,
pub name: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unread_count: Option<u64>,
/// None : contexte sans permissions (champ ignoré dans le JSON).
/// Some(value) : valeur de computed_permission (0 si absente).
#[serde(skip_serializing_if = "Option::is_none")]
pub permission: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ReadStateResponse {
pub channel_id: Uuid,
pub last_read_message_id: Option<Uuid>,
pub updated_at: Option<DateTime<Utc>>,
pub unread_count: u64,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SetReadStateRequest {
pub last_read_message_id: Option<Uuid>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SetChannelPermissionRequest {
/// Bitmask des permissions à appliquer.
#[schema(example = 15)]
pub permissions: u64,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ChannelUserPermissionResponse {
pub id: Uuid,
pub channel_id: Uuid,
pub user_id: Uuid,
pub permissions: u64,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ChannelRolePermissionResponse {
pub id: Uuid,
pub channel_id: Uuid,
pub role_id: Uuid,
pub permissions: u64,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ChannelPermissionsResponse {
pub users: Vec<ChannelUserPermissionResponse>,
pub roles: Vec<ChannelRolePermissionResponse>,
}
@@ -6,6 +6,7 @@ use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct MessageResponse {
pub id: Uuid,
pub server_id: Option<Uuid>,
pub channel_id: Uuid,
pub user_id: Uuid,
pub content: String,
@@ -14,6 +15,15 @@ pub struct MessageResponse {
pub reply_to_id: Option<Uuid>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct MessagePageResponse {
pub messages: Vec<MessageResponse>,
pub oldest_id: Option<Uuid>,
pub newest_id: Option<Uuid>,
pub has_more_before: bool,
pub has_more_after: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateMessageRequest {
pub channel_id: Uuid,
@@ -26,19 +36,10 @@ pub struct UpdateMessageRequest {
pub content: String,
}
#[derive(serde::Deserialize, utoipa::IntoParams)]
#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
pub struct MessageQueryParams {
pub channel_id: Option<uuid::Uuid>,
pub channel_id: Uuid,
pub before_id: Option<Uuid>,
pub after_id: Option<Uuid>,
pub limit: Option<u64>,
}
impl Default for MessageQueryParams {
fn default() -> Self {
Self {
channel_id: None,
before_id: None,
limit: Some(50),
}
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod auth;
pub mod attachment;
pub mod message;
pub mod role;
pub mod server;
pub mod core;
pub mod user;
pub mod channel;
pub mod category;
@@ -3,36 +3,32 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema, utoipa::IntoParams)]
pub struct RoleQueryParams {
pub server_id: Option<Uuid>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateGroupRequest {
pub struct CreateRoleRequest {
pub server_id: Uuid,
#[schema(example = "Modérateurs")]
pub name: String,
#[serde(default)]
pub is_default: bool,
pub server_permissions: i64,
pub channel_permissions: i64,
pub voice_permissions: i64,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateGroupRequest {
pub struct UpdateRoleRequest {
#[schema(example = "Modérateurs (MAJ)")]
pub name: String,
pub is_default: bool,
pub server_permissions: i64,
pub channel_permissions: i64,
pub voice_permissions: i64,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct GroupResponse {
pub struct RoleResponse {
pub id: Uuid,
pub server_id: Uuid,
pub name: String,
pub is_default: bool,
pub created_at: DateTime<Utc>,
pub server_permissions: i64,
pub channel_permissions: i64,
pub voice_permissions: i64,
}
+67
View File
@@ -0,0 +1,67 @@
use crate::domain::dto::category::CategoryResponse;
use crate::domain::dto::channel::ChannelResponse;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateServerRequest {
#[schema(example = "Mon Super Serveur")]
pub name: String,
pub password: Option<String>,
#[serde(default)]
pub is_default: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateServerRequest {
pub name: String,
pub password: Option<String>,
pub is_default: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ServerResponse {
pub id: Uuid,
pub name: String,
pub is_default: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unread_count: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SetServerPermissionRequest {
/// Bitmask des permissions à appliquer.
#[schema(example = 7)]
pub permissions: u64,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ServerUserPermissionResponse {
pub id: Uuid,
pub server_id: Uuid,
pub user_id: Uuid,
pub permissions: u64,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct ServerRolePermissionResponse {
pub id: Uuid,
pub server_id: Uuid,
pub role_id: Uuid,
pub permissions: u64,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub enum ServerExplorerItemResponse {
Category(CategoryResponse, Vec<ChannelResponse>),
Channel(ChannelResponse),
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ServerTreeResponse {
pub items: Vec<ServerExplorerItemResponse>,
}
@@ -3,6 +3,11 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema, utoipa::IntoParams)]
pub struct UserQueryParams {
pub server_id: Option<Uuid>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateUserRequest {
pub username: String,
+20
View File
@@ -0,0 +1,20 @@
use crate::models::channel;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct ChannelCreatedEvent {
pub server_id: Uuid,
pub channel: channel::Model,
}
#[derive(Debug, Clone)]
pub struct ChannelUpdatedEvent {
pub server_id: Uuid,
pub channel: channel::Model,
}
#[derive(Debug, Clone)]
pub struct ChannelDeletedEvent {
pub server_id: Uuid,
pub channel: channel::Model,
}
+23
View File
@@ -0,0 +1,23 @@
use crate::models::message;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct MessageCreatedEvent {
pub server_id: Option<Uuid>,
pub channel_id: Uuid,
pub message: message::Model,
}
#[derive(Debug, Clone)]
pub struct MessageUpdatedEvent {
pub server_id: Option<Uuid>,
pub channel_id: Uuid,
pub message: message::Model,
}
#[derive(Debug, Clone)]
pub struct MessageDeletedEvent {
pub server_id: Option<Uuid>,
pub channel_id: Uuid,
pub message: message::Model,
}
+3
View File
@@ -0,0 +1,3 @@
pub mod channel;
pub mod message;
pub mod server;
+16
View File
@@ -0,0 +1,16 @@
use crate::models::server;
#[derive(Debug, Clone)]
pub struct ServerCreatedEvent {
pub server: server::Model,
}
#[derive(Debug, Clone)]
pub struct ServerUpdatedEvent {
pub server: server::Model,
}
#[derive(Debug, Clone)]
pub struct ServerDeletedEvent {
pub server: server::Model,
}
+2
View File
@@ -0,0 +1,2 @@
pub mod events;
pub mod dto;
+3
View File
@@ -7,5 +7,8 @@ pub mod metrics;
pub mod middleware;
pub mod server;
pub mod validation;
pub mod permissions;
pub use permissions::{RequireServerPermission, RequireChannelPermission};
pub type OxRouter = Router<AppState>;
+225
View File
@@ -0,0 +1,225 @@
// Unused
use super::context::CurrentUser;
use super::error::HTTPError;
use crate::core::AppState;
use crate::permissions::{ChannelPermission, ServerPermission};
use axum::extract::FromRequestParts;
use axum::http::request::Parts;
use std::ops::Deref;
use uuid::Uuid;
/// An Axum extractor that ensures the currently authenticated user has the specified
/// server permission(s) on a target server.
///
/// The target `server_id` is automatically extracted from path parameters (supporting
/// path parameters named `server_id` or `id`).
///
/// # Superuser Bypass
/// If the user is a superuser (`is_superuser == true`), the permission check automatically passes.
///
/// # Usage Example
/// ```rust
/// use axum::extract::State;
/// use uuid::Uuid;
/// use crate::http::permissions::RequireServerPermission;
/// use crate::permissions::ServerPermission;
/// use crate::core::AppState;
///
/// pub async fn update_server_settings(
/// RequireServerPermission::<{ ServerPermission::MANAGE_SERVER.bits() }>(user): RequireServerPermission<{ ServerPermission::MANAGE_SERVER.bits() }>,
/// State(state): State<AppState>,
/// Path(server_id): Path<Uuid>,
/// ) -> Result<(), HTTPError> {
/// // User has MANAGE_SERVER or is a superuser
/// Ok(())
/// }
/// ```
#[derive(Clone, Debug)]
pub struct RequireServerPermission<const PERM: u64>(pub CurrentUser);
impl<const PERM: u64> Deref for RequireServerPermission<PERM> {
type Target = CurrentUser;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<S, const PERM: u64> FromRequestParts<S> for RequireServerPermission<PERM>
where
S: Send + Sync,
{
type Rejection = HTTPError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
// 1. Extract CurrentUser (which validates authentication and returns 401 if missing)
let current_user = CurrentUser::from_request_parts(parts, state).await?;
// 2. Superuser bypasses all checks
if current_user.is_superuser {
return Ok(RequireServerPermission(current_user));
}
// 3. Get AppState from extensions
let app_state = match parts.extensions.get::<AppState>() {
Some(s) => s.clone(),
None => {
return Err(HTTPError::InternalServerError(
"AppState missing in request extensions".to_string(),
));
}
};
// 4. Extract server_id from path parameters.
let server_id = match extract_path_param_uuid(parts, &["server_id", "id"]) {
Some(id) => id,
None => {
return Err(HTTPError::BadRequest(
"Missing or invalid server_id".to_string(),
));
}
};
// 5. Check user permission via server repository
let permission_result = app_state
.repositories
.server
.get_user_permission(server_id, current_user.id)
.await;
let permission_bits = match permission_result {
Ok(Some(p)) => p.permissions,
Ok(None) => 0,
Err(e) => return Err(HTTPError::InternalServerError(e.to_string())),
};
let required = ServerPermission::from_bits_truncate(PERM);
let granted = ServerPermission::from_bits_truncate(permission_bits as u64);
if granted.contains(required) {
Ok(RequireServerPermission(current_user))
} else {
Err(HTTPError::Forbidden)
}
}
}
/// An Axum extractor that ensures the currently authenticated user has the specified
/// channel permission(s) on a target channel.
///
/// The target `channel_id` (or `id`) is automatically extracted from path parameters.
///
/// # Superuser Bypass
/// If the user is a superuser (`is_superuser == true`), the permission check automatically passes.
///
/// # Usage Example
/// ```rust
/// use axum::extract::State;
/// use uuid::Uuid;
/// use crate::http::permissions::RequireChannelPermission;
/// use crate::permissions::ChannelPermission;
/// use crate::core::AppState;
///
/// pub async fn read_channel_messages(
/// RequireChannelPermission::<{ ChannelPermission::READ_CHANNEL.bits() }>(user): RequireChannelPermission<{ ChannelPermission::READ_CHANNEL.bits() }>,
/// State(state): State<AppState>,
/// Path(channel_id): Path<Uuid>,
/// ) -> Result<(), HTTPError> {
/// // User has READ_CHANNEL or is a superuser
/// Ok(())
/// }
/// ```
#[derive(Clone, Debug)]
pub struct RequireChannelPermission<const PERM: u64>(pub CurrentUser);
impl<const PERM: u64> Deref for RequireChannelPermission<PERM> {
type Target = CurrentUser;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<S, const PERM: u64> FromRequestParts<S> for RequireChannelPermission<PERM>
where
S: Send + Sync,
{
type Rejection = HTTPError;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let current_user = CurrentUser::from_request_parts(parts, state).await?;
if current_user.is_superuser {
return Ok(RequireChannelPermission(current_user));
}
let app_state = match parts.extensions.get::<AppState>() {
Some(s) => s.clone(),
None => {
return Err(HTTPError::InternalServerError(
"AppState missing in request extensions".to_string(),
));
}
};
let channel_id = match extract_path_param_uuid(parts, &["channel_id", "id"]) {
Some(id) => id,
None => {
return Err(HTTPError::BadRequest(
"Missing or invalid channel_id".to_string(),
));
}
};
let permission_result = app_state
.repositories
.channel
.get_user_permission(channel_id, current_user.id)
.await;
let permission_bits = match permission_result {
Ok(Some(p)) => p.permissions,
Ok(None) => 0,
Err(e) => return Err(HTTPError::InternalServerError(e.to_string())),
};
let required = ChannelPermission::from_bits_truncate(PERM);
let granted = ChannelPermission::from_bits_truncate(permission_bits as u64);
if granted.contains(required) {
Ok(RequireChannelPermission(current_user))
} else {
Err(HTTPError::Forbidden)
}
}
}
/// Helper function to extract a Uuid path parameter matching any of the given key names
/// from Axum request extensions.
fn extract_path_param_uuid(parts: &Parts, keys: &[&str]) -> Option<Uuid> {
if let Some(map) = parts
.extensions
.get::<std::collections::HashMap<String, String>>()
{
for key in keys {
if let Some(val) = map.get(*key) {
if let Ok(uuid) = Uuid::parse_str(val) {
return Some(uuid);
}
}
}
}
if let Some(params) = parts.extensions.get::<Vec<(String, String)>>() {
for (k, v) in params {
if keys.contains(&k.as_str()) {
if let Ok(uuid) = Uuid::parse_str(v) {
return Some(uuid);
}
}
}
}
None
}
+5
View File
@@ -11,3 +11,8 @@ pub mod udp;
pub mod auth;
pub mod metrics;
pub mod domain;
pub mod services;
pub mod utils;
+1 -3
View File
@@ -1,7 +1,5 @@
use migration::{Migrator, MigratorTrait};
use oxspeak_server_lib::config::AppConfig;
use oxspeak_server_lib::core::App;
use oxspeak_server_lib::database::Database;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -10,7 +8,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.with_env_filter(
std::env::var("RUST_LOG")
// .unwrap_or_else(|_| "info,sqlx=debug,sea_orm=debug,sea_orm_migration=info".into()),
.unwrap_or_else(|_| "info,sqlx=info,sea_orm=info,sea_orm_migration=info".into()),
.unwrap_or_else(|_| "info,sqlx=debug,sea_orm=debug,sea_orm_migration=debug".into()),
)
.with_target(true)
.with_level(true)
+5 -14
View File
@@ -4,6 +4,7 @@ use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::Set;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "attachment")]
pub struct Model {
@@ -14,24 +15,14 @@ pub struct Model {
pub file_size: i32,
pub mime_type: String,
pub created_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::message::Entity",
from = "Column::MessageId",
to = "super::message::Column::Id",
belongs_to,
from = "message_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Message,
}
impl Related<super::message::Entity> for Entity {
fn to() -> RelationDef {
Relation::Message.def()
}
pub message: HasOne<super::message::Entity>,
}
#[async_trait]
+8 -24
View File
@@ -1,9 +1,10 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
use sea_orm::Set;
use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::Set;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "category")]
pub struct Model {
@@ -11,35 +12,18 @@ pub struct Model {
pub id: Uuid,
pub server_id: Uuid,
pub name: String,
pub position: i32,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::channel::Entity")]
Channel,
#[sea_orm(has_many)]
pub channels: HasMany<super::channel::Entity>,
#[sea_orm(
belongs_to = "super::server::Entity",
from = "Column::ServerId",
to = "super::server::Column::Id",
belongs_to,
from = "server_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Server,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::server::Entity> for Entity {
fn to() -> RelationDef {
Relation::Server.def()
}
pub server: HasOne<super::server::Entity>,
}
#[async_trait]
+14 -44
View File
@@ -1,8 +1,8 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
use sea_orm::Set;
use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::Set;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@@ -20,6 +20,7 @@ pub enum ChannelType {
DM,
}
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "channel")]
pub struct Model {
@@ -27,61 +28,30 @@ pub struct Model {
pub id: Uuid,
pub server_id: Option<Uuid>,
pub category_id: Option<Uuid>,
pub position: i32,
pub channel_type: ChannelType,
pub name: Option<String>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub default_channel_permissions: Option<i64>,
pub default_voice_permissions: Option<i64>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::category::Entity",
from = "Column::CategoryId",
to = "super::category::Column::Id",
belongs_to,
from = "category_id",
to = "id",
on_update = "NoAction",
on_delete = "SetNull"
)]
Category,
#[sea_orm(has_many = "super::channel_user::Entity")]
ChannelUser,
#[sea_orm(has_many = "super::message::Entity")]
Message,
pub category: HasOne<super::category::Entity>,
#[sea_orm(has_many)]
pub channel_users: HasMany<super::channel_user::Entity>,
#[sea_orm(has_many)]
pub messages: HasMany<super::message::Entity>,
#[sea_orm(
belongs_to = "super::server::Entity",
from = "Column::ServerId",
to = "super::server::Column::Id",
belongs_to,
from = "server_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Server,
}
impl Related<super::category::Entity> for Entity {
fn to() -> RelationDef {
Relation::Category.def()
}
}
impl Related<super::channel_user::Entity> for Entity {
fn to() -> RelationDef {
Relation::ChannelUser.def()
}
}
impl Related<super::message::Entity> for Entity {
fn to() -> RelationDef {
Relation::Message.def()
}
}
impl Related<super::server::Entity> for Entity {
fn to() -> RelationDef {
Relation::Server.def()
}
pub server: HasOne<super::server::Entity>,
}
#[async_trait]
+50
View File
@@ -0,0 +1,50 @@
//! SeaORM Entity pour les permissions d'un rôle dans un canal.
use crate::permissions::ChannelPermission;
use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::{NotSet, Set};
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "channel_role_permission")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub channel_id: Uuid,
pub role_id: Uuid,
/// Bitmask des permissions accordées au rôle dans ce canal.
pub permissions: i64,
#[sea_orm(
belongs_to,
from = "channel_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub channel: HasOne<super::channel::Entity>,
#[sea_orm(
belongs_to,
from = "role_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub role: HasOne<super::role::Entity>,
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
id: Set(Uuid::new_v4()),
channel_id: NotSet,
role_id: NotSet,
permissions: Set(ChannelPermission::empty().bits() as i64),
}
}
}
+9 -25
View File
@@ -4,6 +4,7 @@ use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::Set;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "channel_user")]
pub struct Model {
@@ -12,40 +13,23 @@ pub struct Model {
pub channel_id: Uuid,
pub user_id: Uuid,
pub role: String,
pub permissions: u64,
pub joined_at: DateTimeUtc,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id",
belongs_to,
from = "channel_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Channel,
pub channel: HasOne<super::channel::Entity>,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
belongs_to,
from = "user_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
User,
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
pub user: HasOne<super::user::Entity>,
}
#[async_trait]
+50
View File
@@ -0,0 +1,50 @@
//! SeaORM Entity pour les permissions directes d'un utilisateur dans un canal.
use crate::permissions::ChannelPermission;
use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::{NotSet, Set};
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "channel_user_permission")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub channel_id: Uuid,
pub user_id: Uuid,
/// Bitmask des permissions accordées directement à l'utilisateur dans ce canal.
pub permissions: i64,
#[sea_orm(
belongs_to,
from = "channel_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub channel: HasOne<super::channel::Entity>,
#[sea_orm(
belongs_to,
from = "user_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub user: HasOne<super::user::Entity>,
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
id: Set(Uuid::new_v4()),
channel_id: NotSet,
user_id: NotSet,
permissions: Set(ChannelPermission::empty().bits() as i64),
}
}
}
+33
View File
@@ -0,0 +1,33 @@
use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "channel_user_read_state")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub channel_id: Uuid,
pub user_id: Uuid,
pub last_read_message_id: Option<Uuid>,
pub updated_at: DateTimeUtc,
#[sea_orm(
belongs_to,
from = "channel_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub channel: HasOne<super::channel::Entity>,
#[sea_orm(
belongs_to,
from = "user_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub user: HasOne<super::user::Entity>,
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {}
+65
View File
@@ -0,0 +1,65 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
use async_trait::async_trait;
/// Permet de cache les permissions des utilisateurs pour éviter de recalculer les permissions à chaque fois qu'une requête est faite.
use sea_orm::entity::prelude::*;
use sea_orm::{NotSet, Set};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, ToSchema,
)]
#[sea_orm(rs_type = "i32", db_type = "Integer")]
pub enum PermissionScopeType {
#[sea_orm(num_value = 0)]
Server,
#[sea_orm(num_value = 1)]
Category,
#[sea_orm(num_value = 2)]
Channel,
}
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "computed_permission")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
/// L'utilisateur à qui appartiennent ces permissions
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: Uuid,
pub server_id: Uuid,
pub scope_type: PermissionScopeType,
/// L'ID de la ressource (soit un Uuid d'une Category, soit l'Uuid d'un Channel, soit l'Uuid du Server)
#[sea_orm(primary_key, auto_increment = false)]
pub resource_id: Uuid,
/// Cache des permissions (stocké en i64 pour SQL, utilisé en u64)
pub permissions: i64,
#[sea_orm(
belongs_to,
from = "user_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub user: HasOne<super::user::Entity>,
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
id: Set(Uuid::new_v4()),
user_id: NotSet,
server_id: NotSet,
scope_type: NotSet,
resource_id: NotSet,
permissions: Set(0),
}
}
}
-57
View File
@@ -1,57 +0,0 @@
//! `SeaORM` Entity.
use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::Set;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "group")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub server_id: Uuid,
pub name: String,
pub is_default: bool,
pub created_at: DateTimeUtc,
/// Permissions serveur par défaut (stockées en i64, lues en u64)
pub server_permissions: i64,
pub channel_permissions: i64,
pub voice_permissions: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::server::Entity",
from = "Column::ServerId",
to = "super::server::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Server,
#[sea_orm(has_many = "super::group_member::Entity")]
GroupMember,
}
impl Related<super::server::Entity> for Entity {
fn to() -> RelationDef {
Relation::Server.def()
}
}
impl Related<super::group_member::Entity> for Entity {
fn to() -> RelationDef {
Relation::GroupMember.def()
}
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
id: Set(Uuid::new_v4()),
..ActiveModelTrait::default()
}
}
}
-46
View File
@@ -1,46 +0,0 @@
//! `SeaORM` Entity.
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "group_member")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub group_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: Uuid,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::group::Entity",
from = "Column::GroupId",
to = "super::group::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Group,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
User,
}
impl Related<super::group::Entity> for Entity {
fn to() -> RelationDef {
Relation::Group.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
+19 -36
View File
@@ -4,6 +4,7 @@ use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::Set;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "message")]
pub struct Model {
@@ -16,54 +17,36 @@ pub struct Model {
pub created_at: DateTimeUtc,
pub updated_at: Option<DateTimeUtc>,
pub reply_to_id: Option<Uuid>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::attachment::Entity")]
Attachment,
#[sea_orm(has_many)]
pub attachments: HasMany<super::attachment::Entity>,
#[sea_orm(
belongs_to = "super::channel::Entity",
from = "Column::ChannelId",
to = "super::channel::Column::Id",
belongs_to,
from = "channel_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Channel,
pub channel: HasOne<super::channel::Entity>,
#[sea_orm(
belongs_to = "Entity",
from = "Column::ReplyToId",
to = "Column::Id",
self_ref,
relation_enum = "ReplyTo",
relation_reverse = "Replies",
from = "reply_to_id",
to = "id",
on_update = "NoAction",
on_delete = "SetNull"
)]
SelfRef,
pub reply_to: HasOne<Entity>,
#[sea_orm(self_ref, relation_enum = "Replies", relation_reverse = "ReplyTo")]
pub replies: HasMany<Entity>,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
belongs_to,
from = "user_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
User,
}
impl Related<super::attachment::Entity> for Entity {
fn to() -> RelationDef {
Relation::Attachment.def()
}
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
pub user: HasOne<super::user::Entity>,
}
#[async_trait]
+9 -2
View File
@@ -5,10 +5,17 @@ pub mod prelude;
pub mod attachment;
pub mod category;
pub mod channel;
pub mod channel_role_permission;
pub mod channel_user;
pub mod group;
pub mod group_member;
pub mod channel_user_read_state;
pub mod channel_user_permission;
pub mod computed_permission;
pub mod message;
pub mod role;
pub mod role_user;
pub mod server;
pub mod server_item_order;
pub mod server_role_permission;
pub mod server_user;
pub mod server_user_permission;
pub mod user;
+7 -2
View File
@@ -4,9 +4,14 @@ pub use super::attachment::Entity as Attachment;
pub use super::category::Entity as Category;
pub use super::channel::Entity as Channel;
pub use super::channel_user::Entity as ChannelUser;
pub use super::group::Entity as Group;
pub use super::group_member::Entity as GroupMember;
pub use super::channel_user_read_state::Entity as ChannelUserReadState;
pub use super::computed_permission::Entity as ComputedPermission;
pub use super::message::Entity as Message;
pub use super::role::Entity as Group;
pub use super::role_user::Entity as GroupMember;
pub use super::server::Entity as Server;
pub use super::server_item_order::Entity as ServerItemOrder;
pub use super::server_role_permission::Entity as ServerRolePermission;
pub use super::server_user::Entity as ServerUser;
pub use super::server_user_permission::Entity as ServerUserPermission;
pub use super::user::Entity as User;
+37
View File
@@ -0,0 +1,37 @@
//! `SeaORM` Entity.
use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::Set;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "role")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub server_id: Uuid,
pub name: String,
pub is_default: bool,
pub created_at: DateTimeUtc,
#[sea_orm(
belongs_to,
from = "server_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub server: HasOne<super::server::Entity>,
#[sea_orm(has_many)]
pub role_users: HasMany<super::role_user::Entity>,
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
id: Set(Uuid::new_v4()),
..ActiveModelTrait::default()
}
}
}
+31
View File
@@ -0,0 +1,31 @@
//! `SeaORM` Entity.
use sea_orm::entity::prelude::*;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "role_user")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub role_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: Uuid,
#[sea_orm(
belongs_to,
from = "role_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub role: HasOne<super::role::Entity>,
#[sea_orm(
belongs_to,
from = "user_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub user: HasOne<super::user::Entity>,
}
impl ActiveModelBehavior for ActiveModel {}
+8 -48
View File
@@ -1,10 +1,10 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
use crate::permissions::{ChannelPermission, PermissionSet, ServerPermission, VoicePermission};
use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::Set;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "server")]
pub struct Model {
@@ -15,62 +15,22 @@ pub struct Model {
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub is_default: bool,
/// Permissions serveur par défaut (stockées en i64, lues en u64)
pub default_server_permissions: i64,
pub default_channel_permissions: i64,
pub default_voice_permissions: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::category::Entity")]
Category,
#[sea_orm(has_many = "super::channel::Entity")]
Channel,
#[sea_orm(has_many = "super::server_user::Entity")]
ServerUser,
}
impl Related<super::category::Entity> for Entity {
fn to() -> RelationDef {
Relation::Category.def()
}
}
impl Related<super::channel::Entity> for Entity {
fn to() -> RelationDef {
Relation::Channel.def()
}
}
impl Related<super::server_user::Entity> for Entity {
fn to() -> RelationDef {
Relation::ServerUser.def()
}
pub owner_id: Option<Uuid>,
#[sea_orm(has_many)]
pub categories: HasMany<super::category::Entity>,
#[sea_orm(has_many)]
pub channels: HasMany<super::channel::Entity>,
#[sea_orm(has_many)]
pub server_users: HasMany<super::server_user::Entity>,
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
let default_perm = PermissionSet::DEFAULT;
Self {
id: Set(Uuid::new_v4()),
is_default: Set(false),
default_server_permissions: Set(default_perm.server.bits() as i64),
default_channel_permissions: Set(default_perm.channel.bits() as i64),
default_voice_permissions: Set(default_perm.voice.bits() as i64),
..ActiveModelTrait::default()
}
}
}
impl Model {
pub fn default_permissions(&self) -> PermissionSet {
PermissionSet {
server: ServerPermission::from_bits_truncate(self.default_server_permissions as u64),
channel: ChannelPermission::from_bits_truncate(self.default_channel_permissions as u64),
voice: VoicePermission::from_bits_truncate(self.default_voice_permissions as u64),
}
}
}
+74
View File
@@ -0,0 +1,74 @@
use sea_orm::Set;
use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, EnumIter, DeriveActiveEnum, Serialize, Deserialize, ToSchema,
)]
#[sea_orm(rs_type = "i32", db_type = "Integer")]
#[serde(rename_all = "snake_case")]
pub enum OrderedResourceType {
#[sea_orm(num_value = 0)]
Channel,
#[sea_orm(num_value = 1)]
Category,
}
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "server_item_order")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub server_id: Uuid,
/// UUID du channel ou de la catégorie ordonné(e).
pub resource_id: Uuid,
/// Type de la ressource référencée par resource_id.
pub resource_type: OrderedResourceType,
/// NULL pour la liste racine du serveur.
/// Renseigné uniquement pour un channel placé dans une catégorie.
pub parent_category_id: Option<Uuid>,
/// Clé de tri relative à (server_id, parent_category_id).
pub order_key: i64,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(
belongs_to,
from = "server_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub server: HasOne<super::server::Entity>,
#[sea_orm(
belongs_to,
from = "parent_category_id",
to = "id",
on_update = "Cascade",
on_delete = "Cascade"
)]
pub parent_category: HasOne<super::category::Entity>,
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
id: Set(Uuid::new_v4()),
..ActiveModelTrait::default()
}
}
}
+50
View File
@@ -0,0 +1,50 @@
use crate::permissions::ServerPermission;
use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::{NotSet, Set};
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "server_role_permission")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub server_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub role_id: Uuid,
/// Bitmask des permissions accordées directement à l'utilisateur.
pub permissions: i64,
#[sea_orm(
belongs_to,
from = "server_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub server: HasOne<super::server::Entity>,
#[sea_orm(
belongs_to,
from = "role_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub role: HasOne<super::role::Entity>,
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
id: Set(Uuid::new_v4()),
server_id: NotSet,
role_id: NotSet,
permissions: Set(ServerPermission::empty().bits() as i64),
}
}
}
+9 -31
View File
@@ -4,6 +4,7 @@ use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::Set;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "server_user")]
pub struct Model {
@@ -14,45 +15,22 @@ pub struct Model {
pub username: Option<String>,
pub joined_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub is_admin: bool,
pub is_owner: bool,
/// Permissions serveur par défaut (stockées en i64, lues en u64)
pub server_permissions: i64,
pub channel_permissions: i64,
pub voice_permissions: i64,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::server::Entity",
from = "Column::ServerId",
to = "super::server::Column::Id",
belongs_to,
from = "server_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
Server,
pub server: HasOne<super::server::Entity>,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
belongs_to,
from = "user_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
User,
}
impl Related<super::server::Entity> for Entity {
fn to() -> RelationDef {
Relation::Server.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
pub user: HasOne<super::user::Entity>,
}
#[async_trait]
+49
View File
@@ -0,0 +1,49 @@
use crate::permissions::ServerPermission;
use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::{NotSet, Set};
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "server_user_permission")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub server_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: Uuid,
/// Bitmask des permissions accordées directement à l'utilisateur.
pub permissions: i64,
#[sea_orm(
belongs_to,
from = "server_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub server: HasOne<super::server::Entity>,
#[sea_orm(
belongs_to,
from = "user_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub user: HasOne<super::user::Entity>,
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
Self {
id: Set(Uuid::new_v4()),
server_id: NotSet,
user_id: NotSet,
permissions: Set(ServerPermission::empty().bits() as i64),
}
}
}
+8 -28
View File
@@ -4,6 +4,8 @@ use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
use sea_orm::Set;
#[sea_orm::model]
#[sea_orm(model_ex_attrs(derive(Debug)))]
#[derive(Clone, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "user")]
pub struct Model {
@@ -16,6 +18,12 @@ pub struct Model {
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub is_superuser: bool,
#[sea_orm(has_many)]
pub channel_users: HasMany<super::channel_user::Entity>,
#[sea_orm(has_many)]
pub messages: HasMany<super::message::Entity>,
#[sea_orm(has_many)]
pub server_users: HasMany<super::server_user::Entity>,
}
impl std::fmt::Debug for Model {
@@ -32,34 +40,6 @@ impl std::fmt::Debug for Model {
}
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::channel_user::Entity")]
ChannelUser,
#[sea_orm(has_many = "super::message::Entity")]
Message,
#[sea_orm(has_many = "super::server_user::Entity")]
ServerUser,
}
impl Related<super::channel_user::Entity> for Entity {
fn to() -> RelationDef {
Relation::ChannelUser.def()
}
}
impl Related<super::message::Entity> for Entity {
fn to() -> RelationDef {
Relation::Message.def()
}
}
impl Related<super::server_user::Entity> for Entity {
fn to() -> RelationDef {
Relation::ServerUser.def()
}
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {
fn new() -> Self {
+13 -47
View File
@@ -37,6 +37,7 @@ bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ChannelPermission: u64 {
// Permission communes / texte
/// Voir le canal et son contenu.
const READ_CHANNEL = 1 << 0;
@@ -66,37 +67,16 @@ bitflags! {
/// Épingler ou désépingler des messages.
const MANAGE_MESSAGES = 1 << 10;
}
}
bitflags! {
/// Permissions applicables aux canaux vocaux.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct VoicePermission: u64 {
/// Rejoindre un canal vocal.
const JOIN_CHANNEL = 1 << 0;
/// Parler dans un canal vocal.
const SPEAK = 1 << 1;
/// Utiliser sa caméra ou partager son écran.
const STREAM = 1 << 2;
/// Couper son propre microphone.
const MUTE_SELF = 1 << 3;
/// Couper le microphone d'un autre membre.
const MUTE_OTHERS = 1 << 4;
/// Déplacer un membre vers un autre canal vocal.
const MOVE_OTHERS = 1 << 6;
/// Expulser un membre d'un canal vocal.
const DISCONNECT_OTHERS = 1 << 7;
/// Modifier les paramètres du canal vocal.
const MANAGE_VOICE_CHANNEL = 1 << 8;
// Permissions vocales (30-45 réservées)
const JOIN_VOICE = 1 << 30;
const SPEAK = 1 << 31;
const STREAM = 1 << 32;
const MUTE_SELF = 1 << 33;
const MUTE_OTHERS = 1 << 34;
const MOVE_OTHERS = 1 << 35;
const DISCONNECT_OTHERS = 1 << 36;
const MANAGE_VOICE_CHANNEL = 1 << 37;
}
}
@@ -105,20 +85,11 @@ bitflags! {
pub struct PermissionSet {
pub server: ServerPermission,
pub channel: ChannelPermission,
pub voice: VoicePermission,
}
impl PermissionSet {
pub const fn new(
server: ServerPermission,
channel: ChannelPermission,
voice: VoicePermission,
) -> Self {
Self {
server,
channel,
voice,
}
pub const fn new(server: ServerPermission, channel: ChannelPermission) -> Self {
Self { server, channel }
}
/// Permissions par défaut accordées à un membre standard.
@@ -131,15 +102,10 @@ impl PermissionSet {
| ChannelPermission::DELETE_OWN_MESSAGE.bits()
| ChannelPermission::ADD_REACTIONS.bits(),
),
VoicePermission::from_bits_retain(
VoicePermission::JOIN_CHANNEL.bits() | VoicePermission::SPEAK.bits(),
),
);
/// Vérifie si l'ensemble contient la permission demandée.
pub const fn contains(&self, required: Self) -> bool {
self.server.contains(required.server)
&& self.channel.contains(required.channel)
&& self.voice.contains(required.voice)
self.server.contains(required.server) && self.channel.contains(required.channel)
}
}
+16 -8
View File
@@ -1,6 +1,6 @@
use crate::models::category;
use crate::repositories::{AnyResult, RepositoryContext};
use sea_orm::{ActiveModelTrait, EntityTrait};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter};
use std::sync::Arc;
use uuid::Uuid;
@@ -16,23 +16,32 @@ impl CategoryRepository {
.await?)
}
pub async fn get_by_server(&self, server_id: Uuid) -> AnyResult<Vec<category::Model>> {
Ok(category::Entity::find()
.filter(category::Column::ServerId.eq(server_id))
.all(&self.context.db)
.await?)
}
pub async fn get_all(&self) -> AnyResult<Vec<category::Model>> {
Ok(category::Entity::find().all(&self.context.db).await?)
}
pub async fn filter(&self, server_id: Option<Uuid>) -> AnyResult<Vec<category::Model>> {
let mut query = category::Entity::find();
if let Some(s_id) = server_id {
query = query.filter(category::Column::ServerId.eq(s_id));
}
Ok(query.all(&self.context.db).await?)
}
pub async fn update(&self, active: category::ActiveModel) -> AnyResult<category::Model> {
let category = active.update(&self.context.db).await?;
self.context
.events
.emit("category_updated", category.clone());
Ok(category)
}
pub async fn create(&self, active: category::ActiveModel) -> AnyResult<category::Model> {
let category = active.insert(&self.context.db).await?;
self.context
.events
.emit("category_created", category.clone());
Ok(category)
}
@@ -40,7 +49,6 @@ impl CategoryRepository {
let res = category::Entity::delete_by_id(id)
.exec(&self.context.db)
.await?;
self.context.events.emit("category_deleted", id);
Ok(res.rows_affected > 0)
}
}
+135 -7
View File
@@ -1,7 +1,10 @@
use crate::models::channel;
use crate::models::{channel, channel_role_permission, channel_user_permission};
use crate::repositories::types::ChannelFilter;
use crate::repositories::{AnyResult, RepositoryContext};
use sea_orm::{ActiveModelTrait, EntityTrait};
use sea_orm::sea_query::OnConflict;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use std::sync::Arc;
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct ChannelRepository {
@@ -9,7 +12,7 @@ pub struct ChannelRepository {
}
impl ChannelRepository {
pub async fn get_by_id(&self, id: uuid::Uuid) -> AnyResult<Option<channel::Model>> {
pub async fn get_by_id(&self, id: Uuid) -> AnyResult<Option<channel::Model>> {
Ok(channel::Entity::find_by_id(id)
.one(&self.context.db)
.await?)
@@ -19,23 +22,148 @@ impl ChannelRepository {
Ok(channel::Entity::find().all(&self.context.db).await?)
}
pub async fn filter(&self, filter: ChannelFilter) -> AnyResult<Vec<channel::Model>> {
let mut query = channel::Entity::find();
if let Some(s_id) = filter.server_id {
query = query.filter(channel::Column::ServerId.eq(s_id));
}
Ok(query.all(&self.context.db).await?)
}
pub async fn update(&self, active: channel::ActiveModel) -> AnyResult<channel::Model> {
let channel = active.update(&self.context.db).await?;
self.context.events.emit("channel_updated", channel.clone());
Ok(channel)
}
pub async fn create(&self, active: channel::ActiveModel) -> AnyResult<channel::Model> {
let channel = active.insert(&self.context.db).await?;
self.context.events.emit("channel_created", channel.clone());
Ok(channel)
}
pub async fn delete(&self, id: uuid::Uuid) -> AnyResult<bool> {
pub async fn delete(&self, id: Uuid) -> AnyResult<bool> {
let res = channel::Entity::delete_by_id(id)
.exec(&self.context.db)
.await?;
self.context.events.emit("channel_deleted", id);
Ok(res.rows_affected > 0)
}
pub async fn get_user_permission(
&self,
channel_id: Uuid,
user_id: Uuid,
) -> AnyResult<Option<channel_user_permission::Model>> {
Ok(channel_user_permission::Entity::find()
.filter(channel_user_permission::Column::ChannelId.eq(channel_id))
.filter(channel_user_permission::Column::UserId.eq(user_id))
.one(&self.context.db)
.await?)
}
pub async fn list_user_permissions(
&self,
channel_id: Uuid,
) -> AnyResult<Vec<channel_user_permission::Model>> {
Ok(channel_user_permission::Entity::find()
.filter(channel_user_permission::Column::ChannelId.eq(channel_id))
.all(&self.context.db)
.await?)
}
pub async fn set_user_permission(
&self,
channel_id: Uuid,
user_id: Uuid,
permissions: u64,
) -> AnyResult<()> {
let permission = channel_user_permission::ActiveModel {
channel_id: Set(channel_id),
user_id: Set(user_id),
permissions: Set(permissions as i64),
..Default::default()
};
channel_user_permission::Entity::insert(permission)
.on_conflict(
OnConflict::columns([
channel_user_permission::Column::ChannelId,
channel_user_permission::Column::UserId,
])
.update_columns([channel_user_permission::Column::Permissions])
.to_owned(),
)
.exec(&self.context.db)
.await?;
Ok(())
}
pub async fn remove_user_permission(&self, channel_id: Uuid, user_id: Uuid) -> AnyResult<()> {
channel_user_permission::Entity::delete_many()
.filter(channel_user_permission::Column::ChannelId.eq(channel_id))
.filter(channel_user_permission::Column::UserId.eq(user_id))
.exec(&self.context.db)
.await?;
Ok(())
}
pub async fn get_role_permission(
&self,
channel_id: Uuid,
role_id: Uuid,
) -> AnyResult<Option<channel_role_permission::Model>> {
Ok(channel_role_permission::Entity::find()
.filter(channel_role_permission::Column::ChannelId.eq(channel_id))
.filter(channel_role_permission::Column::RoleId.eq(role_id))
.one(&self.context.db)
.await?)
}
pub async fn list_role_permissions(
&self,
channel_id: Uuid,
) -> AnyResult<Vec<channel_role_permission::Model>> {
Ok(channel_role_permission::Entity::find()
.filter(channel_role_permission::Column::ChannelId.eq(channel_id))
.all(&self.context.db)
.await?)
}
pub async fn set_role_permission(
&self,
channel_id: Uuid,
role_id: Uuid,
permissions: u64,
) -> AnyResult<()> {
let permission = channel_role_permission::ActiveModel {
channel_id: Set(channel_id),
role_id: Set(role_id),
permissions: Set(permissions as i64),
..Default::default()
};
channel_role_permission::Entity::insert(permission)
.on_conflict(
OnConflict::columns([
channel_role_permission::Column::ChannelId,
channel_role_permission::Column::RoleId,
])
.update_columns([channel_role_permission::Column::Permissions])
.to_owned(),
)
.exec(&self.context.db)
.await?;
Ok(())
}
pub async fn remove_role_permission(&self, channel_id: Uuid, role_id: Uuid) -> AnyResult<()> {
channel_role_permission::Entity::delete_many()
.filter(channel_role_permission::Column::ChannelId.eq(channel_id))
.filter(channel_role_permission::Column::RoleId.eq(role_id))
.exec(&self.context.db)
.await?;
Ok(())
}
}
+265
View File
@@ -0,0 +1,265 @@
use crate::models::{
channel, channel_role_permission, channel_user_permission, computed_permission, role_user,
server_role_permission, server_user, server_user_permission,
};
use crate::permissions::{ChannelPermission, ServerPermission};
use crate::repositories::{AnyResult, RepositoryContext};
use sea_orm::{
ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, Set, TransactionTrait,
};
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use uuid::Uuid;
use crate::models::computed_permission::PermissionScopeType;
use crate::repositories::types::PermissionResource;
use crate::utils::ScopedLockManager;
// Instance globale du manager de verrous scopés par Server ID
static PERM_LOCK_MANAGER: OnceLock<ScopedLockManager<Uuid>> = OnceLock::new();
fn lock_manager() -> &'static ScopedLockManager<Uuid> {
PERM_LOCK_MANAGER.get_or_init(ScopedLockManager::new)
}
#[derive(Clone, Debug)]
pub struct ComputedPermissionRepository {
pub context: Arc<RepositoryContext>,
}
impl ComputedPermissionRepository {
/// Récupère toutes les permissions calculées.
pub async fn get_all(&self) -> AnyResult<Vec<computed_permission::Model>> {
Ok(computed_permission::Entity::find()
.all(&self.context.db)
.await?)
}
pub async fn get_for_resource(
&self,
user_id: Uuid,
resource: PermissionResource,
) -> AnyResult<Option<computed_permission::Model>> {
Ok(computed_permission::Entity::find()
.filter(computed_permission::Column::UserId.eq(user_id))
.filter(computed_permission::Column::ScopeType.eq(resource.scope_type()))
.filter(computed_permission::Column::ResourceId.eq(resource.resource_id()))
.one(&self.context.db)
.await?)
}
/// Vérifie si l'utilisateur possède au moins une entrée de permission sur une ressource.
pub async fn had_perm_on(&self, user_id: Uuid, resource_id: Uuid) -> AnyResult<bool> {
Ok(computed_permission::Entity::find()
.filter(computed_permission::Column::UserId.eq(user_id))
.filter(computed_permission::Column::ResourceId.eq(resource_id))
.count(&self.context.db)
.await?
> 0)
}
// -------------------------------------------------------------------------
// Synchronisations de permissions scopées
// -------------------------------------------------------------------------
/// Scope SERVEUR : Recalcule le cache de permissions pour TOUS les utilisateurs du serveur.
/// À n'utiliser que pour les opérations lourdes ou structurelles.
pub async fn full_sync_server(&self, server_id: Uuid) -> AnyResult<()> {
let user_ids = server_user::Entity::find()
.filter(server_user::Column::ServerId.eq(server_id))
.select_only()
.column(server_user::Column::UserId)
.into_tuple::<Uuid>()
.all(&self.context.db)
.await?;
for user_id in user_ids {
self.full_sync_user(user_id, server_id).await?;
}
Ok(())
}
/// Scope RÔLE : Recalcule le cache uniquement pour les membres d'un rôle spécifique.
pub async fn sync_role_members(&self, role_id: Uuid, server_id: Uuid) -> AnyResult<()> {
let user_ids = role_user::Entity::find()
.filter(role_user::Column::RoleId.eq(role_id))
.select_only()
.column(role_user::Column::UserId)
.into_tuple::<Uuid>()
.all(&self.context.db)
.await?;
for user_id in user_ids {
self.full_sync_user(user_id, server_id).await?;
}
Ok(())
}
/// Scope UTILISATEUR : Recalcule le cache de permissions d'un seul utilisateur sur un serveur.
///
/// Les permissions effectives sont composées de :
/// - permissions serveur accordées aux rôles de l'utilisateur ;
/// - permissions serveur accordées directement à l'utilisateur ;
/// - permissions de canal accordées aux rôles de l'utilisateur ;
/// - permissions directes de l'utilisateur dans les canaux.
pub async fn full_sync_user(&self, user_id: Uuid, server_id: Uuid) -> AnyResult<()> {
let _server_guard = lock_manager().lock_scope(server_id).await;
// ---------------------------------------------------------------------
// 1. Rôles de l'utilisateur
// ---------------------------------------------------------------------
let role_ids = role_user::Entity::find()
.filter(role_user::Column::UserId.eq(user_id))
.select_only()
.column(role_user::Column::RoleId)
.into_tuple::<Uuid>()
.all(&self.context.db)
.await?;
// ---------------------------------------------------------------------
// 2. Permissions serveur des rôles
// ---------------------------------------------------------------------
let mut server_permissions = ServerPermission::empty();
if !role_ids.is_empty() {
let role_permissions = server_role_permission::Entity::find()
.filter(server_role_permission::Column::ServerId.eq(server_id))
.filter(server_role_permission::Column::RoleId.is_in(role_ids.clone()))
.all(&self.context.db)
.await?;
for permission in role_permissions {
server_permissions |=
ServerPermission::from_bits_retain(permission.permissions as u64);
}
}
// ---------------------------------------------------------------------
// 3. Permissions serveur directes de l'utilisateur
// ---------------------------------------------------------------------
if let Some(permission) = server_user_permission::Entity::find()
.filter(server_user_permission::Column::ServerId.eq(server_id))
.filter(server_user_permission::Column::UserId.eq(user_id))
.one(&self.context.db)
.await?
{
server_permissions |= ServerPermission::from_bits_retain(permission.permissions as u64);
}
// ---------------------------------------------------------------------
// 4. Canaux du serveur
// ---------------------------------------------------------------------
let channels = channel::Entity::find()
.filter(channel::Column::ServerId.eq(server_id))
.all(&self.context.db)
.await?;
let channel_ids: Vec<Uuid> = channels.iter().map(|c| c.id).collect();
// ---------------------------------------------------------------------
// 5. Permissions de rôles pour tous les canaux
// ---------------------------------------------------------------------
let role_channel_permissions = if role_ids.is_empty() || channel_ids.is_empty() {
Vec::new()
} else {
channel_role_permission::Entity::find()
.filter(channel_role_permission::Column::ChannelId.is_in(channel_ids.clone()))
.filter(channel_role_permission::Column::RoleId.is_in(role_ids))
.all(&self.context.db)
.await?
};
let mut permissions_by_channel: HashMap<Uuid, ChannelPermission> = HashMap::new();
for permission in role_channel_permissions {
permissions_by_channel
.entry(permission.channel_id)
.or_default()
.insert(ChannelPermission::from_bits_retain(
permission.permissions as u64,
));
}
// ---------------------------------------------------------------------
// 6. Permissions directes de l'utilisateur pour tous les canaux
// ---------------------------------------------------------------------
let user_channel_permissions = if channel_ids.is_empty() {
Vec::new()
} else {
channel_user_permission::Entity::find()
.filter(channel_user_permission::Column::UserId.eq(user_id))
.filter(channel_user_permission::Column::ChannelId.is_in(channel_ids))
.all(&self.context.db)
.await?
};
for permission in user_channel_permissions {
permissions_by_channel
.entry(permission.channel_id)
.or_default()
.insert(ChannelPermission::from_bits_retain(
permission.permissions as u64,
));
}
// ---------------------------------------------------------------------
// 7. Construction des modèles à insérer
// ---------------------------------------------------------------------
let mut computed_permissions = Vec::with_capacity(channels.len().saturating_add(1));
// Permissions au niveau serveur
computed_permissions.push(computed_permission::ActiveModel {
user_id: Set(user_id),
server_id: Set(server_id),
scope_type: Set(PermissionScopeType::Server),
resource_id: Set(server_id),
permissions: Set(server_permissions.bits() as i64),
..Default::default()
});
// Permissions au niveau canal
for channel in channels {
let channel_permissions = permissions_by_channel
.remove(&channel.id)
.unwrap_or_else(ChannelPermission::empty);
computed_permissions.push(computed_permission::ActiveModel {
user_id: Set(user_id),
server_id: Set(server_id),
scope_type: Set(PermissionScopeType::Channel),
resource_id: Set(channel.id),
permissions: Set(channel_permissions.bits() as i64),
..Default::default()
});
}
// ---------------------------------------------------------------------
// 8. Remplacement atomique du cache en BDD
// ---------------------------------------------------------------------
self.context
.db
.transaction::<_, (), anyhow::Error>(|transaction| {
Box::pin(async move {
computed_permission::Entity::delete_many()
.filter(computed_permission::Column::UserId.eq(user_id))
.filter(computed_permission::Column::ServerId.eq(server_id))
.exec(transaction)
.await?;
if !computed_permissions.is_empty() {
computed_permission::Entity::insert_many(computed_permissions)
.exec(transaction)
.await?;
}
Ok(())
})
})
.await?;
Ok(())
}
}
-47
View File
@@ -1,47 +0,0 @@
use crate::models::group;
use crate::repositories::{AnyResult, RepositoryContext};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter};
use std::sync::Arc;
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct GroupRepository {
pub context: Arc<RepositoryContext>,
}
impl GroupRepository {
pub async fn get_all_by_server(&self, server_id: Uuid) -> AnyResult<Vec<group::Model>> {
Ok(group::Entity::find()
.filter(group::Column::ServerId.eq(server_id))
.all(&self.context.db)
.await?)
}
pub async fn get_all(&self) -> AnyResult<Vec<group::Model>> {
Ok(group::Entity::find().all(&self.context.db).await?)
}
pub async fn get_by_id(&self, id: Uuid) -> AnyResult<Option<group::Model>> {
Ok(group::Entity::find_by_id(id).one(&self.context.db).await?)
}
pub async fn create(&self, active: group::ActiveModel) -> AnyResult<group::Model> {
let group = active.insert(&self.context.db).await?;
self.context.events.emit("group_created", group.clone());
Ok(group)
}
pub async fn update(&self, active: group::ActiveModel) -> AnyResult<group::Model> {
let group = active.update(&self.context.db).await?;
self.context.events.emit("group_updated", group.clone());
Ok(group)
}
pub async fn delete(&self, id: Uuid) -> AnyResult<bool> {
let res = group::Entity::delete_by_id(id)
.exec(&self.context.db)
.await?;
self.context.events.emit("group_deleted", id);
Ok(res.rows_affected > 0)
}
}

Some files were not shown because too many files have changed in this diff Show More