Compare commits
41
Commits
b40373f3e3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6d6968e52 | ||
|
|
20beea24d5 | ||
|
|
93800e8460 | ||
|
|
8f3fd6a127 | ||
|
|
42ab990f7d | ||
|
|
d1f9234457 | ||
|
|
0d8c86af16 | ||
|
|
e9fe51363f | ||
|
|
c10925b84b | ||
|
|
bb4e17ba2f | ||
|
|
aa486be6e5 | ||
|
|
659fd0f304 | ||
|
|
9dbb7ffd5b | ||
|
|
ba51dde1c2 | ||
|
|
73379e9ca8 | ||
|
|
b946cfd866 | ||
|
|
22b0bc36bb | ||
|
|
fdc9fb592d | ||
|
|
621f8cefa0 | ||
|
|
086e5ab0ea | ||
|
|
1bb00e1edf | ||
|
|
759bc1dc15 | ||
|
|
6cb9acf98b | ||
|
|
98050bb770 | ||
|
|
9b705f0d96 | ||
|
|
066074dcd4 | ||
|
|
96ffe27040 | ||
|
|
c29e38d2dc | ||
|
|
ed4cb2a39c | ||
|
|
7b33b76b3d | ||
|
|
70c0b649e6 | ||
|
|
d0e4bdd90e | ||
|
|
40e98bf3e2 | ||
|
|
068e100ca1 | ||
|
|
6fb8ab19aa | ||
|
|
b54b60c988 | ||
|
|
62f7c6edba | ||
|
|
23998b9ea9 | ||
|
|
94b012465a | ||
|
|
8afa694aed | ||
|
|
b7c48ce7f3 |
@@ -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.
|
||||
@@ -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 qu’ils 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 d’identifiants 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, d’ordre 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 d’autres 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
|
||||
lorsqu’une suppression ne nécessite que l’identifiant ; 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 l’identifiant 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 d’information 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 l’inté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.
|
||||
@@ -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,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
+207
-208
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -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.5", 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"
|
||||
|
||||
@@ -13,11 +13,11 @@ name = "event_bus_throughput"
|
||||
harness = false
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.52.3", default-features = false, features = ["rt", "sync"] }
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
||||
parking_lot = "0.12.5"
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.23.5", features = ["v4"] }
|
||||
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"] }
|
||||
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time", "sync"] }
|
||||
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
||||
|
||||
@@ -331,6 +331,48 @@ impl EventBus {
|
||||
})
|
||||
}
|
||||
|
||||
// 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,
|
||||
C: Clone + Send + Sync + 'static,
|
||||
F: Fn(C, T) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
let mut rx = self.get_or_create_sender(topic).subscribe();
|
||||
let topic_owned = topic.to_string();
|
||||
|
||||
debug!(topic, "Async subscriber registered");
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(evt) => {
|
||||
if let Some(typed) = evt.downcast_ref::<T>() {
|
||||
trace!(topic = topic_owned, "Async handler invoked");
|
||||
|
||||
handler(context.clone(), typed.clone()).await;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(
|
||||
topic = topic_owned,
|
||||
skipped = n,
|
||||
"Subscriber lagged, messages dropped"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!(
|
||||
topic = topic_owned,
|
||||
"Channel closed, async subscriber exiting"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Subscription — low-level access (advanced use cases)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
node_modules/
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<template>
|
||||
<router-view/>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 d’ajout' }
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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-avatar
|
||||
class="d-block text-center mx-auto mb-9"
|
||||
color="grey-lighten-1"
|
||||
size="28"
|
||||
></v-avatar>
|
||||
<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
|
||||
: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;
|
||||
}
|
||||
|
||||
</style>
|
||||
.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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
<v-col cols="12" md="6">
|
||||
<ChannelPermissionEditor
|
||||
v-model="channelPermissions"
|
||||
title="Test des permissions canal"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
@@ -1,7 +1,9 @@
|
||||
<template>
|
||||
<HelloWorld />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import HelloWorld from '@/components/HelloWorld.vue'
|
||||
</script>
|
||||
|
||||
|
||||
<template>
|
||||
<div>
|
||||
Hello
|
||||
</div>
|
||||
</template>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -160,4 +342,4 @@ watch(messages, () => {
|
||||
margin: 8px 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
+229
-123
@@ -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>
|
||||
<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
|
||||
:title="item.Category[0].name"
|
||||
v-bind="groupProps"
|
||||
@contextmenu="onCategoryContextMenu($event, item.Category[0])"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<v-list>
|
||||
<v-list-item
|
||||
v-for="channel in channels"
|
||||
:key="channel.id"
|
||||
:title="channel.name"
|
||||
:to="`/server/${serverId}/channel/${channel.id}`"
|
||||
link
|
||||
></v-list-item>
|
||||
<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
|
||||
@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>
|
||||
@@ -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()}`;
|
||||
@@ -16,4 +20,21 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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: {
|
||||
@@ -62,4 +66,4 @@ export const useChannelStore = defineStore('channel', {
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
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) {
|
||||
@@ -100,4 +111,4 @@ export const useGatewayStore = defineStore('gateway', {
|
||||
}, delay)
|
||||
},
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+307
-41
@@ -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) {
|
||||
console.error("Erreur lors du chargement des messages:", error);
|
||||
if (requestVersion === this.requestVersion) {
|
||||
console.error("Erreur lors du chargement des messages:", error);
|
||||
}
|
||||
} finally {
|
||||
this.loading = false;
|
||||
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);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 d’ajouter 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
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -93,4 +108,6 @@ export const useSessionStore = defineStore('session', {
|
||||
this.isReady = true
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
onReloadAll(() => useSessionStore().reloadServers())
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 d’un 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),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface Role {
|
||||
id: string
|
||||
server_id: string
|
||||
name: string
|
||||
is_default: boolean
|
||||
created_at: string
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface User {
|
||||
id: string
|
||||
username: string
|
||||
pub_key: string | null
|
||||
is_superuser: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
+12
-12
@@ -1,12 +1,12 @@
|
||||
pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20220101_000001_create_table;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![Box::new(m20220101_000001_create_table::Migration)]
|
||||
}
|
||||
}
|
||||
pub use sea_orm_migration::prelude::*;
|
||||
|
||||
mod m20220101_000001_create_table;
|
||||
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigratorTrait for Migrator {
|
||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||
vec![Box::new(m20220101_000001_create_table::Migration)]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,13 +138,18 @@ impl MigrationTrait for Migration {
|
||||
.to(Alias::new("user"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.index(
|
||||
Index::create()
|
||||
.name("uq_server_user")
|
||||
.col(Alias::new("server_id"))
|
||||
.col(Alias::new("user_id"))
|
||||
.unique(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("uq_server_user")
|
||||
.table(Alias::new("server_user"))
|
||||
.col(Alias::new("server_id"))
|
||||
.col(Alias::new("user_id"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
@@ -185,13 +190,18 @@ impl MigrationTrait for Migration {
|
||||
.to(Alias::new("server"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.index(
|
||||
Index::create()
|
||||
.name("uq_role_server_name")
|
||||
.col(Alias::new("server_id"))
|
||||
.col(Alias::new("name"))
|
||||
.unique(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("uq_role_server_name")
|
||||
.table(Alias::new("role"))
|
||||
.col(Alias::new("server_id"))
|
||||
.col(Alias::new("name"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
@@ -243,12 +253,6 @@ impl MigrationTrait for Migration {
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("server_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("name")).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("position"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
@@ -285,12 +289,6 @@ impl MigrationTrait for Migration {
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("server_id")).uuid().null())
|
||||
.col(ColumnDef::new(Alias::new("category_id")).uuid().null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("position"))
|
||||
.integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("channel_type"))
|
||||
.integer()
|
||||
@@ -327,6 +325,96 @@ impl MigrationTrait for Migration {
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Ordre des catégories et des canaux (Polymorphique)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("server_item_order"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("server_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("resource_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("resource_type"))
|
||||
.integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("parent_category_id"))
|
||||
.uuid()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("order_key"))
|
||||
.big_integer()
|
||||
.not_null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_server_item_order_server")
|
||||
.from(Alias::new("server_item_order"), Alias::new("server_id"))
|
||||
.to(Alias::new("server"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_server_item_order_parent_category")
|
||||
.from(
|
||||
Alias::new("server_item_order"),
|
||||
Alias::new("parent_category_id"),
|
||||
)
|
||||
.to(Alias::new("category"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_server_item_order_scope")
|
||||
.table(Alias::new("server_item_order"))
|
||||
.col(Alias::new("server_id"))
|
||||
.col(Alias::new("parent_category_id"))
|
||||
.col(Alias::new("order_key"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("uq_server_item_order_resource")
|
||||
.table(Alias::new("server_item_order"))
|
||||
.col(Alias::new("server_id"))
|
||||
.col(Alias::new("resource_type"))
|
||||
.col(Alias::new("resource_id"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Membres des canaux
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -370,13 +458,82 @@ impl MigrationTrait for Migration {
|
||||
.to(Alias::new("user"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.index(
|
||||
Index::create()
|
||||
.name("uq_channel_user")
|
||||
.col(Alias::new("channel_id"))
|
||||
.col(Alias::new("user_id"))
|
||||
.unique(),
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("uq_channel_user")
|
||||
.table(Alias::new("channel_user"))
|
||||
.col(Alias::new("channel_id"))
|
||||
.col(Alias::new("user_id"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Position de lecture des utilisateurs
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("channel_user_read_state"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("channel_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("user_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("last_read_message_id"))
|
||||
.uuid()
|
||||
.null(),
|
||||
)
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("updated_at"))
|
||||
.timestamp_with_time_zone()
|
||||
.not_null()
|
||||
.default(Expr::current_timestamp()),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_channel_user_read_state_channel")
|
||||
.from(
|
||||
Alias::new("channel_user_read_state"),
|
||||
Alias::new("channel_id"),
|
||||
)
|
||||
.to(Alias::new("channel"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_channel_user_read_state_user")
|
||||
.from(
|
||||
Alias::new("channel_user_read_state"),
|
||||
Alias::new("user_id"),
|
||||
)
|
||||
.to(Alias::new("user"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("uq_channel_user_read_state")
|
||||
.table(Alias::new("channel_user_read_state"))
|
||||
.col(Alias::new("channel_id"))
|
||||
.col(Alias::new("user_id"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
@@ -436,6 +593,17 @@ impl MigrationTrait for Migration {
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_message_channel_id_id")
|
||||
.table(Alias::new("message"))
|
||||
.col(Alias::new("channel_id"))
|
||||
.col(Alias::new("id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
@@ -476,6 +644,58 @@ impl MigrationTrait for Migration {
|
||||
// Permissions
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
.table(Alias::new("server_user_permission"))
|
||||
.if_not_exists()
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("id"))
|
||||
.uuid()
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("server_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("user_id")).uuid().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("permissions"))
|
||||
.big_integer()
|
||||
.not_null()
|
||||
.default(0),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_server_user_permission_server")
|
||||
.from(
|
||||
Alias::new("server_user_permission"),
|
||||
Alias::new("server_id"),
|
||||
)
|
||||
.to(Alias::new("server"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_server_user_permission_user")
|
||||
.from(Alias::new("server_user_permission"), Alias::new("user_id"))
|
||||
.to(Alias::new("user"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("uq_server_user_permission")
|
||||
.table(Alias::new("server_user_permission"))
|
||||
.col(Alias::new("server_id"))
|
||||
.col(Alias::new("user_id"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_table(
|
||||
Table::create()
|
||||
@@ -512,13 +732,18 @@ impl MigrationTrait for Migration {
|
||||
.to(Alias::new("role"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.index(
|
||||
Index::create()
|
||||
.name("uq_server_role_permission")
|
||||
.col(Alias::new("server_id"))
|
||||
.col(Alias::new("role_id"))
|
||||
.unique(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("uq_server_role_permission")
|
||||
.table(Alias::new("server_role_permission"))
|
||||
.col(Alias::new("server_id"))
|
||||
.col(Alias::new("role_id"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
@@ -559,13 +784,18 @@ impl MigrationTrait for Migration {
|
||||
.to(Alias::new("role"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.index(
|
||||
Index::create()
|
||||
.name("uq_channel_role_permission")
|
||||
.col(Alias::new("channel_id"))
|
||||
.col(Alias::new("role_id"))
|
||||
.unique(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("uq_channel_role_permission")
|
||||
.table(Alias::new("channel_role_permission"))
|
||||
.col(Alias::new("channel_id"))
|
||||
.col(Alias::new("role_id"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
@@ -606,13 +836,18 @@ impl MigrationTrait for Migration {
|
||||
.to(Alias::new("user"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.index(
|
||||
Index::create()
|
||||
.name("uq_channel_user_permission")
|
||||
.col(Alias::new("channel_id"))
|
||||
.col(Alias::new("user_id"))
|
||||
.unique(),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("uq_channel_user_permission")
|
||||
.table(Alias::new("channel_user_permission"))
|
||||
.col(Alias::new("channel_id"))
|
||||
.col(Alias::new("user_id"))
|
||||
.unique()
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
@@ -626,6 +861,7 @@ impl MigrationTrait for Migration {
|
||||
Table::create()
|
||||
.table(Alias::new("computed_permission"))
|
||||
.if_not_exists()
|
||||
.col(ColumnDef::new(Alias::new("id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("user_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("server_id")).uuid().not_null())
|
||||
.col(
|
||||
@@ -642,6 +878,7 @@ impl MigrationTrait for Migration {
|
||||
)
|
||||
.primary_key(
|
||||
Index::create()
|
||||
.col(Alias::new("id"))
|
||||
.col(Alias::new("user_id"))
|
||||
.col(Alias::new("server_id"))
|
||||
.col(Alias::new("scope_type"))
|
||||
@@ -669,15 +906,17 @@ impl MigrationTrait for Migration {
|
||||
}
|
||||
|
||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||
// Les tables dépendantes doivent être supprimées avant leurs parents.
|
||||
let tables = [
|
||||
"computed_permission",
|
||||
"channel_user_read_state",
|
||||
"channel_user_permission",
|
||||
"channel_role_permission",
|
||||
"server_user_permission",
|
||||
"server_role_permission",
|
||||
"attachment",
|
||||
"message",
|
||||
"channel_user",
|
||||
"server_item_order",
|
||||
"channel",
|
||||
"category",
|
||||
"role_user",
|
||||
|
||||
@@ -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
-13
@@ -1,14 +1,12 @@
|
||||
mod permission_sync;
|
||||
pub mod state;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::core::permission_sync::PermissionSyncService;
|
||||
use crate::core::state::Services;
|
||||
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};
|
||||
@@ -19,7 +17,6 @@ use uuid::Uuid;
|
||||
|
||||
pub struct App {
|
||||
pub state: AppState,
|
||||
pub services: Services,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -35,10 +32,9 @@ impl App {
|
||||
let event_bus = Arc::new(EventBus::with_capacity(1024));
|
||||
|
||||
// Initialize shared repositories
|
||||
let repositories = Arc::new(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? {
|
||||
@@ -70,7 +66,12 @@ impl App {
|
||||
|
||||
let metrics = AppMetrics::new();
|
||||
|
||||
let permission_sync = PermissionSyncService::new(repositories.clone(), event_bus.clone());
|
||||
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,
|
||||
@@ -81,13 +82,10 @@ impl App {
|
||||
metrics,
|
||||
gateway,
|
||||
event_bus,
|
||||
services,
|
||||
};
|
||||
|
||||
let services = Services {
|
||||
permission_sync: Arc::new(permission_sync),
|
||||
};
|
||||
|
||||
Ok(Self { state, services })
|
||||
Ok(Self { state })
|
||||
}
|
||||
|
||||
pub async fn run(self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
use crate::repositories::Repositories;
|
||||
use event_bus::EventBus;
|
||||
use std::sync::Arc;
|
||||
|
||||
// list of all events :
|
||||
// server_user_created
|
||||
// server_user_deleted
|
||||
//
|
||||
// role_user_created
|
||||
// role_user_deleted
|
||||
//
|
||||
// server_role_permission_created
|
||||
// server_role_permission_updated
|
||||
// server_role_permission_deleted
|
||||
//
|
||||
// server_user_permission_created
|
||||
// server_user_permission_updated
|
||||
// server_user_permission_deleted
|
||||
//
|
||||
// channel_role_permission_created
|
||||
// channel_role_permission_updated
|
||||
// channel_role_permission_deleted
|
||||
//
|
||||
// channel_user_permission_created
|
||||
// channel_user_permission_updated
|
||||
// channel_user_permission_deleted
|
||||
//
|
||||
// channel_created
|
||||
// channel_deleted
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PermissionSyncService {
|
||||
repositories: Arc<Repositories>,
|
||||
event_bus: Arc<EventBus>,
|
||||
}
|
||||
|
||||
impl PermissionSyncService {
|
||||
pub fn new(repositories: Arc<Repositories>, event_bus: Arc<EventBus>) -> Self {
|
||||
Self {
|
||||
repositories,
|
||||
event_bus,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn listen(&self) {}
|
||||
}
|
||||
+2
-6
@@ -1,9 +1,9 @@
|
||||
use crate::config::AppConfig;
|
||||
use crate::core::permission_sync::PermissionSyncService;
|
||||
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};
|
||||
@@ -18,11 +18,7 @@ pub struct AppState {
|
||||
pub metrics: AppMetrics,
|
||||
pub gateway: Arc<GatewayManager>,
|
||||
pub event_bus: Arc<EventBus>,
|
||||
pub services: Arc<Services>,
|
||||
}
|
||||
|
||||
impl AppState {}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Services {
|
||||
pub permission_sync: Arc<PermissionSyncService>,
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,8 +3,13 @@ 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,
|
||||
@@ -13,14 +18,14 @@ pub struct CreateGroupRequest {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct UpdateGroupRequest {
|
||||
pub struct UpdateRoleRequest {
|
||||
#[schema(example = "Modérateurs (MAJ)")]
|
||||
pub name: String,
|
||||
pub is_default: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct GroupResponse {
|
||||
pub struct RoleResponse {
|
||||
pub id: Uuid,
|
||||
pub server_id: Uuid,
|
||||
pub name: String,
|
||||
@@ -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,
|
||||
@@ -1,17 +1,20 @@
|
||||
use crate::models::prelude::Channel;
|
||||
use crate::models::channel;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct ChannelCreated {
|
||||
server_id: Uuid,
|
||||
channel: Channel,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelCreatedEvent {
|
||||
pub server_id: Uuid,
|
||||
pub channel: channel::Model,
|
||||
}
|
||||
|
||||
pub struct ChannelUpdated {
|
||||
server_id: Uuid,
|
||||
channel: Channel,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelUpdatedEvent {
|
||||
pub server_id: Uuid,
|
||||
pub channel: channel::Model,
|
||||
}
|
||||
|
||||
pub struct ChannelDeleted {
|
||||
server_id: Uuid,
|
||||
channel: Channel,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelDeletedEvent {
|
||||
pub server_id: Uuid,
|
||||
pub channel: channel::Model,
|
||||
}
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
use crate::models::prelude::Message;
|
||||
use crate::models::message;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct MessageCreated {
|
||||
server_id: Option<Uuid>,
|
||||
channel_id: Uuid,
|
||||
message: Message,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageCreatedEvent {
|
||||
pub server_id: Option<Uuid>,
|
||||
pub channel_id: Uuid,
|
||||
pub message: message::Model,
|
||||
}
|
||||
|
||||
pub struct MessageUpdated {
|
||||
server_id: Option<Uuid>,
|
||||
channel_id: Uuid,
|
||||
message: Message,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageUpdatedEvent {
|
||||
pub server_id: Option<Uuid>,
|
||||
pub channel_id: Uuid,
|
||||
pub message: message::Model,
|
||||
}
|
||||
|
||||
pub struct MessageDeleted {
|
||||
server_id: Option<Uuid>,
|
||||
channel_id: Uuid,
|
||||
message: Message,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MessageDeletedEvent {
|
||||
pub server_id: Option<Uuid>,
|
||||
pub channel_id: Uuid,
|
||||
pub message: message::Model,
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
use crate::models::prelude::Server;
|
||||
use crate::models::server;
|
||||
|
||||
pub struct ServerCreated {
|
||||
server: Server,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerCreatedEvent {
|
||||
pub server: server::Model,
|
||||
}
|
||||
|
||||
pub struct ServerUpdated {
|
||||
server: Server,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerUpdatedEvent {
|
||||
pub server: server::Model,
|
||||
}
|
||||
|
||||
pub struct ServerDeleted {
|
||||
server: Server,
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerDeletedEvent {
|
||||
pub server: server::Model,
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod events;
|
||||
pub mod dto;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -13,3 +13,6 @@ pub mod auth;
|
||||
pub mod metrics;
|
||||
|
||||
pub mod domain;
|
||||
|
||||
pub mod services;
|
||||
pub mod utils;
|
||||
|
||||
+1
-3
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
#[sea_orm::model]
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
@@ -12,7 +12,6 @@ pub struct Model {
|
||||
pub id: Uuid,
|
||||
pub server_id: Uuid,
|
||||
pub name: String,
|
||||
pub position: i32,
|
||||
pub created_at: DateTimeUtc,
|
||||
pub updated_at: DateTimeUtc,
|
||||
#[sea_orm(has_many)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -28,7 +28,6 @@ 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,
|
||||
|
||||
@@ -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 {}
|
||||
+21
-19
@@ -1,19 +1,21 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod attachment;
|
||||
pub mod category;
|
||||
pub mod channel;
|
||||
pub mod channel_role_permission;
|
||||
pub mod channel_user;
|
||||
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_role_permission;
|
||||
pub mod server_user;
|
||||
pub mod server_user_permission;
|
||||
pub mod user;
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||
|
||||
pub mod prelude;
|
||||
|
||||
pub mod attachment;
|
||||
pub mod category;
|
||||
pub mod channel;
|
||||
pub mod channel_role_permission;
|
||||
pub mod channel_user;
|
||||
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;
|
||||
|
||||
+17
-15
@@ -1,15 +1,17 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||
|
||||
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::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_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;
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||
|
||||
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::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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use sea_orm::{NotSet, Set};
|
||||
|
||||
#[sea_orm::model]
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||
#[sea_orm(table_name = "server_group_permission")]
|
||||
#[sea_orm(table_name = "server_role_permission")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,23 @@ use crate::models::{
|
||||
use crate::permissions::{ChannelPermission, ServerPermission};
|
||||
use crate::repositories::{AnyResult, RepositoryContext};
|
||||
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait};
|
||||
use sea_orm::{
|
||||
ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, Set, TransactionTrait,
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
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 {
|
||||
@@ -18,13 +29,42 @@ pub struct ComputedPermissionRepository {
|
||||
}
|
||||
|
||||
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?)
|
||||
}
|
||||
|
||||
/// Recalcule le cache de permissions pour tous les utilisateurs du serveur.
|
||||
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))
|
||||
@@ -41,19 +81,36 @@ impl ComputedPermissionRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recalcule le cache de permissions d'un utilisateur sur un serveur.
|
||||
/// 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<()> {
|
||||
// ---------------------------------------------------------------------
|
||||
// Rôles de l'utilisateur
|
||||
// ---------------------------------------------------------------------
|
||||
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()
|
||||
@@ -63,9 +120,8 @@ impl ComputedPermissionRepository {
|
||||
.await?;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Permissions serveur des rôles
|
||||
// 2. Permissions serveur des rôles
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
let mut server_permissions = ServerPermission::empty();
|
||||
|
||||
if !role_ids.is_empty() {
|
||||
@@ -82,9 +138,8 @@ impl ComputedPermissionRepository {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Permissions serveur directes de l'utilisateur
|
||||
// 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))
|
||||
@@ -95,20 +150,18 @@ impl ComputedPermissionRepository {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Canaux du serveur
|
||||
// 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(|channel| channel.id).collect();
|
||||
let channel_ids: Vec<Uuid> = channels.iter().map(|c| c.id).collect();
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Permissions de rôles pour tous les canaux
|
||||
// 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 {
|
||||
@@ -131,9 +184,8 @@ impl ComputedPermissionRepository {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Permissions directes de l'utilisateur pour tous les canaux
|
||||
// 6. Permissions directes de l'utilisateur pour tous les canaux
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
let user_channel_permissions = if channel_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -154,12 +206,11 @@ impl ComputedPermissionRepository {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Construction du cache
|
||||
// 7. Construction des modèles à insérer
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
let mut computed_permissions = Vec::with_capacity(channels.len().saturating_add(1));
|
||||
|
||||
// Permissions au niveau serveur.
|
||||
// Permissions au niveau serveur
|
||||
computed_permissions.push(computed_permission::ActiveModel {
|
||||
user_id: Set(user_id),
|
||||
server_id: Set(server_id),
|
||||
@@ -169,7 +220,7 @@ impl ComputedPermissionRepository {
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
// Permissions au niveau canal.
|
||||
// Permissions au niveau canal
|
||||
for channel in channels {
|
||||
let channel_permissions = permissions_by_channel
|
||||
.remove(&channel.id)
|
||||
@@ -186,9 +237,8 @@ impl ComputedPermissionRepository {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Remplacement atomique du cache
|
||||
// 8. Remplacement atomique du cache en BDD
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
self.context
|
||||
.db
|
||||
.transaction::<_, (), anyhow::Error>(|transaction| {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
use crate::models::role;
|
||||
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<role::Model>> {
|
||||
Ok(role::Entity::find()
|
||||
.filter(role::Column::ServerId.eq(server_id))
|
||||
.all(&self.context.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_all(&self) -> AnyResult<Vec<role::Model>> {
|
||||
Ok(role::Entity::find().all(&self.context.db).await?)
|
||||
}
|
||||
|
||||
pub async fn get_by_id(&self, id: Uuid) -> AnyResult<Option<role::Model>> {
|
||||
Ok(role::Entity::find_by_id(id).one(&self.context.db).await?)
|
||||
}
|
||||
|
||||
pub async fn create(&self, active: role::ActiveModel) -> AnyResult<role::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: role::ActiveModel) -> AnyResult<role::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 = role::Entity::delete_by_id(id)
|
||||
.exec(&self.context.db)
|
||||
.await?;
|
||||
self.context.events.emit("group_deleted", id);
|
||||
Ok(res.rows_affected > 0)
|
||||
}
|
||||
}
|
||||
+88
-36
@@ -1,9 +1,18 @@
|
||||
use super::types::MessageFilter;
|
||||
use crate::models::{channel, message};
|
||||
use crate::models::message;
|
||||
use crate::repositories::{AnyResult, RepositoryContext};
|
||||
use event_bus::Scope;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DEFAULT_MESSAGE_LIMIT: u64 = 20;
|
||||
pub const MAX_MESSAGE_LIMIT: u64 = 100;
|
||||
|
||||
pub struct MessagePage {
|
||||
pub messages: Vec<message::Model>,
|
||||
pub has_more_before: bool,
|
||||
pub has_more_after: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MessageRepository {
|
||||
@@ -21,7 +30,11 @@ impl MessageRepository {
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn filter(&self, filter: MessageFilter) -> AnyResult<Vec<message::Model>> {
|
||||
pub async fn filter(&self, filter: MessageFilter) -> AnyResult<MessagePage> {
|
||||
let limit = filter
|
||||
.limit
|
||||
.unwrap_or(DEFAULT_MESSAGE_LIMIT)
|
||||
.clamp(1, MAX_MESSAGE_LIMIT);
|
||||
let mut query = message::Entity::find();
|
||||
|
||||
if let Some(channel_id) = filter.channel_id {
|
||||
@@ -32,11 +45,80 @@ impl MessageRepository {
|
||||
query = query.filter(message::Column::Id.lt(before_id));
|
||||
}
|
||||
|
||||
if let Some(limit) = filter.limit {
|
||||
query = query.order_by_desc(message::Column::Id).limit(limit);
|
||||
if let Some(after_id) = filter.after_id {
|
||||
query = query
|
||||
.filter(message::Column::Id.gt(after_id))
|
||||
.order_by_asc(message::Column::Id);
|
||||
} else {
|
||||
query = query.order_by_desc(message::Column::Id);
|
||||
}
|
||||
|
||||
Ok(query.all(&self.context.db).await?)
|
||||
let mut messages = query.limit(limit + 1).all(&self.context.db).await?;
|
||||
let has_more_in_direction = messages.len() > limit as usize;
|
||||
messages.truncate(limit as usize);
|
||||
|
||||
// Queries that walk backwards are executed in descending order so the
|
||||
// database can stop as soon as it has found the requested rows. The UI
|
||||
// always receives chronological order.
|
||||
if filter.after_id.is_none() {
|
||||
messages.reverse();
|
||||
}
|
||||
|
||||
let (has_more_before, has_more_after) = if filter.after_id.is_some() {
|
||||
let has_messages_before = self
|
||||
.exists_on_or_before(filter.channel_id, filter.after_id.unwrap())
|
||||
.await?;
|
||||
(has_messages_before, has_more_in_direction)
|
||||
} else if filter.before_id.is_some() {
|
||||
let has_messages_after = self
|
||||
.exists_on_or_after(filter.channel_id, filter.before_id.unwrap())
|
||||
.await?;
|
||||
(has_more_in_direction, has_messages_after)
|
||||
} else {
|
||||
(has_more_in_direction, false)
|
||||
};
|
||||
|
||||
Ok(MessagePage {
|
||||
messages,
|
||||
has_more_before,
|
||||
has_more_after,
|
||||
})
|
||||
}
|
||||
|
||||
async fn exists_on_or_before(&self, channel_id: Option<Uuid>, id: Uuid) -> AnyResult<bool> {
|
||||
let mut query = message::Entity::find()
|
||||
.select_only()
|
||||
.column(message::Column::Id)
|
||||
.filter(message::Column::Id.lte(id));
|
||||
|
||||
if let Some(channel_id) = channel_id {
|
||||
query = query.filter(message::Column::ChannelId.eq(channel_id));
|
||||
}
|
||||
|
||||
Ok(query
|
||||
.limit(1)
|
||||
.into_tuple::<Uuid>()
|
||||
.one(&self.context.db)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn exists_on_or_after(&self, channel_id: Option<Uuid>, id: Uuid) -> AnyResult<bool> {
|
||||
let mut query = message::Entity::find()
|
||||
.select_only()
|
||||
.column(message::Column::Id)
|
||||
.filter(message::Column::Id.gte(id));
|
||||
|
||||
if let Some(channel_id) = channel_id {
|
||||
query = query.filter(message::Column::ChannelId.eq(channel_id));
|
||||
}
|
||||
|
||||
Ok(query
|
||||
.limit(1)
|
||||
.into_tuple::<Uuid>()
|
||||
.one(&self.context.db)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
pub async fn get_by_channel(&self, channel_id: uuid::Uuid) -> AnyResult<Vec<message::Model>> {
|
||||
@@ -48,38 +130,11 @@ impl MessageRepository {
|
||||
|
||||
pub async fn update(&self, active: message::ActiveModel) -> AnyResult<message::Model> {
|
||||
let message = active.update(&self.context.db).await?;
|
||||
self.context.events.emit("message_updated", message.clone());
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
pub async fn create(&self, active: message::ActiveModel) -> AnyResult<message::Model> {
|
||||
let message = active.insert(&self.context.db).await?;
|
||||
|
||||
// self.context.events.emit("message_created", message.clone());
|
||||
|
||||
// todo : test
|
||||
// Ici l'évènement est déclencher sur les topic suivant :
|
||||
// message_created
|
||||
// channel:_channel_uuid_:message_created
|
||||
// si server : server:_server_uuid_:message_created
|
||||
// scoped event
|
||||
let mut scopes: Vec<Scope> = Vec::new();
|
||||
scopes.push(Scope::uuid("channel", message.channel_id));
|
||||
// retrieve related channel and server
|
||||
let server_id: Option<uuid::Uuid> = channel::Entity::find_by_id(message.channel_id)
|
||||
.select_only()
|
||||
.column(channel::Column::ServerId)
|
||||
.into_tuple::<Option<uuid::Uuid>>()
|
||||
.one(&self.context.db)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
if let Some(server_id) = server_id {
|
||||
scopes.push(Scope::uuid("server", server_id));
|
||||
}
|
||||
self.context
|
||||
.events
|
||||
.emit_scoped("message_created", scopes, message.clone());
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
@@ -88,9 +143,6 @@ impl MessageRepository {
|
||||
.exec(&self.context.db)
|
||||
.await?;
|
||||
let deleted = result.rows_affected > 0;
|
||||
if deleted {
|
||||
self.context.events.emit("message_deleted", id);
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-10
@@ -3,27 +3,31 @@ pub type AnyResult<T> = anyhow::Result<T>;
|
||||
use crate::repositories::category::CategoryRepository;
|
||||
use crate::repositories::channel::ChannelRepository;
|
||||
use crate::repositories::computed_permission::ComputedPermissionRepository;
|
||||
use crate::repositories::group::GroupRepository;
|
||||
use crate::repositories::message::MessageRepository;
|
||||
use crate::repositories::read_state::ReadStateRepository;
|
||||
use crate::repositories::role::RoleRepository;
|
||||
use crate::repositories::server::ServerRepository;
|
||||
use crate::repositories::server_item_order::ServerItemOrderRepository;
|
||||
use crate::repositories::server_tree::ServerTreeRepository;
|
||||
use crate::repositories::user::UserRepository;
|
||||
use event_bus::EventBus;
|
||||
use sea_orm::DatabaseConnection;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod category;
|
||||
mod channel;
|
||||
mod computed_permission;
|
||||
mod group;
|
||||
mod message;
|
||||
mod read_state;
|
||||
mod role;
|
||||
mod server;
|
||||
mod server_item_order;
|
||||
mod server_tree;
|
||||
pub mod types;
|
||||
mod user;
|
||||
pub mod user;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RepositoryContext {
|
||||
db: DatabaseConnection,
|
||||
events: Arc<EventBus>,
|
||||
pub db: DatabaseConnection,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -31,15 +35,18 @@ pub struct Repositories {
|
||||
pub server: ServerRepository,
|
||||
pub category: CategoryRepository,
|
||||
pub channel: ChannelRepository,
|
||||
pub group: GroupRepository,
|
||||
pub role: RoleRepository,
|
||||
pub message: MessageRepository,
|
||||
pub read_state: ReadStateRepository,
|
||||
pub user: UserRepository,
|
||||
pub computed_permission: ComputedPermissionRepository,
|
||||
pub server_item_order: ServerItemOrderRepository,
|
||||
pub server_tree: ServerTreeRepository,
|
||||
}
|
||||
|
||||
impl Repositories {
|
||||
pub fn new(db: DatabaseConnection, events: Arc<EventBus>) -> Self {
|
||||
let context = Arc::new(RepositoryContext { db, events });
|
||||
pub fn new(db: DatabaseConnection) -> Self {
|
||||
let context = Arc::new(RepositoryContext { db });
|
||||
|
||||
Self {
|
||||
server: ServerRepository {
|
||||
@@ -51,18 +58,27 @@ impl Repositories {
|
||||
channel: ChannelRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
group: GroupRepository {
|
||||
role: RoleRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
message: MessageRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
read_state: ReadStateRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
user: UserRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
computed_permission: ComputedPermissionRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
server_item_order: ServerItemOrderRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
server_tree: ServerTreeRepository {
|
||||
context: context.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
use crate::models::{channel, channel_user_read_state, message};
|
||||
use crate::repositories::{AnyResult, RepositoryContext};
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ReadStateRepository {
|
||||
pub context: Arc<RepositoryContext>,
|
||||
}
|
||||
|
||||
impl ReadStateRepository {
|
||||
pub async fn get(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> AnyResult<Option<channel_user_read_state::Model>> {
|
||||
Ok(channel_user_read_state::Entity::find()
|
||||
.filter(channel_user_read_state::Column::ChannelId.eq(channel_id))
|
||||
.filter(channel_user_read_state::Column::UserId.eq(user_id))
|
||||
.one(&self.context.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn set(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
user_id: Uuid,
|
||||
last_read_message_id: Option<Uuid>,
|
||||
) -> AnyResult<channel_user_read_state::Model> {
|
||||
let now = Utc::now();
|
||||
let active = channel_user_read_state::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
channel_id: Set(channel_id),
|
||||
user_id: Set(user_id),
|
||||
last_read_message_id: Set(last_read_message_id),
|
||||
updated_at: Set(now),
|
||||
};
|
||||
|
||||
if let Some(existing) = self.get(channel_id, user_id).await? {
|
||||
if existing.last_read_message_id >= last_read_message_id {
|
||||
return Ok(existing);
|
||||
}
|
||||
let mut active: channel_user_read_state::ActiveModel = existing.into();
|
||||
active.last_read_message_id = Set(last_read_message_id);
|
||||
active.updated_at = Set(now);
|
||||
return Ok(active.update(&self.context.db).await?);
|
||||
}
|
||||
|
||||
Ok(active.insert(&self.context.db).await?)
|
||||
}
|
||||
|
||||
pub async fn unread_counts(
|
||||
&self,
|
||||
channel_ids: &[Uuid],
|
||||
user_id: Uuid,
|
||||
) -> AnyResult<HashMap<Uuid, u64>> {
|
||||
if channel_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let states = channel_user_read_state::Entity::find()
|
||||
.filter(channel_user_read_state::Column::UserId.eq(user_id))
|
||||
.filter(channel_user_read_state::Column::ChannelId.is_in(channel_ids.to_vec()))
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
let cursors: HashMap<Uuid, Option<Uuid>> = states
|
||||
.into_iter()
|
||||
.map(|state| (state.channel_id, state.last_read_message_id))
|
||||
.collect();
|
||||
|
||||
let messages = message::Entity::find()
|
||||
.select_only()
|
||||
.column(message::Column::ChannelId)
|
||||
.column(message::Column::Id)
|
||||
.filter(message::Column::ChannelId.is_in(channel_ids.to_vec()))
|
||||
.into_tuple::<(Uuid, Uuid)>()
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
|
||||
let mut counts = HashMap::new();
|
||||
for (channel_id, message_id) in messages {
|
||||
let unread = match cursors.get(&channel_id) {
|
||||
Some(Some(cursor)) => message_id > *cursor,
|
||||
_ => true,
|
||||
};
|
||||
if unread {
|
||||
*counts.entry(channel_id).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(counts)
|
||||
}
|
||||
|
||||
pub async fn unread_counts_by_server(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
) -> AnyResult<HashMap<Uuid, u64>> {
|
||||
let channels = channel::Entity::find()
|
||||
.select_only()
|
||||
.column(channel::Column::Id)
|
||||
.column(channel::Column::ServerId)
|
||||
.filter(channel::Column::ServerId.is_not_null())
|
||||
.into_tuple::<(Uuid, Option<Uuid>)>()
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
|
||||
let channel_to_server: HashMap<Uuid, Uuid> = channels
|
||||
.into_iter()
|
||||
.filter_map(|(channel_id, server_id)| server_id.map(|server_id| (channel_id, server_id)))
|
||||
.collect();
|
||||
if channel_to_server.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let channel_ids: Vec<Uuid> = channel_to_server.keys().copied().collect();
|
||||
let states = channel_user_read_state::Entity::find()
|
||||
.filter(channel_user_read_state::Column::UserId.eq(user_id))
|
||||
.filter(channel_user_read_state::Column::ChannelId.is_in(channel_ids.clone()))
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
let cursors: HashMap<Uuid, Option<Uuid>> = states
|
||||
.into_iter()
|
||||
.map(|state| (state.channel_id, state.last_read_message_id))
|
||||
.collect();
|
||||
|
||||
let messages = message::Entity::find()
|
||||
.select_only()
|
||||
.column(message::Column::ChannelId)
|
||||
.column(message::Column::Id)
|
||||
.filter(message::Column::ChannelId.is_in(channel_ids))
|
||||
.into_tuple::<(Uuid, Uuid)>()
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
|
||||
let mut counts = HashMap::new();
|
||||
for (channel_id, message_id) in messages {
|
||||
let unread = match cursors.get(&channel_id) {
|
||||
Some(Some(cursor)) => message_id > *cursor,
|
||||
_ => true,
|
||||
};
|
||||
if unread {
|
||||
let server_id = channel_to_server[&channel_id];
|
||||
*counts.entry(server_id).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(counts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
use crate::models::{role, role_user, user};
|
||||
use crate::repositories::{AnyResult, RepositoryContext};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RoleRepository {
|
||||
pub context: Arc<RepositoryContext>,
|
||||
}
|
||||
|
||||
impl RoleRepository {
|
||||
pub async fn get_all_by_server(&self, server_id: Uuid) -> AnyResult<Vec<role::Model>> {
|
||||
Ok(role::Entity::find()
|
||||
.filter(role::Column::ServerId.eq(server_id))
|
||||
.all(&self.context.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn get_default_by_server(&self, server_id: Uuid) -> AnyResult<Option<role::Model>> {
|
||||
Ok(role::Entity::find()
|
||||
.filter(role::Column::ServerId.eq(server_id))
|
||||
.filter(role::Column::IsDefault.eq(true))
|
||||
.one(&self.context.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn add_to_default(&self, user_id: Uuid, server_id: Uuid) -> AnyResult<()> {
|
||||
let default_role = self.get_default_by_server(server_id).await?;
|
||||
if let Some(default_role) = default_role {
|
||||
role_user::ActiveModel {
|
||||
role_id: Set(default_role.id),
|
||||
user_id: Set(user_id),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&self.context.db)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_all(&self) -> AnyResult<Vec<role::Model>> {
|
||||
Ok(role::Entity::find().all(&self.context.db).await?)
|
||||
}
|
||||
|
||||
pub async fn get_by_id(&self, id: Uuid) -> AnyResult<Option<role::Model>> {
|
||||
Ok(role::Entity::find_by_id(id).one(&self.context.db).await?)
|
||||
}
|
||||
|
||||
pub async fn create(&self, active: role::ActiveModel) -> AnyResult<role::Model> {
|
||||
let group = active.insert(&self.context.db).await?;
|
||||
Ok(group)
|
||||
}
|
||||
|
||||
pub async fn update(&self, active: role::ActiveModel) -> AnyResult<role::Model> {
|
||||
let group = active.update(&self.context.db).await?;
|
||||
Ok(group)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: Uuid) -> AnyResult<bool> {
|
||||
let res = role::Entity::delete_by_id(id)
|
||||
.exec(&self.context.db)
|
||||
.await?;
|
||||
Ok(res.rows_affected > 0)
|
||||
}
|
||||
|
||||
pub async fn get_members(&self, role_id: Uuid) -> AnyResult<Vec<user::Model>> {
|
||||
let memberships = role_user::Entity::find()
|
||||
.filter(role_user::Column::RoleId.eq(role_id))
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
|
||||
let mut members = Vec::with_capacity(memberships.len());
|
||||
for membership in memberships {
|
||||
if let Some(user) = user::Entity::find_by_id(membership.user_id)
|
||||
.one(&self.context.db)
|
||||
.await?
|
||||
{
|
||||
members.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(members)
|
||||
}
|
||||
|
||||
pub async fn add_member(&self, role_id: Uuid, user_id: Uuid) -> AnyResult<bool> {
|
||||
if role_user::Entity::find_by_id((role_id, user_id))
|
||||
.one(&self.context.db)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
role_user::ActiveModel {
|
||||
role_id: Set(role_id),
|
||||
user_id: Set(user_id),
|
||||
}
|
||||
.insert(&self.context.db)
|
||||
.await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn remove_member(&self, role_id: Uuid, user_id: Uuid) -> AnyResult<bool> {
|
||||
let result = role_user::Entity::delete_by_id((role_id, user_id))
|
||||
.exec(&self.context.db)
|
||||
.await?;
|
||||
Ok(result.rows_affected > 0)
|
||||
}
|
||||
}
|
||||
+186
-65
@@ -1,9 +1,10 @@
|
||||
use super::types::{ServerExplorerItem, ServerTree};
|
||||
use super::{AnyResult, RepositoryContext};
|
||||
use crate::models::{category, channel, role, server, server_user};
|
||||
use crate::models::{role, server, server_role_permission, server_user, server_user_permission,
|
||||
};
|
||||
use sea_orm::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use sea_orm::{ActiveModelTrait, QuerySelect, Set};
|
||||
|
||||
use sea_orm::sea_query::OnConflict;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -30,7 +31,6 @@ impl ServerRepository {
|
||||
|
||||
pub async fn update(&self, active: server::ActiveModel) -> AnyResult<server::Model> {
|
||||
let server = active.update(&self.context.db).await?;
|
||||
self.context.events.emit("server_updated", server.clone());
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ impl ServerRepository {
|
||||
};
|
||||
default_group.insert(&self.context.db).await?;
|
||||
|
||||
self.context.events.emit("server_created", server.clone());
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
@@ -72,17 +71,31 @@ impl ServerRepository {
|
||||
.insert(&self.context.db)
|
||||
.await?;
|
||||
|
||||
self.context
|
||||
.events
|
||||
.emit("server_user_created", (server_id, user_id));
|
||||
let role_id: Uuid = role::Entity::find()
|
||||
.filter(role::Column::ServerId.eq(server_id))
|
||||
.filter(role::Column::IsDefault.eq(true))
|
||||
.select_only()
|
||||
.column(role::Column::Id)
|
||||
.into_tuple::<Uuid>()
|
||||
.one(&self.context.db)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Rôle par défaut introuvable"))?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn get_user(&self, server_id: Uuid, user_id: Uuid) -> AnyResult<Option<server_user::Model>> {
|
||||
Ok(server_user::Entity::find()
|
||||
.filter(server_user::Column::ServerId.eq(server_id))
|
||||
.filter(server_user::Column::UserId.eq(user_id))
|
||||
.one(&self.context.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: Uuid) -> AnyResult<bool> {
|
||||
let res = server::Entity::delete_by_id(id)
|
||||
.exec(&self.context.db)
|
||||
.await?;
|
||||
self.context.events.emit("server_deleted", id);
|
||||
Ok(res.rows_affected > 0)
|
||||
}
|
||||
|
||||
@@ -90,62 +103,170 @@ impl ServerRepository {
|
||||
let res = server::Entity::find().count(&self.context.db).await?;
|
||||
Ok(res as usize)
|
||||
}
|
||||
|
||||
pub async fn get_user_permission(
|
||||
&self,
|
||||
server_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> AnyResult<Option<server_user_permission::Model>> {
|
||||
Ok(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?)
|
||||
}
|
||||
|
||||
pub async fn get_user_permissions(
|
||||
&self,
|
||||
server_id: Uuid,
|
||||
) -> AnyResult<Vec<server_user_permission::Model>> {
|
||||
Ok(server_user_permission::Entity::find()
|
||||
.filter(server_user_permission::Column::ServerId.eq(server_id))
|
||||
.all(&self.context.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn set_user_permission(
|
||||
&self,
|
||||
server_id: Uuid,
|
||||
user_id: Uuid,
|
||||
permissions: u64,
|
||||
) -> AnyResult<()> {
|
||||
let permission = server_user_permission::ActiveModel {
|
||||
server_id: Set(server_id),
|
||||
user_id: Set(user_id),
|
||||
permissions: Set(permissions as i64),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
server_user_permission::Entity::insert(permission)
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
server_user_permission::Column::ServerId,
|
||||
server_user_permission::Column::UserId,
|
||||
])
|
||||
.update_columns([server_user_permission::Column::Permissions])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&self.context.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_user_permission(&self, server_id: Uuid, user_id: Uuid) -> AnyResult<()> {
|
||||
server_user_permission::Entity::delete_many()
|
||||
.filter(server_user_permission::Column::ServerId.eq(server_id))
|
||||
.filter(server_user_permission::Column::UserId.eq(user_id))
|
||||
.exec(&self.context.db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_role_permission(
|
||||
&self,
|
||||
server_id: Uuid,
|
||||
role_id: Uuid,
|
||||
) -> AnyResult<Option<server_role_permission::Model>> {
|
||||
Ok(server_role_permission::Entity::find()
|
||||
.filter(server_role_permission::Column::ServerId.eq(server_id))
|
||||
.filter(server_role_permission::Column::RoleId.eq(role_id))
|
||||
.one(&self.context.db)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn set_role_permission(
|
||||
&self,
|
||||
server_id: Uuid,
|
||||
role_id: Uuid,
|
||||
permissions: u64,
|
||||
) -> AnyResult<()> {
|
||||
let permission = server_role_permission::ActiveModel {
|
||||
server_id: Set(server_id),
|
||||
role_id: Set(role_id),
|
||||
permissions: Set(permissions as i64),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = server_role_permission::Entity::insert(permission)
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
server_role_permission::Column::ServerId,
|
||||
server_role_permission::Column::RoleId,
|
||||
])
|
||||
.update_columns([server_role_permission::Column::Permissions])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec(&self.context.db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_role_permission(&self, server_id: Uuid, role_id: Uuid) -> AnyResult<()> {
|
||||
server_role_permission::Entity::delete_many()
|
||||
.filter(server_role_permission::Column::ServerId.eq(server_id))
|
||||
.filter(server_role_permission::Column::RoleId.eq(role_id))
|
||||
.exec(&self.context.db)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
impl ServerRepository {
|
||||
pub async fn get_tree(&self, server_id: Uuid) -> AnyResult<ServerTree> {
|
||||
// 1. Récupération des catégories avec leurs channels
|
||||
let categories_with_channels = category::Entity::find()
|
||||
.filter(category::Column::ServerId.eq(server_id))
|
||||
.find_with_related(channel::Entity)
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
|
||||
// 2. Récupération des channels orphelins (sans catégorie)
|
||||
let orphan_channels = channel::Entity::find()
|
||||
.filter(channel::Column::ServerId.eq(server_id))
|
||||
.filter(channel::Column::CategoryId.is_null())
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
|
||||
// 3. Transformation et tri des enfants
|
||||
let mut items: Vec<ServerExplorerItem> = Vec::new();
|
||||
|
||||
for (cat, mut channels) in categories_with_channels {
|
||||
// On trie les channels internes (obligatoire car SQL ne garantit aucun ordre ici)
|
||||
channels.sort_by(|a, b| {
|
||||
a.position
|
||||
.cmp(&b.position)
|
||||
.then(a.created_at.cmp(&b.created_at))
|
||||
});
|
||||
items.push(ServerExplorerItem::Category(cat, channels));
|
||||
}
|
||||
|
||||
for chan in orphan_channels {
|
||||
items.push(ServerExplorerItem::Channel(chan));
|
||||
}
|
||||
|
||||
// 4. Tri final de la liste globale (Mélange catégories et orphelins)
|
||||
items.sort_by(|a, b| {
|
||||
let pos_cmp = a.position().cmp(&b.position());
|
||||
|
||||
if pos_cmp == std::cmp::Ordering::Equal {
|
||||
// Départage par date si position identique
|
||||
let date_a = match a {
|
||||
ServerExplorerItem::Category(c, _) => c.created_at,
|
||||
ServerExplorerItem::Channel(c) => c.created_at,
|
||||
};
|
||||
let date_b = match b {
|
||||
ServerExplorerItem::Category(c, _) => c.created_at,
|
||||
ServerExplorerItem::Channel(c) => c.created_at,
|
||||
};
|
||||
date_a.cmp(&date_b)
|
||||
} else {
|
||||
pos_cmp
|
||||
}
|
||||
});
|
||||
|
||||
Ok(ServerTree { items })
|
||||
}
|
||||
}
|
||||
// impl ServerRepository {
|
||||
// pub async fn get_tree(&self, server_id: Uuid) -> AnyResult<ServerTree> {
|
||||
// // 1. Récupération des catégories avec leurs channels
|
||||
// let categories_with_channels = category::Entity::find()
|
||||
// .filter(category::Column::ServerId.eq(server_id))
|
||||
// .find_with_related(channel::Entity)
|
||||
// .all(&self.context.db)
|
||||
// .await?;
|
||||
//
|
||||
// // 2. Récupération des channels orphelins (sans catégorie)
|
||||
// let orphan_channels = channel::Entity::find()
|
||||
// .filter(channel::Column::ServerId.eq(server_id))
|
||||
// .filter(channel::Column::CategoryId.is_null())
|
||||
// .all(&self.context.db)
|
||||
// .await?;
|
||||
//
|
||||
// // 3. Transformation et tri des enfants
|
||||
// let mut items: Vec<ServerExplorerItem> = Vec::new();
|
||||
//
|
||||
// for (cat, mut channels) in categories_with_channels {
|
||||
// // On trie les channels internes (obligatoire car SQL ne garantit aucun ordre ici)
|
||||
// channels.sort_by(|a, b| {
|
||||
// a.position
|
||||
// .cmp(&b.position)
|
||||
// .then(a.created_at.cmp(&b.created_at))
|
||||
// });
|
||||
// items.push(ServerExplorerItem::Category(cat, channels));
|
||||
// }
|
||||
//
|
||||
// for chan in orphan_channels {
|
||||
// items.push(ServerExplorerItem::Channel(chan));
|
||||
// }
|
||||
//
|
||||
// // 4. Tri final de la liste globale (Mélange catégories et orphelins)
|
||||
// items.sort_by(|a, b| {
|
||||
// let pos_cmp = a.position().cmp(&b.position());
|
||||
//
|
||||
// if pos_cmp == std::cmp::Ordering::Equal {
|
||||
// // Départage par date si position identique
|
||||
// let date_a = match a {
|
||||
// ServerExplorerItem::Category(c, _) => c.created_at,
|
||||
// ServerExplorerItem::Channel(c) => c.created_at,
|
||||
// };
|
||||
// let date_b = match b {
|
||||
// ServerExplorerItem::Category(c, _) => c.created_at,
|
||||
// ServerExplorerItem::Channel(c) => c.created_at,
|
||||
// };
|
||||
// date_a.cmp(&date_b)
|
||||
// } else {
|
||||
// pos_cmp
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// Ok(ServerTree { items })
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
use crate::models::server_item_order;
|
||||
use crate::repositories::{AnyResult, RepositoryContext};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ServerItemOrderRepository {
|
||||
pub context: Arc<RepositoryContext>,
|
||||
}
|
||||
|
||||
impl ServerItemOrderRepository {
|
||||
/// Récupère la liste ordonnée pour un serveur (racine ou spécifique à une catégorie)
|
||||
pub async fn get_by_server(&self, server_id: Uuid) -> AnyResult<Vec<server_item_order::Model>> {
|
||||
Ok(server_item_order::Entity::find()
|
||||
.filter(server_item_order::Column::ServerId.eq(server_id))
|
||||
.order_by_asc(server_item_order::Column::ParentCategoryId)
|
||||
.order_by_asc(server_item_order::Column::OrderKey)
|
||||
.all(&self.context.db)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use crate::models::{category, channel, channel_user_read_state, computed_permission, message, server_item_order};
|
||||
use crate::permissions::ChannelPermission;
|
||||
use crate::repositories::types::{CategoryWithPermissions, ChannelWithPermissions, ServerTreeData};
|
||||
use crate::repositories::{AnyResult, RepositoryContext};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ServerTreeRepository {
|
||||
pub context: Arc<RepositoryContext>,
|
||||
}
|
||||
|
||||
impl ServerTreeRepository {
|
||||
pub async fn get_for_user(&self, server_id: Uuid, user_id: Uuid) -> AnyResult<ServerTreeData> {
|
||||
let (orders, categories_models, channel_models, computed_permissions) = tokio::try_join!(
|
||||
async {
|
||||
Ok::<_, anyhow::Error>(
|
||||
server_item_order::Entity::find()
|
||||
.filter(server_item_order::Column::ServerId.eq(server_id))
|
||||
.order_by_asc(server_item_order::Column::ParentCategoryId)
|
||||
.order_by_asc(server_item_order::Column::OrderKey)
|
||||
.all(&self.context.db)
|
||||
.await?,
|
||||
)
|
||||
},
|
||||
async {
|
||||
Ok::<_, anyhow::Error>(
|
||||
category::Entity::find()
|
||||
.filter(category::Column::ServerId.eq(server_id))
|
||||
.all(&self.context.db)
|
||||
.await?,
|
||||
)
|
||||
},
|
||||
async {
|
||||
Ok::<_, anyhow::Error>(
|
||||
channel::Entity::find()
|
||||
.filter(channel::Column::ServerId.eq(server_id))
|
||||
.all(&self.context.db)
|
||||
.await?,
|
||||
)
|
||||
},
|
||||
async {
|
||||
Ok::<_, anyhow::Error>(
|
||||
computed_permission::Entity::find()
|
||||
.filter(computed_permission::Column::UserId.eq(user_id))
|
||||
.filter(computed_permission::Column::ServerId.eq(server_id))
|
||||
.all(&self.context.db)
|
||||
.await?,
|
||||
)
|
||||
},
|
||||
)?;
|
||||
|
||||
let perm_map: HashMap<Uuid, u64> = computed_permissions
|
||||
.into_iter()
|
||||
.map(|cp| (cp.resource_id, cp.permissions as u64))
|
||||
.collect();
|
||||
|
||||
let categories = categories_models
|
||||
.into_iter()
|
||||
.map(|category| {
|
||||
let permissions = perm_map
|
||||
.get(&category.id)
|
||||
.map(|&p| ChannelPermission::from_bits_retain(p));
|
||||
CategoryWithPermissions {
|
||||
category,
|
||||
permissions,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let channel_ids: Vec<Uuid> = channel_models.iter().map(|channel| channel.id).collect();
|
||||
let read_states = channel_user_read_state::Entity::find()
|
||||
.filter(channel_user_read_state::Column::UserId.eq(user_id))
|
||||
.filter(channel_user_read_state::Column::ChannelId.is_in(channel_ids.clone()))
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
let cursors: HashMap<Uuid, Option<Uuid>> = read_states
|
||||
.into_iter()
|
||||
.map(|state| (state.channel_id, state.last_read_message_id))
|
||||
.collect();
|
||||
let messages = message::Entity::find()
|
||||
.select_only()
|
||||
.column(message::Column::ChannelId)
|
||||
.column(message::Column::Id)
|
||||
.filter(message::Column::ChannelId.is_in(channel_ids))
|
||||
.into_tuple::<(Uuid, Uuid)>()
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
let mut unread_counts = HashMap::new();
|
||||
for (channel_id, message_id) in messages {
|
||||
let unread = match cursors.get(&channel_id) {
|
||||
Some(Some(cursor)) => message_id > *cursor,
|
||||
_ => true,
|
||||
};
|
||||
if unread {
|
||||
*unread_counts.entry(channel_id).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let channels = channel_models
|
||||
.into_iter()
|
||||
.map(|channel| {
|
||||
let permissions = perm_map
|
||||
.get(&channel.id)
|
||||
.map(|&p| ChannelPermission::from_bits_retain(p));
|
||||
ChannelWithPermissions {
|
||||
channel,
|
||||
permissions,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(ServerTreeData {
|
||||
orders,
|
||||
categories,
|
||||
channels,
|
||||
unread_counts,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::models::{category, channel};
|
||||
use crate::models::{category, channel, computed_permission, server_item_order};
|
||||
use crate::permissions::ChannelPermission;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub enum ServerExplorerItem {
|
||||
Category(category::Model, Vec<channel::Model>),
|
||||
@@ -6,14 +8,14 @@ pub enum ServerExplorerItem {
|
||||
}
|
||||
|
||||
// Pour pouvoir trier facilement
|
||||
impl ServerExplorerItem {
|
||||
pub fn position(&self) -> i32 {
|
||||
match self {
|
||||
ServerExplorerItem::Category(cat, _) => cat.position,
|
||||
ServerExplorerItem::Channel(chan) => chan.position,
|
||||
}
|
||||
}
|
||||
}
|
||||
// impl ServerExplorerItem {
|
||||
// pub fn position(&self) -> i32 {
|
||||
// match self {
|
||||
// ServerExplorerItem::Category(cat, _) => cat.position,
|
||||
// ServerExplorerItem::Channel(chan) => chan.position,
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
pub struct ServerTree {
|
||||
pub items: Vec<ServerExplorerItem>,
|
||||
@@ -22,5 +24,57 @@ pub struct ServerTree {
|
||||
pub struct MessageFilter {
|
||||
pub channel_id: Option<uuid::Uuid>,
|
||||
pub before_id: Option<uuid::Uuid>,
|
||||
pub after_id: Option<uuid::Uuid>,
|
||||
pub limit: Option<u64>,
|
||||
}
|
||||
|
||||
pub struct ChannelFilter {
|
||||
pub server_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
pub struct UserFilter {
|
||||
pub server_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PermissionResource {
|
||||
Server(Uuid),
|
||||
Category(Uuid),
|
||||
Channel(Uuid),
|
||||
}
|
||||
|
||||
impl PermissionResource {
|
||||
pub fn scope_type(self) -> computed_permission::PermissionScopeType {
|
||||
match self {
|
||||
Self::Server(_) => computed_permission::PermissionScopeType::Server,
|
||||
Self::Category(_) => computed_permission::PermissionScopeType::Category,
|
||||
Self::Channel(_) => computed_permission::PermissionScopeType::Channel,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resource_id(self) -> Uuid {
|
||||
match self {
|
||||
Self::Server(id) | Self::Category(id) | Self::Channel(id) => id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CategoryWithPermissions {
|
||||
pub category: category::Model,
|
||||
pub permissions: Option<ChannelPermission>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelWithPermissions {
|
||||
pub channel: channel::Model,
|
||||
pub permissions: Option<ChannelPermission>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerTreeData {
|
||||
pub orders: Vec<server_item_order::Model>,
|
||||
pub categories: Vec<CategoryWithPermissions>,
|
||||
pub channels: Vec<ChannelWithPermissions>,
|
||||
pub unread_counts: std::collections::HashMap<Uuid, u64>,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use crate::auth::password;
|
||||
use crate::models::user;
|
||||
use crate::models::{server_user, user};
|
||||
use crate::repositories::types::UserFilter;
|
||||
use crate::repositories::{AnyResult, RepositoryContext};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, Set,
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, JoinType, PaginatorTrait,
|
||||
QueryFilter, QuerySelect, RelationTrait, Set,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -16,6 +18,17 @@ impl UserRepository {
|
||||
Ok(user::Entity::find().all(&self.context.db).await?)
|
||||
}
|
||||
|
||||
pub async fn filter(&self, filter: UserFilter) -> AnyResult<Vec<user::Model>> {
|
||||
let mut query = user::Entity::find();
|
||||
if let Some(s_id) = filter.server_id {
|
||||
query = query
|
||||
.join(JoinType::InnerJoin, user::Relation::ServerUser.def())
|
||||
.filter(server_user::Column::ServerId.eq(s_id))
|
||||
.distinct();
|
||||
}
|
||||
Ok(query.all(&self.context.db).await?)
|
||||
}
|
||||
|
||||
pub async fn count(&self) -> AnyResult<u64> {
|
||||
Ok(user::Entity::find().count(&self.context.db).await?)
|
||||
}
|
||||
@@ -53,13 +66,11 @@ impl UserRepository {
|
||||
|
||||
pub async fn update(&self, active: user::ActiveModel) -> AnyResult<user::Model> {
|
||||
let user = active.update(&self.context.db).await?;
|
||||
self.context.events.emit("user_updated", user.clone());
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub async fn create(&self, active: user::ActiveModel) -> AnyResult<user::Model> {
|
||||
let user = active.insert(&self.context.db).await?;
|
||||
self.context.events.emit("user_created", user.clone());
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
@@ -80,9 +91,8 @@ impl UserRepository {
|
||||
|
||||
active.password = Set(password);
|
||||
|
||||
let user = self.update(active).await?;
|
||||
let _user = self.update(active).await?;
|
||||
|
||||
self.context.events.emit("user_changed", user);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -91,9 +101,6 @@ impl UserRepository {
|
||||
.exec(&self.context.db)
|
||||
.await?;
|
||||
let deleted = result.rows_affected > 0;
|
||||
if deleted {
|
||||
self.context.events.emit("user_deleted", id);
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::{domain::Attachment, dto::AttachmentResponse};
|
||||
use super::domain::Attachment;
|
||||
use crate::domain::dto::attachment::AttachmentResponse;
|
||||
|
||||
pub fn to_response(_item: Attachment) -> AttachmentResponse {
|
||||
todo!()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod domain;
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod mapper;
|
||||
pub mod routes;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::dto::{LoginRequest, LoginResponse, MeResponse};
|
||||
use crate::domain::dto::auth::{LoginRequest, LoginResponse, MeResponse};
|
||||
use crate::auth::token::create_jwt;
|
||||
use crate::core::AppState;
|
||||
use crate::http::context::CurrentUser;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod domain;
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod mapper;
|
||||
pub mod routes;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::core::state::AppState;
|
||||
use crate::http::context::Superuser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::routes::category::dto::{
|
||||
CategoryResponse, CreateCategoryRequest, UpdateCategoryRequest,
|
||||
use crate::domain::dto::category::{
|
||||
CategoryQueryParams, CategoryResponse, CreateCategoryRequest, UpdateCategoryRequest,
|
||||
};
|
||||
use crate::routes::category::mapper;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
@@ -20,12 +20,16 @@ use uuid::Uuid;
|
||||
(status = 200, description = "Liste des catégories récupérée avec succès", body = [CategoryResponse]),
|
||||
(status = 500, description = "Erreur interne du serveur")
|
||||
),
|
||||
params(
|
||||
CategoryQueryParams
|
||||
),
|
||||
tag = "Categories"
|
||||
)]
|
||||
pub async fn get_all(
|
||||
State(state): State<AppState>,
|
||||
Query(filters): Query<CategoryQueryParams>,
|
||||
) -> Result<Json<Vec<CategoryResponse>>, HTTPError> {
|
||||
let categories = state.repositories.category.get_all().await?;
|
||||
let categories = state.repositories.category.filter(filters.server_id).await?;
|
||||
Ok(Json(
|
||||
categories
|
||||
.into_iter()
|
||||
@@ -90,8 +94,7 @@ pub async fn create(
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
|
||||
|
||||
let active_model = mapper::create_request_to_am(payload);
|
||||
let category = state.repositories.category.create(active_model).await?;
|
||||
let category = state.services.category.create_category(payload.server_id, payload.name).await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(mapper::category_model_to_category_response(category)),
|
||||
@@ -123,15 +126,14 @@ pub async fn update(
|
||||
Json(payload): Json<UpdateCategoryRequest>,
|
||||
) -> Result<Json<CategoryResponse>, HTTPError> {
|
||||
// Vérifier l'existence
|
||||
let category = state
|
||||
let _category = state
|
||||
.repositories
|
||||
.category
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
let active_model = mapper::update_request_to_am(category.id, category.server_id, payload);
|
||||
let category = state.repositories.category.update(active_model).await?;
|
||||
let category = state.services.category.update_category(id, payload.name).await?;
|
||||
|
||||
Ok(Json(mapper::category_model_to_category_response(category)))
|
||||
}
|
||||
@@ -158,7 +160,7 @@ pub async fn delete(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
if state.repositories.category.delete(id).await? {
|
||||
if state.services.category.delete_category(id).await? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(HTTPError::NotFound)
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
use crate::models::category;
|
||||
use crate::routes::category::dto::{
|
||||
use crate::domain::dto::category::{
|
||||
CategoryResponse, CreateCategoryRequest, UpdateCategoryRequest,
|
||||
};
|
||||
use crate::models::category;
|
||||
use sea_orm::Set;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn category_model_to_category_response(model: category::Model) -> CategoryResponse {
|
||||
category_model_to_category_response_with_permission(model, None)
|
||||
}
|
||||
|
||||
pub fn category_model_to_category_response_with_permission(
|
||||
model: category::Model,
|
||||
permission: Option<u64>,
|
||||
) -> CategoryResponse {
|
||||
CategoryResponse {
|
||||
id: model.id,
|
||||
server_id: model.server_id,
|
||||
name: model.name,
|
||||
position: model.position,
|
||||
created_at: model.created_at,
|
||||
updated_at: model.updated_at,
|
||||
permission,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +28,6 @@ pub fn create_request_to_am(req: CreateCategoryRequest) -> category::ActiveModel
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(req.server_id),
|
||||
name: Set(req.name),
|
||||
position: Set(req.position),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -35,7 +41,6 @@ pub fn update_request_to_am(
|
||||
id: Set(id),
|
||||
server_id: Set(server_id),
|
||||
name: Set(req.name),
|
||||
position: Set(req.position),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod domain;
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
pub mod mapper;
|
||||
pub mod routes;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
use crate::models::channel::ChannelType;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct CreateChannelRequest {
|
||||
pub server_id: Option<Uuid>,
|
||||
pub category_id: Option<Uuid>,
|
||||
#[serde(default)]
|
||||
pub position: i32,
|
||||
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 position: i32,
|
||||
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 position: i32,
|
||||
pub channel_type: ChannelType,
|
||||
pub name: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
+337
-10
@@ -1,12 +1,17 @@
|
||||
use crate::core::state::AppState;
|
||||
use crate::http::context::Superuser;
|
||||
use crate::http::context::{CurrentUser, Superuser};
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::routes::channel::dto::{ChannelResponse, CreateChannelRequest, UpdateChannelRequest};
|
||||
use crate::domain::dto::channel::{
|
||||
ChannelQueryParams, ChannelResponse, ChannelPermissionsResponse, ChannelRolePermissionResponse,
|
||||
ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse,
|
||||
SetChannelPermissionRequest, SetReadStateRequest,
|
||||
UpdateChannelRequest,
|
||||
};
|
||||
use crate::routes::channel::mapper;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -18,12 +23,17 @@ use uuid::Uuid;
|
||||
(status = 200, description = "Liste des channels récupérée avec succès", body = [ChannelResponse]),
|
||||
(status = 500, description = "Erreur interne du serveur")
|
||||
),
|
||||
params(
|
||||
ChannelQueryParams
|
||||
),
|
||||
tag = "Channels"
|
||||
)]
|
||||
pub async fn get_all(
|
||||
State(state): State<AppState>,
|
||||
Query(filters): Query<ChannelQueryParams>,
|
||||
) -> Result<Json<Vec<ChannelResponse>>, HTTPError> {
|
||||
let channels = state.repositories.channel.get_all().await?;
|
||||
let params = mapper::query_params_to_channel_filter(filters);
|
||||
let channels = state.repositories.channel.filter(params).await?;
|
||||
Ok(Json(
|
||||
channels
|
||||
.into_iter()
|
||||
@@ -32,6 +42,91 @@ pub async fn get_all(
|
||||
))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/channels/{channel_id}/read-state",
|
||||
params(("channel_id" = Uuid, Path, description = "ID du canal")),
|
||||
responses((status = 200, body = ReadStateResponse), (status = 404, description = "Canal non trouvé")),
|
||||
tag = "Channels",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn get_read_state(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<Json<ReadStateResponse>, HTTPError> {
|
||||
state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?;
|
||||
let read_state = state.repositories.read_state.get(channel_id, user.id).await?;
|
||||
let unread_count = state
|
||||
.repositories
|
||||
.read_state
|
||||
.unread_counts(&[channel_id], user.id)
|
||||
.await?
|
||||
.get(&channel_id)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(Json(ReadStateResponse {
|
||||
channel_id,
|
||||
last_read_message_id: read_state.as_ref().and_then(|value| value.last_read_message_id),
|
||||
updated_at: read_state.map(|value| value.updated_at),
|
||||
unread_count,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/channels/{channel_id}/read-state",
|
||||
request_body = SetReadStateRequest,
|
||||
params(("channel_id" = Uuid, Path, description = "ID du canal")),
|
||||
responses((status = 200, body = ReadStateResponse), (status = 400, description = "Message invalide"), (status = 404, description = "Canal non trouvé")),
|
||||
tag = "Channels",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn set_read_state(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
Json(payload): Json<SetReadStateRequest>,
|
||||
) -> Result<Json<ReadStateResponse>, HTTPError> {
|
||||
state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
if let Some(message_id) = payload.last_read_message_id {
|
||||
let message = state
|
||||
.repositories
|
||||
.message
|
||||
.get_by_id(message_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Message not found".to_string()))?;
|
||||
if message.channel_id != channel_id {
|
||||
return Err(HTTPError::BadRequest(
|
||||
"Message does not belong to this channel".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let read_state = state
|
||||
.repositories
|
||||
.read_state
|
||||
.set(channel_id, user.id, payload.last_read_message_id)
|
||||
.await?;
|
||||
let unread_count = state
|
||||
.repositories
|
||||
.read_state
|
||||
.unread_counts(&[channel_id], user.id)
|
||||
.await?
|
||||
.get(&channel_id)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(Json(ReadStateResponse {
|
||||
channel_id,
|
||||
last_read_message_id: read_state.last_read_message_id,
|
||||
updated_at: Some(read_state.updated_at),
|
||||
unread_count,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Récupère un channel par son ID
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -60,6 +155,26 @@ pub async fn get_by_id(
|
||||
Ok(Json(mapper::channel_model_to_channel_response(channel)))
|
||||
}
|
||||
|
||||
/// Liste les permissions directes configurées pour un canal.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/channels/{channel_id}/permissions",
|
||||
params(("channel_id" = Uuid, Path, description = "ID du canal")),
|
||||
responses((status = 200, body = ChannelPermissionsResponse), (status = 404, description = "Canal non trouvé")),
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn list_permissions(
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<Json<ChannelPermissionsResponse>, HTTPError> {
|
||||
state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?;
|
||||
let (users, roles) = tokio::try_join!(
|
||||
state.repositories.channel.list_user_permissions(channel_id),
|
||||
state.repositories.channel.list_role_permissions(channel_id),
|
||||
)?;
|
||||
Ok(Json(mapper::channel_permissions_to_response(users, roles)))
|
||||
}
|
||||
|
||||
/// Crée un nouveau channel
|
||||
#[utoipa::path(
|
||||
post,
|
||||
@@ -100,8 +215,7 @@ pub async fn create(
|
||||
.ok_or(HTTPError::BadRequest("Category not found".to_string()))?;
|
||||
}
|
||||
|
||||
let active_model = mapper::create_request_to_am(payload);
|
||||
let channel = state.repositories.channel.create(active_model).await?;
|
||||
let channel = state.services.channel.create_channel(payload).await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(mapper::channel_model_to_channel_response(channel)),
|
||||
@@ -161,8 +275,7 @@ pub async fn update(
|
||||
.ok_or(HTTPError::BadRequest("Category not found".to_string()))?;
|
||||
}
|
||||
|
||||
let active_model = mapper::update_request_to_am(id, payload);
|
||||
let channel = state.repositories.channel.update(active_model).await?;
|
||||
let channel = state.services.channel.update_channel(id, payload).await?;
|
||||
|
||||
Ok(Json(mapper::channel_model_to_channel_response(channel)))
|
||||
}
|
||||
@@ -189,9 +302,223 @@ pub async fn delete(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
if state.repositories.channel.delete(id).await? {
|
||||
if state.services.channel.delete_channel(id).await? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(HTTPError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère les permissions directes d'un utilisateur dans un canal.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/channels/{channel_id}/permissions/users/{user_id}",
|
||||
params(
|
||||
("channel_id" = Uuid, Path, description = "ID du canal"),
|
||||
("user_id" = Uuid, Path, description = "ID de l'utilisateur")
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = ChannelUserPermissionResponse),
|
||||
(status = 404, description = "Permission introuvable"),
|
||||
(status = 500, description = "Erreur interne du serveur")
|
||||
),
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn get_user_permission(
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> {
|
||||
let permission = state
|
||||
.repositories
|
||||
.channel
|
||||
.get_user_permission(channel_id, user_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
Ok(Json(mapper::channel_user_permission_to_response(
|
||||
permission,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Définit ou remplace les permissions directes d'un utilisateur dans un canal.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/channels/{channel_id}/permissions/users/{user_id}",
|
||||
request_body = SetChannelPermissionRequest,
|
||||
params(
|
||||
("channel_id" = Uuid, Path, description = "ID du canal"),
|
||||
("user_id" = Uuid, Path, description = "ID de l'utilisateur")
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = ChannelUserPermissionResponse),
|
||||
(status = 500, description = "Erreur interne du serveur")
|
||||
),
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn set_user_permission(
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
Json(payload): Json<SetChannelPermissionRequest>,
|
||||
) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> {
|
||||
state
|
||||
.services
|
||||
.channel
|
||||
.set_user_permission(channel_id, user_id, payload.permissions)
|
||||
.await?;
|
||||
|
||||
let permission = state
|
||||
.repositories
|
||||
.channel
|
||||
.get_user_permission(channel_id, user_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
Ok(Json(mapper::channel_user_permission_to_response(
|
||||
permission,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Supprime les permissions directes d'un utilisateur dans un canal.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/channels/{channel_id}/permissions/users/{user_id}",
|
||||
params(
|
||||
("channel_id" = Uuid, Path, description = "ID du canal"),
|
||||
("user_id" = Uuid, Path, description = "ID de l'utilisateur")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Permission supprimée"),
|
||||
(status = 404, description = "Permission introuvable"),
|
||||
(status = 500, description = "Erreur interne du serveur")
|
||||
),
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn remove_user_permission(
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
if state
|
||||
.repositories
|
||||
.channel
|
||||
.get_user_permission(channel_id, user_id)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(HTTPError::NotFound);
|
||||
}
|
||||
|
||||
state
|
||||
.services
|
||||
.channel
|
||||
.remove_user_permission(channel_id, user_id)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Récupère les permissions d'un rôle dans un canal.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/channels/{channel_id}/permissions/roles/{role_id}",
|
||||
params(
|
||||
("channel_id" = Uuid, Path, description = "ID du canal"),
|
||||
("role_id" = Uuid, Path, description = "ID du rôle")
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = ChannelRolePermissionResponse),
|
||||
(status = 404, description = "Permission introuvable"),
|
||||
(status = 500, description = "Erreur interne du serveur")
|
||||
),
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn get_role_permission(
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> {
|
||||
let permission = state
|
||||
.repositories
|
||||
.channel
|
||||
.get_role_permission(channel_id, role_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
Ok(Json(mapper::channel_role_permission_to_response(
|
||||
permission,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Définit ou remplace les permissions d'un rôle dans un canal.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/channels/{channel_id}/permissions/roles/{role_id}",
|
||||
request_body = SetChannelPermissionRequest,
|
||||
params(
|
||||
("channel_id" = Uuid, Path, description = "ID du canal"),
|
||||
("role_id" = Uuid, Path, description = "ID du rôle")
|
||||
),
|
||||
responses(
|
||||
(status = 200, body = ChannelRolePermissionResponse),
|
||||
(status = 500, description = "Erreur interne du serveur")
|
||||
),
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn set_role_permission(
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
|
||||
Json(payload): Json<SetChannelPermissionRequest>,
|
||||
) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> {
|
||||
state
|
||||
.services
|
||||
.channel
|
||||
.set_role_permission(channel_id, role_id, payload.permissions)
|
||||
.await?;
|
||||
|
||||
let permission = state
|
||||
.repositories
|
||||
.channel
|
||||
.get_role_permission(channel_id, role_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
Ok(Json(mapper::channel_role_permission_to_response(
|
||||
permission,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Supprime les permissions d'un rôle dans un canal.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/channels/{channel_id}/permissions/roles/{role_id}",
|
||||
params(
|
||||
("channel_id" = Uuid, Path, description = "ID du canal"),
|
||||
("role_id" = Uuid, Path, description = "ID du rôle")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Permission supprimée"),
|
||||
(status = 404, description = "Permission introuvable"),
|
||||
(status = 500, description = "Erreur interne du serveur")
|
||||
),
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn remove_role_permission(
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
if state
|
||||
.repositories
|
||||
.channel
|
||||
.get_role_permission(channel_id, role_id)
|
||||
.await?
|
||||
.is_none()
|
||||
{
|
||||
return Err(HTTPError::NotFound);
|
||||
}
|
||||
|
||||
state
|
||||
.services
|
||||
.channel
|
||||
.remove_role_permission(channel_id, role_id)
|
||||
.await?;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
use crate::models::channel;
|
||||
use crate::routes::channel::dto::{ChannelResponse, CreateChannelRequest, UpdateChannelRequest};
|
||||
use crate::domain::dto::channel::{
|
||||
ChannelPermissionsResponse, ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
|
||||
ChannelUserPermissionResponse, CreateChannelRequest, UpdateChannelRequest,
|
||||
};
|
||||
use crate::models::{channel, channel_role_permission, channel_user_permission};
|
||||
use crate::repositories::types::ChannelFilter;
|
||||
use sea_orm::Set;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn channel_model_to_channel_response(model: channel::Model) -> ChannelResponse {
|
||||
channel_model_to_channel_response_with_permission(model, None)
|
||||
}
|
||||
|
||||
pub fn channel_model_to_channel_response_with_permission(
|
||||
model: channel::Model,
|
||||
permission: Option<u64>,
|
||||
) -> ChannelResponse {
|
||||
ChannelResponse {
|
||||
id: model.id,
|
||||
server_id: model.server_id,
|
||||
category_id: model.category_id,
|
||||
position: model.position,
|
||||
channel_type: model.channel_type,
|
||||
name: model.name,
|
||||
created_at: model.created_at,
|
||||
updated_at: model.updated_at,
|
||||
unread_count: None,
|
||||
permission,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +33,6 @@ pub fn create_request_to_am(req: CreateChannelRequest) -> channel::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(req.server_id),
|
||||
category_id: Set(req.category_id),
|
||||
position: Set(req.position),
|
||||
channel_type: Set(req.channel_type),
|
||||
name: Set(req.name),
|
||||
..Default::default()
|
||||
@@ -33,9 +44,46 @@ pub fn update_request_to_am(id: Uuid, req: UpdateChannelRequest) -> channel::Act
|
||||
id: Set(id),
|
||||
server_id: Set(req.server_id),
|
||||
category_id: Set(req.category_id),
|
||||
position: Set(req.position),
|
||||
channel_type: Set(req.channel_type),
|
||||
name: Set(req.name),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn channel_user_permission_to_response(
|
||||
model: channel_user_permission::Model,
|
||||
) -> ChannelUserPermissionResponse {
|
||||
ChannelUserPermissionResponse {
|
||||
id: model.id,
|
||||
channel_id: model.channel_id,
|
||||
user_id: model.user_id,
|
||||
permissions: model.permissions as u64,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn channel_role_permission_to_response(
|
||||
model: channel_role_permission::Model,
|
||||
) -> ChannelRolePermissionResponse {
|
||||
ChannelRolePermissionResponse {
|
||||
id: model.id,
|
||||
channel_id: model.channel_id,
|
||||
role_id: model.role_id,
|
||||
permissions: model.permissions as u64,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn channel_permissions_to_response(
|
||||
users: Vec<channel_user_permission::Model>,
|
||||
roles: Vec<channel_role_permission::Model>,
|
||||
) -> ChannelPermissionsResponse {
|
||||
ChannelPermissionsResponse {
|
||||
users: users.into_iter().map(channel_user_permission_to_response).collect(),
|
||||
roles: roles.into_iter().map(channel_role_permission_to_response).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query_params_to_channel_filter(params: ChannelQueryParams) -> ChannelFilter {
|
||||
ChannelFilter {
|
||||
server_id: params.server_id,
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user