Compare commits

...
12 Commits
Author SHA1 Message Date
Nell e6d6968e52 init 2026-08-09 20:07:00 +02:00
Nell 20beea24d5 init 2026-08-09 10:30:54 +02:00
Nell 93800e8460 init 2026-08-08 20:55:01 +02:00
Nell 8f3fd6a127 init 2026-08-08 20:19:33 +02:00
Nell 42ab990f7d init 2026-08-08 19:56:57 +02:00
Nell d1f9234457 init 2026-08-08 14:57:04 +02:00
Nell 0d8c86af16 init 2026-08-08 14:17:24 +02:00
Nell e9fe51363f init 2026-08-02 16:36:26 +02:00
Nell c10925b84b init 2026-08-02 13:11:11 +02:00
Nell bb4e17ba2f init 2026-08-02 12:07:51 +02:00
Nell aa486be6e5 init 2026-08-02 11:09:50 +02:00
Nell 659fd0f304 init 2026-08-02 10:34:18 +02:00
57 changed files with 2938 additions and 463 deletions
+9 -2
View File
@@ -1,7 +1,14 @@
<template> <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> </template>
<script lang="ts" setup> <script lang="ts" setup>
// import {useNotificationStore} from "@/stores/notification.ts";
const notification = useNotificationStore();
</script> </script>
@@ -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,230 @@
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {storeToRefs} from 'pinia'
import {useServerStore} from '@/stores/server'
import {useRoleStore} from '@/stores/role'
import {useUserStore} from '@/stores/user'
import {usePermissions} from '@/composables/usePermissions'
import {toServerPermissionMask, type ServerPermissionMask, type ServerUserPermission} from '@/types/permissions'
import ServerPermissionEditor from '@/components/permissions/ServerPermissionEditor.vue'
const props = defineProps<{modelValue: boolean; serverId: string; serverName: string}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const serverStore = useServerStore()
const roleStore = useRoleStore()
const userStore = useUserStore()
const permissionsApi = usePermissions()
const {roles} = storeToRefs(roleStore)
const activeTab = ref('general')
const selectedRoleId = ref<string | null>(null)
const selectedUserId = ref<string | null>(null)
const name = ref('')
const loading = ref(false)
const saving = ref(false)
const error = ref<string | null>(null)
const roleForm = ref('')
const rolePermissions = ref<ServerPermissionMask>(toServerPermissionMask(0))
const memberPermissions = ref<ServerPermissionMask>(toServerPermissionMask(0))
const userPermissions = ref<Record<string, ServerUserPermission>>({})
const selectedRole = computed(() => roles.value.find(role => role.id === selectedRoleId.value) || null)
const selectedMembers = computed(() => selectedRoleId.value ? roleStore.members[selectedRoleId.value] || [] : [])
const availableUsers = computed(() => userStore.users.filter(user => !selectedMembers.value.some(member => member.id === user.id)))
const memberToAdd = ref<string | null>(null)
const selectedUser = computed(() => userStore.users.find(user => user.id === selectedUserId.value) || null)
watch(() => props.modelValue, async open => {
if (open) await load()
}, {immediate: true})
watch(selectedRoleId, async roleId => {
if (!roleId) return
roleForm.value = selectedRole.value?.name || ''
rolePermissions.value = toServerPermissionMask(0)
try {
await roleStore.fetchMembers(roleId)
const permission = await permissionsApi.getServerRolePermission(props.serverId, roleId)
rolePermissions.value = permission.permissions
} catch (e) {
if ((e as Error & {status?: number}).status !== 404) error.value = e instanceof Error ? e.message : 'Erreur de chargement'
}
})
watch(selectedUserId, userId => {
memberPermissions.value = userId && userPermissions.value[userId]
? userPermissions.value[userId].permissions
: toServerPermissionMask(0)
})
async function load() {
loading.value = true
error.value = null
name.value = props.serverName
try {
const [server, , , permissions] = await Promise.all([
serverStore.fetchServer(props.serverId),
roleStore.fetchRoles(props.serverId),
userStore.fetchUsers(props.serverId),
permissionsApi.listServerUserPermissions(props.serverId),
])
name.value = server.name
userPermissions.value = Object.fromEntries(permissions.map(permission => [permission.user_id, permission]))
} catch (e) {
name.value = ''
error.value = e instanceof Error ? e.message : 'Erreur de chargement'
}
finally { loading.value = false }
}
async function saveServer() {
if (!name.value.trim()) return
saving.value = true
try { await serverStore.updateServer(props.serverId, {name: name.value.trim()}) }
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de sauvegarde' }
finally { saving.value = false }
}
async function createRole() {
if (!roleForm.value.trim()) return
try { const role = await roleStore.createRole({server_id: props.serverId, name: roleForm.value.trim()}); selectedRoleId.value = role.id; roleForm.value = '' }
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de création' }
}
async function saveRole() {
if (!selectedRoleId.value || !roleForm.value.trim()) return
try {
await roleStore.updateRole(selectedRoleId.value, {name: roleForm.value.trim()})
await permissionsApi.setServerRolePermission(props.serverId, selectedRoleId.value, rolePermissions.value)
} catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de sauvegarde' }
}
async function deleteRole() {
if (!selectedRoleId.value || selectedRole.value?.is_default) return
try { await roleStore.deleteRole(selectedRoleId.value); selectedRoleId.value = null }
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de suppression' }
}
async function addMember() {
if (!selectedRoleId.value || !memberToAdd.value) return
try { await roleStore.addMember(selectedRoleId.value, memberToAdd.value); memberToAdd.value = null }
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur dajout' }
}
async function savePermissions(value: ServerPermissionMask) {
if (!selectedRoleId.value) return
try { await permissionsApi.setServerRolePermission(props.serverId, selectedRoleId.value, value); rolePermissions.value = value }
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de permissions' }
}
async function saveMemberPermissions(value: ServerPermissionMask) {
if (!selectedUserId.value) return
try {
const permission = await permissionsApi.setServerUserPermission(props.serverId, selectedUserId.value, value)
userPermissions.value[selectedUserId.value] = permission
memberPermissions.value = value
} catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de permissions' }
}
async function resetMemberPermissions() {
if (!selectedUserId.value) return
try {
await permissionsApi.removeServerUserPermission(props.serverId, selectedUserId.value)
delete userPermissions.value[selectedUserId.value]
memberPermissions.value = toServerPermissionMask(0)
} catch (e) {
const status = (e as Error & {status?: number}).status
if (status !== 404) error.value = e instanceof Error ? e.message : 'Erreur de réinitialisation'
}
}
</script>
<template>
<v-dialog :model-value="modelValue" max-width="980" @update:model-value="emit('update:modelValue', $event)">
<v-card min-height="620">
<v-card-title>Paramètres du serveur</v-card-title>
<v-card-text>
<v-alert v-if="error" type="error" density="compact" class="mb-4">{{ error }}</v-alert>
<v-row class="settings-layout" no-gutters>
<v-col cols="12" md="3" class="settings-sidebar">
<v-list density="compact" nav>
<v-list-item title="Général" prepend-icon="mdi-cog" :active="activeTab === 'general'" @click="activeTab = 'general'" />
<v-list-item title="Rôles" prepend-icon="mdi-shield-account" :active="activeTab === 'roles'" @click="activeTab = 'roles'" />
<v-list-item title="Membres" prepend-icon="mdi-account-cog" :active="activeTab === 'members'" @click="activeTab = 'members'" />
</v-list>
</v-col>
<v-col cols="12" md="9" class="pa-5">
<v-progress-linear v-if="loading" indeterminate class="mb-4" />
<template v-if="activeTab === 'general'">
<div class="text-h6 mb-4">Général</div>
<v-text-field v-model="name" label="Nom du serveur" :disabled="loading || saving" />
<v-btn color="primary" :loading="saving" :disabled="loading || !name.trim()" @click="saveServer">Enregistrer</v-btn>
</template>
<template v-else-if="activeTab === 'roles'">
<div class="text-h6 mb-4">Rôles</div>
<v-row>
<v-col cols="12" md="4">
<v-list border density="compact" class="role-list">
<v-list-item v-for="role in roles" :key="role.id" :title="role.name" :active="role.id === selectedRoleId" @click="selectedRoleId = role.id">
<template #append><v-icon v-if="role.is_default" icon="mdi-star" size="small" /></template>
</v-list-item>
</v-list>
<v-text-field v-model="roleForm" class="mt-3" label="Nouveau rôle" hide-details @keyup.enter="createRole" />
<v-btn class="mt-2" block color="primary" variant="tonal" @click="createRole">Ajouter un rôle</v-btn>
</v-col>
<v-col cols="12" md="8">
<template v-if="selectedRole">
<v-text-field v-model="roleForm" label="Nom du rôle" />
<div class="d-flex ga-2 mb-4"><v-btn color="primary" @click="saveRole">Enregistrer</v-btn><v-btn v-if="!selectedRole.is_default" color="error" variant="text" @click="deleteRole">Supprimer</v-btn></div>
<v-select v-model="memberToAdd" :items="availableUsers" item-title="username" item-value="id" label="Ajouter un membre" clearable @update:model-value="addMember" />
<v-list density="compact" border class="mb-4">
<v-list-item v-for="member in selectedMembers" :key="member.id" :title="member.username">
<template #append><v-btn icon="mdi-close" size="small" variant="text" @click="roleStore.removeMember(selectedRoleId!, member.id)" /></template>
</v-list-item>
</v-list>
<ServerPermissionEditor v-model="rolePermissions" title="Permissions du rôle" @save="savePermissions" />
</template>
<v-sheet v-else class="empty-selection d-flex align-center justify-center text-medium-emphasis" border rounded>Sélectionnez un rôle.</v-sheet>
</v-col>
</v-row>
</template>
<template v-else>
<div class="text-h6 mb-4">Permissions des membres</div>
<v-row>
<v-col cols="12" md="4">
<v-list border density="compact" class="role-list">
<v-list-item
v-for="user in userStore.users"
:key="user.id"
:title="user.username"
:active="user.id === selectedUserId"
@click="selectedUserId = user.id"
>
<template #append>
<v-icon v-if="userPermissions[user.id]" icon="mdi-shield-check" size="small" />
</template>
</v-list-item>
</v-list>
</v-col>
<v-col cols="12" md="8">
<template v-if="selectedUser">
<v-chip class="mb-4" color="primary" size="small">{{ selectedUser.username }}</v-chip>
<ServerPermissionEditor
v-model="memberPermissions"
title="Permissions directes du membre"
@save="saveMemberPermissions"
/>
<v-btn class="mt-3" color="error" variant="text" @click="resetMemberPermissions">
Réinitialiser les permissions directes
</v-btn>
</template>
<v-sheet v-else class="empty-selection d-flex align-center justify-center text-medium-emphasis" border rounded>
Sélectionnez un membre.
</v-sheet>
</v-col>
</v-row>
</template>
</v-col>
</v-row>
</v-card-text>
<v-card-actions><v-spacer /><v-btn variant="text" @click="emit('update:modelValue', false)">Fermer</v-btn></v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.settings-layout { min-height: 500px; }
.settings-sidebar { border-right: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); }
.role-list { max-height: 250px; overflow-y: auto; }
.empty-selection { min-height: 400px; }
@media (max-width: 959px) { .settings-sidebar { border-right: 0; border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); } }
</style>
+24 -4
View File
@@ -1,6 +1,9 @@
import {useApi} from '@/composables/useApi' import {useApi} from '@/composables/useApi'
import { import {
type ChannelRolePermission, type ChannelRolePermission,
type ChannelPermissions,
type ChannelPermissionsDto,
channelPermissionsFromDto,
type ChannelRolePermissionDto, type ChannelRolePermissionDto,
channelRolePermissionFromDto, channelRolePermissionFromDto,
type ChannelUserPermission, type ChannelUserPermission,
@@ -22,10 +25,11 @@ export function usePermissions() {
async function parseResponse<T>(response: Response): Promise<T> { async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => null) const error = await response.json().catch(() => null)
const exception = new Error(
throw new Error(
error?.message || 'Erreur lors de la gestion des permissions', error?.message || 'Erreur lors de la gestion des permissions',
) ) as Error & { status?: number }
exception.status = response.status
throw exception
} }
if (response.status === 204) { if (response.status === 204) {
@@ -52,6 +56,14 @@ export function usePermissions() {
return serverUserPermissionFromDto(dto) 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( async function setServerUserPermission(
serverId: string, serverId: string,
userId: string, userId: string,
@@ -129,6 +141,12 @@ export function usePermissions() {
// Permissions canal - utilisateur // 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( async function getChannelUserPermission(
channelId: string, channelId: string,
userId: string, userId: string,
@@ -216,7 +234,9 @@ export function usePermissions() {
} }
return { return {
getChannelPermissions,
getServerUserPermission, getServerUserPermission,
listServerUserPermissions,
setServerUserPermission, setServerUserPermission,
removeServerUserPermission, removeServerUserPermission,
@@ -232,4 +252,4 @@ export function usePermissions() {
setChannelRolePermission, setChannelRolePermission,
removeChannelRolePermission, removeChannelRolePermission,
} }
} }
+56 -7
View File
@@ -1,18 +1,24 @@
<script lang="ts" setup> <script lang="ts" setup>
import {storeToRefs} from 'pinia' import {storeToRefs} from 'pinia'
import {useServerStore} from '@/stores/server' import {useServerStore, type Server} from '@/stores/server'
import {computed, ref, watch} from 'vue' import {computed, ref, watch} from 'vue'
import {useRoute, useRouter} from 'vue-router' import {useRoute, useRouter} from 'vue-router'
import ContextMenu from "@/components/ContextMenu.vue"; import ContextMenu from "@/components/ContextMenu.vue";
import UserListDrawer from '@/components/UserListDrawer.vue' import UserListDrawer from '@/components/UserListDrawer.vue'
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
import {useContextMenu} from '@/composables/useContextMenu'
const serverStore = useServerStore() const serverStore = useServerStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const {openContextMenu} = useContextMenu()
const {servers} = storeToRefs(serverStore) const {servers} = storeToRefs(serverStore)
const showUsersDrawer = ref(false) const showUsersDrawer = ref(false)
const showServerSettings = ref(false)
const selectedServerId = ref<string | null>(null)
const selectedServerName = ref('')
const isServerContext = computed(() => Boolean(route.params.serverId)) const isServerContext = computed(() => Boolean(route.params.serverId))
watch(isServerContext, (isActive) => { watch(isServerContext, (isActive) => {
@@ -82,6 +88,18 @@ const getServerColor = (str: string): string => {
return `hsl(${hue}, 60%, 45%)` 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> </script>
<template> <template>
@@ -125,14 +143,25 @@ const getServerColor = (str: string): string => {
v-for="server in servers" v-for="server in servers"
:key="server.id" :key="server.id"
:to="`/server/${server.id}`" :to="`/server/${server.id}`"
@contextmenu="onServerContextMenu($event, server)"
> >
<v-avatar <v-badge
:style="{ backgroundColor: getServerColor(server.name) }" class="server-badge d-flex mx-auto mb-9"
class="d-flex align-center justify-center mx-auto mb-9 font-weight-bold text-caption text-white" :content="server.unread_count"
size="28" :model-value="(server.unread_count ?? 0) > 0"
color="primary"
location="bottom right"
offset-x="2"
offset-y="2"
> >
{{ getServerInitials(server.name) }} <v-avatar
</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> </router-link>
<v-btn <v-btn
@@ -150,6 +179,13 @@ const getServerColor = (str: string): string => {
v-model="showUsersDrawer" v-model="showUsersDrawer"
/> />
<ServerSettingsDialog
v-if="selectedServerId"
v-model="showServerSettings"
:server-id="selectedServerId"
:server-name="selectedServerName"
/>
<router-view/> <router-view/>
<!-- Menu contextuel global --> <!-- Menu contextuel global -->
<ContextMenu/> <ContextMenu/>
@@ -207,4 +243,17 @@ const getServerColor = (str: string): string => {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.server-badge {
height: 36px;
width: 36px;
}
.server-badge :deep(.v-badge__badge) {
min-width: 22px;
height: 22px;
padding: 0 5px;
font-size: 0.7rem;
line-height: 22px;
}
</style> </style>
+216 -29
View File
@@ -1,11 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
import 'highlight.js/styles/github-dark.css' import 'highlight.js/styles/github-dark.css'
import {computed, nextTick, onMounted, ref, watch} from 'vue'; import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue';
import {useRoute} from 'vue-router';
import {storeToRefs} from 'pinia'; import {storeToRefs} from 'pinia';
import {useMessageStore} from '@/stores/message'; import {useMessageStore} from '@/stores/message';
import {useServerStore} from '@/stores/server';
import {useUserStore} from "@/stores/user.ts"; import {useUserStore} from "@/stores/user.ts";
import {useMarkdown} from '@/composables/useMarkdown' import {useMarkdown} from '@/composables/useMarkdown'
import {onReloadAll} from '@/plugins/events.ts'
const props = defineProps<{ const props = defineProps<{
serverId: string serverId: string
@@ -13,25 +14,154 @@ const props = defineProps<{
}>(); }>();
const channelId = computed(() => props.channelId); const channelId = computed(() => props.channelId);
const route = useRoute();
const messageStore = useMessageStore(); const messageStore = useMessageStore();
const serverStore = useServerStore();
const userStore = useUserStore(); const userStore = useUserStore();
const {renderMarkdown} = useMarkdown() const {renderMarkdown} = useMarkdown()
// Référence vers l'élément scrollable
const messageContainer = ref<HTMLElement | null>(null); const messageContainer = ref<HTMLElement | null>(null);
const {messages, loading, loadingBefore, loadingAfter, hasMoreAfter, isAtBottom, newestId} = storeToRefs(messageStore);
// "messages" ici est une référence réactive liée au store
const {messages, loading} = storeToRefs(messageStore);
const newMessage = ref(''); const newMessage = ref('');
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 () => { const scrollToBottom = async () => {
await nextTick(); await nextTick();
if (messageContainer.value) { if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight; 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();
}
} }
}; };
@@ -39,47 +169,78 @@ const sendMessage = async () => {
if (!newMessage.value.trim()) return; if (!newMessage.value.trim()) return;
const content = newMessage.value; const content = newMessage.value;
const wasAtBottom = messageStore.isAtBottom;
try { try {
await messageStore.sendMessage(channelId.value, content); await messageStore.sendMessage(channelId.value, content);
newMessage.value = ''; // On vide le champ après succès newMessage.value = '';
await scrollToBottom(); if (wasAtBottom) await scrollToBottom();
} catch (e) { } 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(() => { onMounted(() => {
if (channelId.value) { stopReloadAll = onReloadAll(() => messageStore.fetchMessages(channelId.value));
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) { if (newChannelId) {
messageStore.fetchMessages(newChannelId); await messageStore.fetchMessages(newChannelId);
await scrollToBottom();
await markCurrentChannelRead(newChannelId);
} }
}, {immediate: true}) }, {immediate: true})
// Scroll automatique quand la liste des messages change (nouveaux messages reçus)
watch(messages, () => {
scrollToBottom();
}, {deep: true});
</script> </script>
<template> <template>
<!-- Conteneur principal prenant toute la hauteur --> <v-container class="pa-0 fill-height d-flex flex-column channel-layout" fluid>
<v-container class="pa-0 fill-height d-flex flex-column" fluid>
<!-- Zone des messages (scrollable) -->
<div <div
ref="messageContainer" ref="messageContainer"
class="flex-grow-1 overflow-y-auto w-100 message-container" 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 bg-color="transparent" lines="three">
<v-list-item <v-list-item
v-for="msg in messages" v-for="msg in messages"
:key="msg.id" :key="msg.id"
:data-message-id="msg.id"
class="px-4 py-1" class="px-4 py-1"
> >
<template v-slot:prepend> <template v-slot:prepend>
@@ -98,9 +259,26 @@ watch(messages, () => {
</div> </div>
</v-list-item> </v-list-item>
</v-list> </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> </div>
<!-- Zone de saisie fixe en bas -->
<v-sheet class="pa-4 flex-shrink-0" width="100%"> <v-sheet class="pa-4 flex-shrink-0" width="100%">
<v-textarea <v-textarea
v-model="newMessage" v-model="newMessage"
@@ -126,16 +304,25 @@ watch(messages, () => {
</template> </template>
</v-textarea> </v-textarea>
</v-sheet> </v-sheet>
</v-container> </v-container>
</template> </template>
<style scoped> <style scoped>
.message-container { .message-container {
/* Assure que la zone gère son scroll indépendamment */
height: 0; height: 0;
} }
.channel-layout {
position: relative;
}
.recent-messages-button {
position: absolute;
right: 16px;
bottom: 92px;
z-index: 2;
}
.markdown-content :deep(p) { .markdown-content :deep(p) {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -155,4 +342,4 @@ watch(messages, () => {
margin: 8px 0; margin: 8px 0;
overflow-x: auto; overflow-x: auto;
} }
</style> </style>
+71 -8
View File
@@ -2,13 +2,17 @@
import {storeToRefs} from 'pinia' import {storeToRefs} from 'pinia'
import {useChannelStore} from '@/stores/channel' import {useChannelStore} from '@/stores/channel'
import {useCategoryStore} from '@/stores/category' import {useCategoryStore} from '@/stores/category'
import {ref, watch} from 'vue' import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
import {useRoute} from 'vue-router' import {useRoute} from 'vue-router'
import CreateChannelDialog from '@/components/channel/CreateChannelDialog.vue' import CreateChannelDialog from '@/components/channel/CreateChannelDialog.vue'
import CreateCategoryDialog from '@/components/category/CreateCategoryDialog.vue' import CreateCategoryDialog from '@/components/category/CreateCategoryDialog.vue'
import {type MenuItem, useContextMenu} from '@/composables/useContextMenu' import {type MenuItem, useContextMenu} from '@/composables/useContextMenu'
import {useUserStore} from "@/stores/user.ts"; import {useUserStore} from "@/stores/user.ts";
import {useServerStore} from "@/stores/server.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<{ const props = defineProps<{
serverId: string serverId: string
@@ -22,6 +26,11 @@ const userStore = useUserStore()
const serverStore = useServerStore() const serverStore = useServerStore()
const {currentTree} = storeToRefs(serverStore) const {currentTree} = storeToRefs(serverStore)
const {openContextMenu} = useContextMenu() 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 loadServerData = async (targetServerId: string) => { const loadServerData = async (targetServerId: string) => {
@@ -40,6 +49,16 @@ const loadServerData = async (targetServerId: string) => {
} }
} }
let stopReloadAll: (() => void) | null = null
onMounted(() => {
stopReloadAll = onReloadAll(() => loadServerData(props.serverId))
})
onUnmounted(() => {
stopReloadAll?.()
})
watch( watch(
() => props.serverId, () => props.serverId,
async (newServerId) => { async (newServerId) => {
@@ -111,6 +130,14 @@ async function deleteChannel(channelId: string) {
function onChannelContextMenu(event: MouseEvent, channel: any) { function onChannelContextMenu(event: MouseEvent, channel: any) {
const menuItems: MenuItem[] = [ 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', label: 'Marquer comme lu',
icon: 'mdi-check', icon: 'mdi-check',
@@ -136,11 +163,9 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
<template> <template>
<v-navigation-drawer width="244" @contextmenu="onSidebarContextMenu"> <v-navigation-drawer width="244" @contextmenu="onSidebarContextMenu">
<v-sheet <v-sheet color="grey-lighten-5" height="128" width="100%" class="pa-3">
color="grey-lighten-5" <v-btn block variant="text" prepend-icon="mdi-cog" @click="showServerSettings = true">Gérer le serveur</v-btn>
height="128" </v-sheet>
width="100%"
></v-sheet>
<v-list <v-list
v-model:opened="openedCategories" v-model:opened="openedCategories"
@@ -163,9 +188,22 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
:key="channel.id" :key="channel.id"
:title="channel.name" :title="channel.name"
:to="`/server/${serverId}/channel/${channel.id}`" :to="`/server/${serverId}/channel/${channel.id}`"
:class="{ 'font-weight-bold': (channel.unread_count ?? 0) > 0 }"
link link
@contextmenu="onChannelContextMenu($event, channel)" @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> </v-list-group>
<!-- Canal orphelin (racine) --> <!-- Canal orphelin (racine) -->
@@ -174,9 +212,22 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
:key="item.Channel.id" :key="item.Channel.id"
:title="item.Channel.name" :title="item.Channel.name"
:to="`/server/${serverId}/channel/${item.Channel.id}`" :to="`/server/${serverId}/channel/${item.Channel.id}`"
:class="{ 'font-weight-bold': (item.Channel.unread_count ?? 0) > 0 }"
link link
@contextmenu="onChannelContextMenu($event, item.Channel)" @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> </template>
</v-list> </v-list>
</v-navigation-drawer> </v-navigation-drawer>
@@ -194,6 +245,18 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
@created="refreshServerTree" @created="refreshServerTree"
/> />
<ChannelPermissionsDialog
v-model="showPermissionsDialog"
:channel="selectedChannel"
:server-id="serverId"
/>
<ServerSettingsDialog
v-model="showServerSettings"
:server-id="serverId"
:server-name="serverName"
/>
<v-main> <v-main>
<router-view/> <router-view/>
</v-main> </v-main>
+22 -1
View File
@@ -1,5 +1,9 @@
export const bus = new EventTarget(); export const bus = new EventTarget();
type ReloadAllHandler = () => void | Promise<void>;
const reloadAllHandlers = new Set<ReloadAllHandler>();
export function emitGatewayEvent(namespace: string, action: string, content: any) { export function emitGatewayEvent(namespace: string, action: string, content: any) {
// On construit le nom de l'événement de manière cohérente : gateway:message // On construit le nom de l'événement de manière cohérente : gateway:message
const eventName = `gateway:${namespace.toLowerCase()}`; 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 // Retourne une fonction pour se désabonner facilement si besoin
return () => bus.removeEventListener(eventName, wrapper); 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);
}
}
}
+2 -1
View File
@@ -9,6 +9,7 @@ interface Channel {
category_id?: string | null category_id?: string | null
created_at: string created_at: string
updated_at: string updated_at: string
unread_count?: number
} }
/* /*
@@ -65,4 +66,4 @@ export const useChannelStore = defineStore('channel', {
this.error = null; this.error = null;
} }
} }
}) })
+16 -5
View File
@@ -1,6 +1,6 @@
import {defineStore} from 'pinia'; import {defineStore} from 'pinia';
import {useAppStore} from "@/stores/app.ts"; 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' type GatewayStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
@@ -10,6 +10,7 @@ export const useGatewayStore = defineStore('gateway', {
status: 'disconnected' as GatewayStatus, status: 'disconnected' as GatewayStatus,
reconnectAttempts: 0, reconnectAttempts: 0,
shouldReconnect: false, shouldReconnect: false,
reloadOnConnect: false,
reconnectTimer: null as number | null, reconnectTimer: null as number | null,
}), }),
@@ -34,22 +35,31 @@ export const useGatewayStore = defineStore('gateway', {
const socket = new WebSocket(wsUrl) const socket = new WebSocket(wsUrl)
socket.onopen = () => { socket.onopen = () => {
if (this.socket !== socket) return
const shouldReload = this.reloadOnConnect
this.status = 'connected' this.status = 'connected'
this.reconnectAttempts = 0 this.reconnectAttempts = 0
this.reloadOnConnect = false
if (shouldReload) {
void emitReloadAll()
}
} }
socket.onclose = () => { socket.onclose = () => {
if (this.socket !== socket) return
this.status = 'disconnected' this.status = 'disconnected'
if (this.socket === socket) { this.socket = null
this.socket = null
}
if (this.shouldReconnect) { if (this.shouldReconnect) {
this.reloadOnConnect = true
this.scheduleReconnect() this.scheduleReconnect()
} }
} }
socket.onerror = () => { socket.onerror = () => {
if (this.socket !== socket) return
this.status = 'error' this.status = 'error'
} }
@@ -70,6 +80,7 @@ export const useGatewayStore = defineStore('gateway', {
this.socket = null this.socket = null
this.status = 'disconnected' this.status = 'disconnected'
this.reconnectAttempts = 0 this.reconnectAttempts = 0
this.reloadOnConnect = false
}, },
async send(payload: object) { async send(payload: object) {
@@ -100,4 +111,4 @@ export const useGatewayStore = defineStore('gateway', {
}, delay) }, delay)
}, },
} }
}); });
+307 -41
View File
@@ -1,9 +1,18 @@
import {defineStore} from "pinia"; import {defineStore} from "pinia";
import {useApi} from "@/composables/useApi.ts"; import {useApi} from "@/composables/useApi.ts";
import {onGatewayEvent} from "@/plugins/events.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; id: string;
server_id: string | null;
channel_id: string; channel_id: string;
user_id: string; user_id: string;
content: string; content: string;
@@ -12,78 +21,335 @@ interface Message {
reply_to_id: string | null; 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", { export const useMessageStore = defineStore("message", {
state: () => ({ state: () => ({
messages: [] as Message[], messages: [] as Message[],
activeChannelId: null as string | null,
oldestId: null as string | null,
newestId: null as string | null,
hasMoreBefore: false,
hasMoreAfter: false,
loading: false, loading: false,
loadingBefore: false,
loadingAfter: false,
isAtBottom: true,
scrollToBottomRequested: false,
requestVersion: 0,
seenRealtimeMessageIds: new Set<string>(),
}), }),
actions: { 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; this.loading = true;
// Query params
let params = new URLSearchParams();
params.append("channel_id", channel_id);
const queryString = params.toString();
try { try {
const api = useApi(); const page = await requestPage(channelId, {limit: MESSAGE_WINDOW_SIZE});
// Utilisation du paramètre pour cibler le channel if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return;
const response = await api.get(`/messages${queryString ? `?${queryString}` : ""}`);
this.messages = await response.json(); this.messages = mergeMessages(page.messages).slice(-MESSAGE_WINDOW_SIZE);
this.updateBoundaries(page);
this.scrollToBottomRequested = true;
} catch (error) { } 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 { } 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) async fetchBefore(channelId: string): Promise<WindowChange | null> {
// this.messages.push(newMessage); 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) { } catch (error) {
console.error("Erreur lors de l'envoi du message:", error); console.error("Erreur lors de l'envoi du message:", error);
throw 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() { reset() {
this.requestVersion += 1;
this.messages = []; 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) => { onGatewayEvent("Message", (payload) => {
const store = useMessageStore(); const store = useMessageStore();
switch (payload.action) { switch (payload.action) {
case "add": case "add":
const exists = store.messages.some(m => m.id === payload.content.id); store.addRealtimeMessage(payload.content as Message, true);
if (!exists) {
store.messages.push(payload.content);
}
break; break;
case "update": case "update":
const updateIndex = store.messages.findIndex(m => m.id === payload.content.id); store.updateMessage(payload.content as Message);
if (updateIndex !== -1) {
store.messages[updateIndex] = payload.content;
}
break; break;
case "remove": case "remove":
const removeIndex = store.messages.findIndex(m => m.id === payload.content); store.removeMessage(String(payload.content));
if (removeIndex !== -1) {
store.messages.splice(removeIndex, 1);
}
break; break;
default: default:
console.warn("Action non gérée :", payload.action); console.warn("Action non gérée :", payload.action);
} }
}) });
+19
View File
@@ -0,0 +1,19 @@
import {defineStore} from "pinia";
export const useNotificationStore = defineStore("notification", {
state: () => ({
visible: false,
title: "",
message: "",
}),
actions: {
show(title: string, message: string) {
this.title = title;
this.message = message;
this.visible = true;
},
hide() {
this.visible = false;
},
},
});
+62
View File
@@ -0,0 +1,62 @@
import {defineStore} from 'pinia'
import {useApi} from '@/composables/useApi'
import type {Role} from '@/types/role'
import type {User} from '@/types/user'
export const useRoleStore = defineStore('role', {
state: () => ({
roles: [] as Role[],
members: {} as Record<string, User[]>,
loading: false,
}),
actions: {
async fetchRoles(serverId: string) {
const response = await useApi().get(`/roles?server_id=${serverId}`)
if (!response.ok) throw new Error('Impossible de charger les rôles')
this.roles = await response.json()
return this.roles
},
async createRole(payload: {server_id: string; name: string; is_default?: boolean}) {
const response = await useApi().post('/roles', payload)
if (!response.ok) throw new Error('Impossible de créer le rôle')
const role = await response.json()
this.roles.push(role)
return role
},
async updateRole(id: string, payload: {name: string; is_default?: boolean}) {
const response = await useApi().put(`/roles/${id}`, payload)
if (!response.ok) throw new Error('Impossible de modifier le rôle')
const role = await response.json()
const index = this.roles.findIndex(item => item.id === id)
if (index >= 0) this.roles[index] = role
return role
},
async deleteRole(id: string) {
const response = await useApi().delete(`/roles/${id}`)
if (!response.ok) throw new Error('Impossible de supprimer le rôle')
this.roles = this.roles.filter(role => role.id !== id)
delete this.members[id]
},
async fetchMembers(roleId: string) {
const response = await useApi().get(`/roles/${roleId}/members`)
if (!response.ok) throw new Error('Impossible de charger les membres')
this.members[roleId] = await response.json()
return this.members[roleId]
},
async addMember(roleId: string, userId: string) {
const response = await useApi().put(`/roles/${roleId}/members/${userId}`)
if (!response.ok) throw new Error('Impossible dajouter le membre')
await this.fetchMembers(roleId)
},
async removeMember(roleId: string, userId: string) {
const response = await useApi().delete(`/roles/${roleId}/members/${userId}`)
if (!response.ok) throw new Error('Impossible de retirer le membre')
await this.fetchMembers(roleId)
},
reset() {
this.roles = []
this.members = {}
this.loading = false
},
},
})
+74 -2
View File
@@ -3,12 +3,13 @@ import {useApi} from "@/composables/useApi.ts";
import {useChannelStore} from "@/stores/channel.ts"; import {useChannelStore} from "@/stores/channel.ts";
import {useCategoryStore} from "@/stores/category.ts"; import {useCategoryStore} from "@/stores/category.ts";
interface Server { export interface Server {
id: string id: string
name: string name: string
is_default: boolean is_default: boolean
created_at: string created_at: string
updated_at: string updated_at: string
unread_count?: number
} }
export const useServerStore = defineStore("server", { export const useServerStore = defineStore("server", {
@@ -24,6 +25,22 @@ export const useServerStore = defineStore("server", {
const response = await api.get("/servers"); const response = await api.get("/servers");
this.servers = await response.json(); 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 }) { async createServer(payload: { name: string; password?: string | null; is_default?: boolean }) {
this.loading = true; this.loading = true;
this.error = null; this.error = null;
@@ -44,6 +61,25 @@ export const useServerStore = defineStore("server", {
this.loading = false; 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) { async fetchServerTree(serverId: string) {
const api = useApi(); const api = useApi();
const channelStore = useChannelStore(); const channelStore = useChannelStore();
@@ -74,10 +110,46 @@ export const useServerStore = defineStore("server", {
return tree.items; 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() { reset() {
this.servers = []; this.servers = [];
this.loading = false; this.loading = false;
this.error = null; this.error = null;
} }
} }
}); });
+18 -1
View File
@@ -5,8 +5,10 @@ import {useServerStore} from '@/stores/server.ts'
import {useCategoryStore} from '@/stores/category.ts' import {useCategoryStore} from '@/stores/category.ts'
import {useChannelStore} from '@/stores/channel.ts' import {useChannelStore} from '@/stores/channel.ts'
import {useMessageStore} from '@/stores/message.ts' import {useMessageStore} from '@/stores/message.ts'
import {onReloadAll} from '@/plugins/events.ts'
let bootstrapPromise: Promise<void> | null = null let bootstrapPromise: Promise<void> | null = null
let reloadServersPromise: Promise<void> | null = null
export const useSessionStore = defineStore('session', { export const useSessionStore = defineStore('session', {
state: () => ({ 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) { async login(username: string, password: string) {
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -93,4 +108,6 @@ export const useSessionStore = defineStore('session', {
this.isReady = true this.isReady = true
}, },
}, },
}) })
onReloadAll(() => useSessionStore().reloadServers())
+20 -1
View File
@@ -52,6 +52,11 @@ export interface ChannelRolePermission {
permissions: ChannelPermissionMask permissions: ChannelPermissionMask
} }
export interface ChannelPermissions {
users: ChannelUserPermission[]
roles: ChannelRolePermission[]
}
export interface ServerUserPermissionDto { export interface ServerUserPermissionDto {
id: string id: string
server_id: string server_id: string
@@ -80,6 +85,11 @@ export interface ChannelRolePermissionDto {
permissions: number | string permissions: number | string
} }
export interface ChannelPermissionsDto {
users: ChannelUserPermissionDto[]
roles: ChannelRolePermissionDto[]
}
function asServerPermissionMask(value: bigint): ServerPermissionMask { function asServerPermissionMask(value: bigint): ServerPermissionMask {
return value as ServerPermissionMask return value as ServerPermissionMask
} }
@@ -442,4 +452,13 @@ export function channelRolePermissionFromDto(
role_id: dto.role_id, role_id: dto.role_id,
permissions: toChannelPermissionMask(dto.permissions), permissions: toChannelPermissionMask(dto.permissions),
} }
} }
export function channelPermissionsFromDto(
dto: ChannelPermissionsDto,
): ChannelPermissions {
return {
users: dto.users.map(channelUserPermissionFromDto),
roles: dto.roles.map(channelRolePermissionFromDto),
}
}
+7
View File
@@ -0,0 +1,7 @@
export interface Role {
id: string
server_id: string
name: string
is_default: boolean
created_at: string
}
+12 -12
View File
@@ -1,12 +1,12 @@
pub use sea_orm_migration::prelude::*; pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table; mod m20220101_000001_create_table;
pub struct Migrator; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
impl MigratorTrait for Migrator { impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> { fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20220101_000001_create_table::Migration)] vec![Box::new(m20220101_000001_create_table::Migration)]
} }
} }
+172 -65
View File
@@ -138,13 +138,18 @@ impl MigrationTrait for Migration {
.to(Alias::new("user"), Alias::new("id")) .to(Alias::new("user"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade), .on_delete(ForeignKeyAction::Cascade),
) )
.index( .to_owned(),
Index::create() )
.name("uq_server_user") .await?;
.col(Alias::new("server_id"))
.col(Alias::new("user_id")) manager
.unique(), .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(), .to_owned(),
) )
.await?; .await?;
@@ -185,13 +190,18 @@ impl MigrationTrait for Migration {
.to(Alias::new("server"), Alias::new("id")) .to(Alias::new("server"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade), .on_delete(ForeignKeyAction::Cascade),
) )
.index( .to_owned(),
Index::create() )
.name("uq_role_server_name") .await?;
.col(Alias::new("server_id"))
.col(Alias::new("name")) manager
.unique(), .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(), .to_owned(),
) )
.await?; .await?;
@@ -376,14 +386,6 @@ impl MigrationTrait for Migration {
.to(Alias::new("category"), Alias::new("id")) .to(Alias::new("category"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade), .on_delete(ForeignKeyAction::Cascade),
) )
.index(
Index::create()
.name("uq_server_item_order_resource")
.col(Alias::new("server_id"))
.col(Alias::new("resource_type"))
.col(Alias::new("resource_id"))
.unique(),
)
.to_owned(), .to_owned(),
) )
.await?; .await?;
@@ -400,6 +402,19 @@ impl MigrationTrait for Migration {
) )
.await?; .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 // Membres des canaux
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@@ -443,13 +458,82 @@ impl MigrationTrait for Migration {
.to(Alias::new("user"), Alias::new("id")) .to(Alias::new("user"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade), .on_delete(ForeignKeyAction::Cascade),
) )
.index( .to_owned(),
Index::create() )
.name("uq_channel_user") .await?;
.col(Alias::new("channel_id"))
.col(Alias::new("user_id")) manager
.unique(), .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(), .to_owned(),
) )
.await?; .await?;
@@ -509,6 +593,17 @@ impl MigrationTrait for Migration {
) )
.await?; .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 manager
.create_table( .create_table(
Table::create() Table::create()
@@ -568,13 +663,6 @@ impl MigrationTrait for Migration {
.not_null() .not_null()
.default(0), .default(0),
) )
.index(
Index::create()
.name("uq_server_user_permission")
.col(Alias::new("server_id"))
.col(Alias::new("user_id"))
.unique(),
)
.foreign_key( .foreign_key(
ForeignKey::create() ForeignKey::create()
.name("fk_server_user_permission_server") .name("fk_server_user_permission_server")
@@ -596,6 +684,18 @@ impl MigrationTrait for Migration {
) )
.await?; .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 manager
.create_table( .create_table(
Table::create() Table::create()
@@ -632,13 +732,18 @@ impl MigrationTrait for Migration {
.to(Alias::new("role"), Alias::new("id")) .to(Alias::new("role"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade), .on_delete(ForeignKeyAction::Cascade),
) )
.index( .to_owned(),
Index::create() )
.name("uq_server_role_permission") .await?;
.col(Alias::new("server_id"))
.col(Alias::new("role_id")) manager
.unique(), .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(), .to_owned(),
) )
.await?; .await?;
@@ -679,13 +784,18 @@ impl MigrationTrait for Migration {
.to(Alias::new("role"), Alias::new("id")) .to(Alias::new("role"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade), .on_delete(ForeignKeyAction::Cascade),
) )
.index( .to_owned(),
Index::create() )
.name("uq_channel_role_permission") .await?;
.col(Alias::new("channel_id"))
.col(Alias::new("role_id")) manager
.unique(), .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(), .to_owned(),
) )
.await?; .await?;
@@ -726,13 +836,18 @@ impl MigrationTrait for Migration {
.to(Alias::new("user"), Alias::new("id")) .to(Alias::new("user"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade), .on_delete(ForeignKeyAction::Cascade),
) )
.index( .to_owned(),
Index::create() )
.name("uq_channel_user_permission") .await?;
.col(Alias::new("channel_id"))
.col(Alias::new("user_id")) manager
.unique(), .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(), .to_owned(),
) )
.await?; .await?;
@@ -793,6 +908,7 @@ impl MigrationTrait for Migration {
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let tables = [ let tables = [
"computed_permission", "computed_permission",
"channel_user_read_state",
"channel_user_permission", "channel_user_permission",
"channel_role_permission", "channel_role_permission",
"server_user_permission", "server_user_permission",
@@ -821,15 +937,6 @@ impl MigrationTrait for Migration {
.await?; .await?;
} }
manager
.drop_index(
Index::drop()
.name("idx_server_item_order_scope")
.table(Alias::new("server_item_order"))
.to_owned(),
)
.await?;
Ok(()) Ok(())
} }
} }
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Generate test messages directly in the project's SQLite database."""
# python3 scripts/generate_messages.py \
# --db oxspeak.db \
# --channel-id 672e7757-b7df-401b-8e47-8c62e1fb9d7d \
# --user-id d327a80b-83d4-4a53-9c0b-140f60cc0caa \
# --count 1000 \
# --min-words 10 \
# --max-words 500
from __future__ import annotations
import argparse
import random
import sqlite3
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
WORD_POOL = (
"message", "canal", "serveur", "utilisateur", "test", "donnee", "histoire",
"discussion", "contenu", "generation", "curseur", "fenetre", "lecture",
"chargement", "conversation", "exemple", "texte", "systeme", "application",
"client", "serveur", "base", "requete", "resultat", "information", "session",
"connexion", "fonction", "version", "contenu", "rapide", "simple", "aleatoire",
"important", "nouveau", "ancien", "prochain", "precedent", "visible", "local",
"distant", "stable", "chronologique", "variable", "longueur", "performance",
"validation", "operation", "transaction", "historique", "position", "defilement",
)
MESSAGE_MARKER_FORMAT = "[{number:04d}]"
def positive_int(value: str) -> int:
parsed = int(value)
if parsed <= 0:
raise argparse.ArgumentTypeError("must be greater than zero")
return parsed
def parse_uuid(value: str, option_name: str) -> uuid.UUID:
try:
return uuid.UUID(value)
except ValueError as error:
raise argparse.ArgumentTypeError(f"{option_name} is not a valid UUID: {value}") from error
def next_uuid(previous: uuid.UUID | None) -> uuid.UUID:
"""Return a UUID v7 strictly greater than the previous generated ID."""
generated = uuid.uuid7()
if previous is not None and generated.int <= previous.int:
generated = uuid.UUID(int=previous.int + 1)
return generated
def random_message(
rng: random.Random,
min_words: int,
max_words: int,
marker: str,
) -> str:
# The marker itself counts as one word in the requested range.
body_count = rng.randint(max(0, min_words - 1), max_words - 1)
body = " ".join(rng.choices(WORD_POOL, k=body_count))
return f"{marker} {body}".rstrip()
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--db",
type=Path,
default=Path("oxspeak.db"),
help="SQLite database path (default: oxspeak.db)",
)
parser.add_argument("--channel-id", required=True, help="target channel UUID")
parser.add_argument("--user-id", required=True, help="author user UUID")
parser.add_argument(
"--count",
required=True,
type=positive_int,
help="number of messages to insert",
)
parser.add_argument(
"--min-words",
type=positive_int,
default=10,
help="minimum number of words per message (default: 10)",
)
parser.add_argument(
"--max-words",
type=positive_int,
default=500,
help="maximum number of words per message (default: 500)",
)
parser.add_argument(
"--seed",
type=int,
default=None,
help="optional seed to reproduce generated contents",
)
parser.add_argument(
"--batch-size",
type=positive_int,
default=500,
help="number of rows inserted per batch (default: 500)",
)
return parser
def ensure_target_exists(
connection: sqlite3.Connection,
table: str,
identifier: bytes,
label: str,
) -> None:
row = connection.execute(
f'SELECT 1 FROM "{table}" WHERE id = ? LIMIT 1',
(identifier,),
).fetchone()
if row is None:
raise ValueError(f"{label} does not exist in the database")
def generate_messages(
database: Path,
channel_id: uuid.UUID,
user_id: uuid.UUID,
count: int,
batch_size: int,
min_words: int,
max_words: int,
seed: int | None,
) -> None:
started_at = time.monotonic()
connection = sqlite3.connect(database)
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA busy_timeout = 5000")
try:
ensure_target_exists(connection, "channel", channel_id.bytes, "channel")
ensure_target_exists(connection, "user", user_id.bytes, "user")
previous_id: uuid.UUID | None = None
inserted = 0
rng = random.Random(seed)
connection.execute("BEGIN")
try:
while inserted < count:
current_batch_size = min(batch_size, count - inserted)
rows = []
for offset in range(current_batch_size):
message_id = next_uuid(previous_id)
previous_id = message_id
message_number = inserted + offset + 1
marker = MESSAGE_MARKER_FORMAT.format(number=message_number)
content = random_message(rng, min_words, max_words, marker)
created_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
rows.append(
(
message_id.bytes,
channel_id.bytes,
user_id.bytes,
content,
created_at,
None,
None,
)
)
connection.executemany(
"""
INSERT INTO message
(id, channel_id, user_id, content, created_at, updated_at, reply_to_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
rows,
)
inserted += current_batch_size
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
elapsed = time.monotonic() - started_at
print(f"Inserted {count} messages into {database} in {elapsed:.2f}s")
def main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.max_words < args.min_words:
parser.error("--max-words must be greater than or equal to --min-words")
try:
channel_id = parse_uuid(args.channel_id, "--channel-id")
user_id = parse_uuid(args.user_id, "--user-id")
generate_messages(
database=args.db,
channel_id=channel_id,
user_id=user_id,
count=args.count,
batch_size=args.batch_size,
min_words=args.min_words,
max_words=args.max_words,
seed=args.seed,
)
except (OSError, sqlite3.Error, ValueError) as error:
parser.error(str(error))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5 -1
View File
@@ -35,7 +35,6 @@ impl App {
let repositories = Arc::new(Repositories::new(db.clone())); let repositories = Arc::new(Repositories::new(db.clone()));
// Initialize gateway manager // Initialize gateway manager
let gateway = Arc::new(GatewayManager::default());
// Init one server if no one exist // Init one server if no one exist
let default_server = match repositories.server.get_default().await? { let default_server = match repositories.server.get_default().await? {
@@ -68,6 +67,11 @@ impl App {
let metrics = AppMetrics::new(); let metrics = AppMetrics::new();
let services = Arc::new(Services::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 { let state = AppState {
db, db,
+22
View File
@@ -42,12 +42,28 @@ pub struct ChannelResponse {
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_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). /// None : contexte sans permissions (champ ignoré dans le JSON).
/// Some(value) : valeur de computed_permission (0 si absente). /// Some(value) : valeur de computed_permission (0 si absente).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub permission: Option<u64>, 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)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SetChannelPermissionRequest { pub struct SetChannelPermissionRequest {
/// Bitmask des permissions à appliquer. /// Bitmask des permissions à appliquer.
@@ -70,3 +86,9 @@ pub struct ChannelRolePermissionResponse {
pub role_id: Uuid, pub role_id: Uuid,
pub permissions: u64, pub permissions: u64,
} }
#[derive(Debug, Serialize, ToSchema)]
pub struct ChannelPermissionsResponse {
pub users: Vec<ChannelUserPermissionResponse>,
pub roles: Vec<ChannelRolePermissionResponse>,
}
+13 -12
View File
@@ -6,6 +6,7 @@ use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct MessageResponse { pub struct MessageResponse {
pub id: Uuid, pub id: Uuid,
pub server_id: Option<Uuid>,
pub channel_id: Uuid, pub channel_id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub content: String, pub content: String,
@@ -14,6 +15,15 @@ pub struct MessageResponse {
pub reply_to_id: Option<Uuid>, 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)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateMessageRequest { pub struct CreateMessageRequest {
pub channel_id: Uuid, pub channel_id: Uuid,
@@ -26,19 +36,10 @@ pub struct UpdateMessageRequest {
pub content: String, pub content: String,
} }
#[derive(serde::Deserialize, utoipa::IntoParams)] #[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
pub struct MessageQueryParams { pub struct MessageQueryParams {
pub channel_id: Option<uuid::Uuid>, pub channel_id: Uuid,
pub before_id: Option<Uuid>, pub before_id: Option<Uuid>,
pub after_id: Option<Uuid>,
pub limit: Option<u64>, pub limit: Option<u64>,
} }
impl Default for MessageQueryParams {
fn default() -> Self {
Self {
channel_id: None,
before_id: None,
limit: Some(50),
}
}
}
+5
View File
@@ -3,6 +3,11 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema, utoipa::IntoParams)]
pub struct RoleQueryParams {
pub server_id: Option<Uuid>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateRoleRequest { pub struct CreateRoleRequest {
pub server_id: Uuid, pub server_id: Uuid,
+2
View File
@@ -28,6 +28,8 @@ pub struct ServerResponse {
pub is_default: bool, pub is_default: bool,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_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)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
+1 -3
View File
@@ -1,7 +1,5 @@
use migration::{Migrator, MigratorTrait};
use oxspeak_server_lib::config::AppConfig; use oxspeak_server_lib::config::AppConfig;
use oxspeak_server_lib::core::App; use oxspeak_server_lib::core::App;
use oxspeak_server_lib::database::Database;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { 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( .with_env_filter(
std::env::var("RUST_LOG") std::env::var("RUST_LOG")
// .unwrap_or_else(|_| "info,sqlx=debug,sea_orm=debug,sea_orm_migration=info".into()), // .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_target(true)
.with_level(true) .with_level(true)
+33
View File
@@ -0,0 +1,33 @@
use sea_orm::entity::prelude::*;
use sea_orm::prelude::async_trait::async_trait;
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "channel_user_read_state")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub channel_id: Uuid,
pub user_id: Uuid,
pub last_read_message_id: Option<Uuid>,
pub updated_at: DateTimeUtc,
#[sea_orm(
belongs_to,
from = "channel_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub channel: HasOne<super::channel::Entity>,
#[sea_orm(
belongs_to,
from = "user_id",
to = "id",
on_update = "NoAction",
on_delete = "Cascade"
)]
pub user: HasOne<super::user::Entity>,
}
#[async_trait]
impl ActiveModelBehavior for ActiveModel {}
+21 -20
View File
@@ -1,20 +1,21 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
pub mod prelude; pub mod prelude;
pub mod attachment; pub mod attachment;
pub mod category; pub mod category;
pub mod channel; pub mod channel;
pub mod channel_role_permission; pub mod channel_role_permission;
pub mod channel_user; pub mod channel_user;
pub mod channel_user_permission; pub mod channel_user_read_state;
pub mod computed_permission; pub mod channel_user_permission;
pub mod message; pub mod computed_permission;
pub mod role; pub mod message;
pub mod role_user; pub mod role;
pub mod server; pub mod role_user;
pub mod server_item_order; pub mod server;
pub mod server_role_permission; pub mod server_item_order;
pub mod server_user; pub mod server_role_permission;
pub mod server_user_permission; pub mod server_user;
pub mod user; pub mod server_user_permission;
pub mod user;
+17 -16
View File
@@ -1,16 +1,17 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 //! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
pub use super::attachment::Entity as Attachment; pub use super::attachment::Entity as Attachment;
pub use super::category::Entity as Category; pub use super::category::Entity as Category;
pub use super::channel::Entity as Channel; pub use super::channel::Entity as Channel;
pub use super::channel_user::Entity as ChannelUser; pub use super::channel_user::Entity as ChannelUser;
pub use super::computed_permission::Entity as ComputedPermission; pub use super::channel_user_read_state::Entity as ChannelUserReadState;
pub use super::message::Entity as Message; pub use super::computed_permission::Entity as ComputedPermission;
pub use super::role::Entity as Group; pub use super::message::Entity as Message;
pub use super::role_user::Entity as GroupMember; pub use super::role::Entity as Group;
pub use super::server::Entity as Server; pub use super::role_user::Entity as GroupMember;
pub use super::server_item_order::Entity as ServerItemOrder; pub use super::server::Entity as Server;
pub use super::server_role_permission::Entity as ServerRolePermission; pub use super::server_item_order::Entity as ServerItemOrder;
pub use super::server_user::Entity as ServerUser; pub use super::server_role_permission::Entity as ServerRolePermission;
pub use super::server_user_permission::Entity as ServerUserPermission; pub use super::server_user::Entity as ServerUser;
pub use super::user::Entity as User; pub use super::server_user_permission::Entity as ServerUserPermission;
pub use super::user::Entity as User;
+20
View File
@@ -59,6 +59,16 @@ impl ChannelRepository {
.await?) .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( pub async fn set_user_permission(
&self, &self,
channel_id: Uuid, channel_id: Uuid,
@@ -109,6 +119,16 @@ impl ChannelRepository {
.await?) .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( pub async fn set_role_permission(
&self, &self,
channel_id: Uuid, channel_id: Uuid,
+88 -6
View File
@@ -1,9 +1,18 @@
use super::types::MessageFilter; use super::types::MessageFilter;
use crate::models::{channel, message}; use crate::models::message;
use crate::repositories::{AnyResult, RepositoryContext}; use crate::repositories::{AnyResult, RepositoryContext};
use event_bus::Scope;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use std::sync::Arc; 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)] #[derive(Clone, Debug)]
pub struct MessageRepository { pub struct MessageRepository {
@@ -21,7 +30,11 @@ impl MessageRepository {
.await?) .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(); let mut query = message::Entity::find();
if let Some(channel_id) = filter.channel_id { if let Some(channel_id) = filter.channel_id {
@@ -32,11 +45,80 @@ impl MessageRepository {
query = query.filter(message::Column::Id.lt(before_id)); query = query.filter(message::Column::Id.lt(before_id));
} }
if let Some(limit) = filter.limit { if let Some(after_id) = filter.after_id {
query = query.order_by_desc(message::Column::Id).limit(limit); 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>> { pub async fn get_by_channel(&self, channel_id: uuid::Uuid) -> AnyResult<Vec<message::Model>> {
+6
View File
@@ -4,6 +4,7 @@ use crate::repositories::category::CategoryRepository;
use crate::repositories::channel::ChannelRepository; use crate::repositories::channel::ChannelRepository;
use crate::repositories::computed_permission::ComputedPermissionRepository; use crate::repositories::computed_permission::ComputedPermissionRepository;
use crate::repositories::message::MessageRepository; use crate::repositories::message::MessageRepository;
use crate::repositories::read_state::ReadStateRepository;
use crate::repositories::role::RoleRepository; use crate::repositories::role::RoleRepository;
use crate::repositories::server::ServerRepository; use crate::repositories::server::ServerRepository;
use crate::repositories::server_item_order::ServerItemOrderRepository; use crate::repositories::server_item_order::ServerItemOrderRepository;
@@ -16,6 +17,7 @@ mod category;
mod channel; mod channel;
mod computed_permission; mod computed_permission;
mod message; mod message;
mod read_state;
mod role; mod role;
mod server; mod server;
mod server_item_order; mod server_item_order;
@@ -35,6 +37,7 @@ pub struct Repositories {
pub channel: ChannelRepository, pub channel: ChannelRepository,
pub role: RoleRepository, pub role: RoleRepository,
pub message: MessageRepository, pub message: MessageRepository,
pub read_state: ReadStateRepository,
pub user: UserRepository, pub user: UserRepository,
pub computed_permission: ComputedPermissionRepository, pub computed_permission: ComputedPermissionRepository,
pub server_item_order: ServerItemOrderRepository, pub server_item_order: ServerItemOrderRepository,
@@ -61,6 +64,9 @@ impl Repositories {
message: MessageRepository { message: MessageRepository {
context: context.clone(), context: context.clone(),
}, },
read_state: ReadStateRepository {
context: context.clone(),
},
user: UserRepository { user: UserRepository {
context: context.clone(), context: context.clone(),
}, },
+152
View File
@@ -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)
}
}
+46 -1
View File
@@ -1,4 +1,4 @@
use crate::models::{role, role_user}; use crate::models::{role, role_user, user};
use crate::repositories::{AnyResult, RepositoryContext}; use crate::repositories::{AnyResult, RepositoryContext};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use std::sync::Arc; use std::sync::Arc;
@@ -63,4 +63,49 @@ impl RoleRepository {
.await?; .await?;
Ok(res.rows_affected > 0) 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)
}
} }
+18
View File
@@ -84,6 +84,14 @@ impl ServerRepository {
Ok(true) 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> { pub async fn delete(&self, id: Uuid) -> AnyResult<bool> {
let res = server::Entity::delete_by_id(id) let res = server::Entity::delete_by_id(id)
.exec(&self.context.db) .exec(&self.context.db)
@@ -108,6 +116,16 @@ impl ServerRepository {
.await?) .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( pub async fn set_user_permission(
&self, &self,
server_id: Uuid, server_id: Uuid,
+32 -2
View File
@@ -1,8 +1,8 @@
use crate::models::{category, channel, computed_permission, server_item_order}; use crate::models::{category, channel, channel_user_read_state, computed_permission, message, server_item_order};
use crate::permissions::ChannelPermission; use crate::permissions::ChannelPermission;
use crate::repositories::types::{CategoryWithPermissions, ChannelWithPermissions, ServerTreeData}; use crate::repositories::types::{CategoryWithPermissions, ChannelWithPermissions, ServerTreeData};
use crate::repositories::{AnyResult, RepositoryContext}; use crate::repositories::{AnyResult, RepositoryContext};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@@ -70,6 +70,35 @@ impl ServerTreeRepository {
}) })
.collect(); .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 let channels = channel_models
.into_iter() .into_iter()
.map(|channel| { .map(|channel| {
@@ -87,6 +116,7 @@ impl ServerTreeRepository {
orders, orders,
categories, categories,
channels, channels,
unread_counts,
}) })
} }
} }
+2
View File
@@ -24,6 +24,7 @@ pub struct ServerTree {
pub struct MessageFilter { pub struct MessageFilter {
pub channel_id: Option<uuid::Uuid>, pub channel_id: Option<uuid::Uuid>,
pub before_id: Option<uuid::Uuid>, pub before_id: Option<uuid::Uuid>,
pub after_id: Option<uuid::Uuid>,
pub limit: Option<u64>, pub limit: Option<u64>,
} }
@@ -75,4 +76,5 @@ pub struct ServerTreeData {
pub orders: Vec<server_item_order::Model>, pub orders: Vec<server_item_order::Model>,
pub categories: Vec<CategoryWithPermissions>, pub categories: Vec<CategoryWithPermissions>,
pub channels: Vec<ChannelWithPermissions>, pub channels: Vec<ChannelWithPermissions>,
pub unread_counts: std::collections::HashMap<Uuid, u64>,
} }
+109 -3
View File
@@ -1,9 +1,10 @@
use crate::core::state::AppState; use crate::core::state::AppState;
use crate::http::context::Superuser; use crate::http::context::{CurrentUser, Superuser};
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::domain::dto::channel::{ use crate::domain::dto::channel::{
ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse, ChannelQueryParams, ChannelResponse, ChannelPermissionsResponse, ChannelRolePermissionResponse,
ChannelUserPermissionResponse, CreateChannelRequest, SetChannelPermissionRequest, ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse,
SetChannelPermissionRequest, SetReadStateRequest,
UpdateChannelRequest, UpdateChannelRequest,
}; };
use crate::routes::channel::mapper; use crate::routes::channel::mapper;
@@ -41,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 /// Récupère un channel par son ID
#[utoipa::path( #[utoipa::path(
get, get,
@@ -69,6 +155,26 @@ pub async fn get_by_id(
Ok(Json(mapper::channel_model_to_channel_response(channel))) 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 /// Crée un nouveau channel
#[utoipa::path( #[utoipa::path(
post, post,
+12 -1
View File
@@ -1,5 +1,5 @@
use crate::domain::dto::channel::{ use crate::domain::dto::channel::{
ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse, ChannelPermissionsResponse, ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
ChannelUserPermissionResponse, CreateChannelRequest, UpdateChannelRequest, ChannelUserPermissionResponse, CreateChannelRequest, UpdateChannelRequest,
}; };
use crate::models::{channel, channel_role_permission, channel_user_permission}; use crate::models::{channel, channel_role_permission, channel_user_permission};
@@ -23,6 +23,7 @@ pub fn channel_model_to_channel_response_with_permission(
name: model.name, name: model.name,
created_at: model.created_at, created_at: model.created_at,
updated_at: model.updated_at, updated_at: model.updated_at,
unread_count: None,
permission, permission,
} }
} }
@@ -71,6 +72,16 @@ pub fn channel_role_permission_to_response(
} }
} }
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 { pub fn query_params_to_channel_filter(params: ChannelQueryParams) -> ChannelFilter {
ChannelFilter { ChannelFilter {
server_id: params.server_id, server_id: params.server_id,
+8
View File
@@ -11,6 +11,10 @@ pub fn router() -> Router<AppState> {
.put(handlers::update) .put(handlers::update)
.delete(handlers::delete), .delete(handlers::delete),
) )
.route(
"/channels/{channel_id}/permissions",
get(handlers::list_permissions),
)
.route( .route(
"/channels/{channel_id}/permissions/users/{user_id}", "/channels/{channel_id}/permissions/users/{user_id}",
get(handlers::get_user_permission) get(handlers::get_user_permission)
@@ -23,4 +27,8 @@ pub fn router() -> Router<AppState> {
.put(handlers::set_role_permission) .put(handlers::set_role_permission)
.delete(handlers::remove_role_permission), .delete(handlers::remove_role_permission),
) )
.route(
"/channels/{channel_id}/read-state",
get(handlers::get_read_state).put(handlers::set_read_state),
)
} }
+2 -3
View File
@@ -4,8 +4,8 @@ use crate::models::user::Model as User;
use crate::routes::gateway::GatewayClient; use crate::routes::gateway::GatewayClient;
use axum::{ use axum::{
extract::{ extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State, State,
ws::{Message, WebSocket, WebSocketUpgrade},
}, },
response::IntoResponse, response::IntoResponse,
}; };
@@ -56,7 +56,6 @@ async fn handle_socket(socket: WebSocket, state: AppState, user: User) {
// Task pour recevoir les messages du WebSocket // Task pour recevoir les messages du WebSocket
let client_clone = client.clone(); let client_clone = client.clone();
let state_clone = state.clone();
let mut recv_task = tokio::spawn(async move { let mut recv_task = tokio::spawn(async move {
while let Some(Ok(message)) = receiver.next().await { while let Some(Ok(message)) = receiver.next().await {
client_clone.on_message(message).await; client_clone.on_message(message).await;
@@ -69,7 +68,7 @@ async fn handle_socket(socket: WebSocket, state: AppState, user: User) {
_ = (&mut recv_task) => send_task.abort(), _ = (&mut recv_task) => send_task.abort(),
}; };
state.gateway.remove_client(client.clone()); state.gateway.remove_client(&client);
// // Déconnexion (Disconnect) // // Déconnexion (Disconnect)
client.on_disconnect().await; client.on_disconnect().await;
} }
+101 -42
View File
@@ -1,11 +1,10 @@
use crate::models::category; use crate::domain::events::message::{
use crate::models::channel; MessageCreatedEvent, MessageDeletedEvent, MessageUpdatedEvent,
use crate::models::message; };
use crate::models::server;
use crate::models::user::Model as User; use crate::models::user::Model as User;
use crate::routes::category::mapper::category_model_to_category_response; use crate::routes::category::mapper::category_model_to_category_response;
use crate::routes::channel::mapper::channel_model_to_channel_response; use crate::routes::channel::mapper::channel_model_to_channel_response;
use crate::routes::message::mapper::message_model_to_message_response; use crate::routes::message::mapper::message_model_to_message_response_with_server_id;
use crate::routes::server::mapper::server_model_to_server_response; use crate::routes::server::mapper::server_model_to_server_response;
use axum::extract::ws::Message; use axum::extract::ws::Message;
use event_bus::EventBus; use event_bus::EventBus;
@@ -16,15 +15,22 @@ use std::sync::Arc;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use uuid::Uuid; use uuid::Uuid;
use crate::services::Services;
pub mod events; pub mod events;
pub mod handlers; pub mod handlers;
pub mod routes; pub mod routes;
#[derive(Debug, Default)] #[derive(Debug)]
pub struct GatewayManager { pub struct GatewayManager {
// {UserID: {connection_id: GatewayClient}} pub clients: RwLock<HashMap<ConnectionKey, GatewayClient>>,
pub clients: RwLock<HashMap<Uuid, HashMap<Uuid, GatewayClient>>>, services: Arc<Services>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ConnectionKey {
pub user_id: Uuid,
pub connection_id: Uuid,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -37,19 +43,82 @@ pub struct GatewayClient {
} }
impl GatewayManager { impl GatewayManager {
fn add_client(&self, gateway_client: GatewayClient) { pub fn new(services: Arc<Services>) -> Self {
let mut clients = self.clients.write(); Self { clients: RwLock::new(HashMap::new()), services }
let user_id = gateway_client.user.id; }
clients /// Démarre les routeurs centraux des événements de messages.
.entry(user_id) pub fn start(self: &Arc<Self>, event_bus: Arc<EventBus>) {
.or_insert_with(HashMap::new) let manager = Arc::clone(self);
.insert(gateway_client.connection_id, gateway_client); event_bus.on_async::<MessageCreatedEvent, _, _>("message_created", move |event| {
let manager = Arc::clone(&manager);
async move {
manager.broadcast_message(
event.channel_id,
"add",
message_model_to_message_response_with_server_id(
event.message,
event.server_id,
),
);
}
});
let manager = Arc::clone(self);
event_bus.on_async::<MessageUpdatedEvent, _, _>("message_updated", move |event| {
let manager = Arc::clone(&manager);
async move {
manager.broadcast_message(
event.channel_id,
"update",
message_model_to_message_response_with_server_id(
event.message,
event.server_id,
),
);
}
});
let manager = Arc::clone(self);
event_bus.on_async::<MessageDeletedEvent, _, _>("message_deleted", move |event| {
let manager = Arc::clone(&manager);
async move {
manager.broadcast_message(event.channel_id, "remove", event.message.id);
}
});
} }
fn remove_client(&self, gateway_client: GatewayClient) { pub(crate) fn add_client(&self, gateway_client: GatewayClient) {
let mut clients = self.clients.write(); let key = gateway_client.key();
if let Some(client_list) = clients.get_mut(&gateway_client.user.id) { self.clients.write().insert(key, gateway_client);
client_list.remove(&gateway_client.connection_id); }
pub(crate) fn remove_client(&self, gateway_client: &GatewayClient) {
let key = gateway_client.key();
self.clients.write().remove(&key);
}
fn broadcast_message<T: serde::Serialize>(
&self,
channel_id: Uuid,
action: &'static str,
content: T,
) {
let event = GatewayEvent {
namespace: "Message",
action,
content,
};
let Ok(json) = serde_json::to_string(&event) else {
return;
};
let users = self.services.realtime_registry.users_for_channel(channel_id);
let clients = self.clients.read();
for (key, client) in clients.iter() {
if users.contains(&key.user_id) {
let _ = client.sender.send(Message::Text(json.clone().into()));
}
} }
} }
} }
@@ -60,16 +129,22 @@ impl GatewayClient {
sender: mpsc::UnboundedSender<Message>, sender: mpsc::UnboundedSender<Message>,
event_bus: Arc<EventBus>, event_bus: Arc<EventBus>,
) -> Self { ) -> Self {
let connection_id = Uuid::new_v4();
Self { Self {
user, user,
connection_id, connection_id: Uuid::new_v4(),
sender, sender,
event_bus, event_bus,
_event_handles: Vec::new(), _event_handles: Vec::new(),
} }
} }
pub fn key(&self) -> ConnectionKey {
ConnectionKey {
user_id: self.user.id,
connection_id: self.connection_id,
}
}
fn subscribe_event<T, F, R>( fn subscribe_event<T, F, R>(
&self, &self,
event_name: &'static str, event_name: &'static str,
@@ -105,22 +180,8 @@ impl GatewayClient {
pub fn subscribe_to_events(&mut self) { pub fn subscribe_to_events(&mut self) {
let mut handles = Vec::new(); let mut handles = Vec::new();
// Message // Les messages sont routés par GatewayManager selon le channel_id.
handles.push(self.subscribe_event(
"message_created",
"Message",
"add",
message_model_to_message_response,
));
handles.push(self.subscribe_event(
"message_updated",
"Message",
"update",
message_model_to_message_response,
));
handles.push(self.subscribe_event("message_deleted", "Message", "remove", |id: Uuid| id));
// Channel
handles.push(self.subscribe_event( handles.push(self.subscribe_event(
"channel_created", "channel_created",
"Channel", "Channel",
@@ -135,7 +196,6 @@ impl GatewayClient {
)); ));
handles.push(self.subscribe_event("channel_deleted", "Channel", "remove", |id: Uuid| id)); handles.push(self.subscribe_event("channel_deleted", "Channel", "remove", |id: Uuid| id));
// Category
handles.push(self.subscribe_event( handles.push(self.subscribe_event(
"category_created", "category_created",
"Category", "Category",
@@ -150,7 +210,6 @@ impl GatewayClient {
)); ));
handles.push(self.subscribe_event("category_deleted", "Category", "remove", |id: Uuid| id)); handles.push(self.subscribe_event("category_deleted", "Category", "remove", |id: Uuid| id));
// Server
handles.push(self.subscribe_event( handles.push(self.subscribe_event(
"server_created", "server_created",
"Server", "Server",
@@ -175,20 +234,20 @@ impl GatewayClient {
} }
async fn on_connect(&mut self) { async fn on_connect(&mut self) {
tracing::info!("Client connected: {:?}", self.user); tracing::info!(user_id = %self.user.id, "Client connected");
self.subscribe_to_events(); self.subscribe_to_events();
} }
async fn on_disconnect(&mut self) { async fn on_disconnect(&mut self) {
tracing::info!("Client disconnected: {:?}", self.user); tracing::info!(user_id = %self.user.id, "Client disconnected");
self.unsubscribe_all(); self.unsubscribe_all();
} }
async fn on_message(&self, message: Message) { async fn on_message(&self, message: Message) {
match message { match message {
Message::Binary(content) => {} Message::Binary(_) => {}
Message::Text(content) => { Message::Text(content) => {
tracing::info!("Received text message: {}", content); tracing::info!(user_id = %self.user.id, "Received text message: {}", content);
} }
Message::Ping(_) => {} Message::Ping(_) => {}
Message::Pong(_) => {} Message::Pong(_) => {}
+42 -13
View File
@@ -1,21 +1,25 @@
use crate::domain::dto::message::{CreateMessageRequest, MessageQueryParams, MessageResponse, UpdateMessageRequest};
use crate::core::state::AppState; use crate::core::state::AppState;
use crate::domain::dto::message::{
CreateMessageRequest, MessagePageResponse, MessageQueryParams, MessageResponse,
UpdateMessageRequest,
};
use crate::http::context::CurrentUser; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::routes::message::mapper; use crate::routes::message::mapper;
use axum::{ use axum::{
Json,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
Json,
}; };
use uuid::Uuid; use uuid::Uuid;
/// Liste tous les messages /// Liste une fenêtre paginée de messages
#[utoipa::path( #[utoipa::path(
get, get,
path = "/messages", path = "/messages",
responses( responses(
(status = 200, description = "Liste des messages récupérée avec succès", body = [MessageResponse]), (status = 200, description = "Fenêtre de messages récupérée avec succès", body = MessagePageResponse),
(status = 400, description = "Curseurs incompatibles ou canal manquant"),
(status = 500, description = "Erreur interne du serveur") (status = 500, description = "Erreur interne du serveur")
), ),
params( params(
@@ -26,15 +30,29 @@ use uuid::Uuid;
pub async fn get_all( pub async fn get_all(
State(state): State<AppState>, State(state): State<AppState>,
Query(filters): Query<MessageQueryParams>, Query(filters): Query<MessageQueryParams>,
) -> Result<Json<Vec<MessageResponse>>, HTTPError> { ) -> Result<Json<MessagePageResponse>, HTTPError> {
if filters.before_id.is_some() && filters.after_id.is_some() {
return Err(HTTPError::BadRequest(
"before_id and after_id cannot be used together".to_string(),
));
}
let params = mapper::query_params_to_message_filter(filters); let params = mapper::query_params_to_message_filter(filters);
let messages = state.repositories.message.filter(params).await?; let page = state.repositories.message.filter(params).await?;
Ok(Json( let oldest_id = page.messages.first().map(|message| message.id);
messages let newest_id = page.messages.last().map(|message| message.id);
Ok(Json(MessagePageResponse {
messages: page
.messages
.into_iter() .into_iter()
.map(mapper::message_model_to_message_response) .map(mapper::message_model_to_message_response)
.collect(), .collect(),
)) oldest_id,
newest_id,
has_more_before: page.has_more_before,
has_more_after: page.has_more_after,
}))
} }
/// Récupère un message par son ID /// Récupère un message par son ID
@@ -86,7 +104,7 @@ pub async fn create(
Json(payload): Json<CreateMessageRequest>, Json(payload): Json<CreateMessageRequest>,
) -> Result<(StatusCode, Json<MessageResponse>), HTTPError> { ) -> Result<(StatusCode, Json<MessageResponse>), HTTPError> {
// Vérifier que le canal existe // Vérifier que le canal existe
state let channel = state
.repositories .repositories
.channel .channel
.get_by_id(payload.channel_id) .get_by_id(payload.channel_id)
@@ -105,10 +123,17 @@ pub async fn create(
))?; ))?;
} }
let message = state.services.message.create_message(payload.channel_id, user.id, payload.content).await?; let message = state
.services
.message
.create_message(payload.channel_id, user.id, payload.content)
.await?;
Ok(( Ok((
StatusCode::CREATED, StatusCode::CREATED,
Json(mapper::message_model_to_message_response(message)), Json(mapper::message_model_to_message_response_with_server_id(
message,
channel.server_id,
)),
)) ))
} }
@@ -150,7 +175,11 @@ pub async fn update(
return Err(HTTPError::Forbidden); return Err(HTTPError::Forbidden);
} }
let message = state.services.message.update_message(id, payload.content).await?; let message = state
.services
.message
.update_message(id, payload.content)
.await?;
Ok(Json(mapper::message_model_to_message_response(message))) Ok(Json(mapper::message_model_to_message_response(message)))
} }
+12 -3
View File
@@ -1,15 +1,23 @@
use crate::models::message;
use crate::repositories::types::MessageFilter;
use crate::domain::dto::message::{ use crate::domain::dto::message::{
CreateMessageRequest, MessageQueryParams, MessageResponse, UpdateMessageRequest, CreateMessageRequest, MessageQueryParams, MessageResponse, UpdateMessageRequest,
}; };
use crate::models::message;
use crate::repositories::types::MessageFilter;
use chrono::Utc; use chrono::Utc;
use sea_orm::Set; use sea_orm::Set;
use uuid::Uuid; use uuid::Uuid;
pub fn message_model_to_message_response(model: message::Model) -> MessageResponse { pub fn message_model_to_message_response(model: message::Model) -> MessageResponse {
message_model_to_message_response_with_server_id(model, None)
}
pub fn message_model_to_message_response_with_server_id(
model: message::Model,
server_id: Option<Uuid>,
) -> MessageResponse {
MessageResponse { MessageResponse {
id: model.id, id: model.id,
server_id,
channel_id: model.channel_id, channel_id: model.channel_id,
user_id: model.user_id, user_id: model.user_id,
content: model.content, content: model.content,
@@ -48,8 +56,9 @@ pub fn update_request_to_am(
pub fn query_params_to_message_filter(params: MessageQueryParams) -> MessageFilter { pub fn query_params_to_message_filter(params: MessageQueryParams) -> MessageFilter {
MessageFilter { MessageFilter {
channel_id: params.channel_id, channel_id: Some(params.channel_id),
before_id: params.before_id, before_id: params.before_id,
after_id: params.after_id,
limit: params.limit, limit: params.limit,
} }
} }
+2
View File
@@ -60,6 +60,8 @@ use utoipa::{Modify, OpenApi};
crate::domain::dto::channel::ChannelResponse, crate::domain::dto::channel::ChannelResponse,
crate::domain::dto::channel::CreateChannelRequest, crate::domain::dto::channel::CreateChannelRequest,
crate::domain::dto::channel::UpdateChannelRequest, crate::domain::dto::channel::UpdateChannelRequest,
crate::domain::dto::channel::ReadStateResponse,
crate::domain::dto::channel::SetReadStateRequest,
crate::domain::dto::role::RoleResponse, crate::domain::dto::role::RoleResponse,
crate::domain::dto::role::CreateRoleRequest, crate::domain::dto::role::CreateRoleRequest,
crate::domain::dto::role::UpdateRoleRequest, crate::domain::dto::role::UpdateRoleRequest,
+97 -128
View File
@@ -1,162 +1,131 @@
use crate::core::state::AppState; use crate::core::state::AppState;
use crate::http::context::Superuser; use crate::domain::dto::role::{CreateRoleRequest, RoleQueryParams, RoleResponse, UpdateRoleRequest};
use crate::domain::dto::user::UserResponse;
use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::domain::dto::role::{CreateRoleRequest, RoleResponse, UpdateRoleRequest}; use crate::permissions::ServerPermission;
use crate::routes::role::mapper; use crate::routes::role::mapper;
use axum::{ use crate::routes::user::mapper as user_mapper;
Json, use axum::{Json, extract::{Path, Query, State}, http::StatusCode};
extract::{Path, State},
http::StatusCode,
};
use uuid::Uuid; use uuid::Uuid;
/// Liste tous les groupes async fn require_permission(
#[utoipa::path( state: &AppState,
get, user: &CurrentUser,
path = "/groups", server_id: Uuid,
responses( permission: ServerPermission,
(status = 200, description = "Liste des groupes récupérée avec succès", body = [RoleResponse]), ) -> Result<(), HTTPError> {
(status = 500, description = "Erreur interne du serveur") if user.is_superuser {
), return Ok(());
tag = "Roles" }
)]
pub async fn get_all(State(state): State<AppState>) -> Result<Json<Vec<RoleResponse>>, HTTPError> { let granted = state
let groups = state.repositories.role.get_all().await?; .repositories
Ok(Json( .server
groups .get_user_permission(server_id, user.id)
.into_iter() .await?
.map(mapper::group_model_to_group_response) .map(|value| ServerPermission::from_bits_truncate(value.permissions as u64))
.collect(), .unwrap_or_default();
))
if granted.contains(permission) {
Ok(())
} else {
Err(HTTPError::Forbidden)
}
} }
/// Récupère un groupe par son ID #[utoipa::path(get, path = "/roles", params(RoleQueryParams), responses((status = 200, body = [RoleResponse])), tag = "Roles")]
#[utoipa::path( pub async fn get_all(
get, State(state): State<AppState>,
path = "/groups/{id}", Query(filters): Query<RoleQueryParams>,
responses( ) -> Result<Json<Vec<RoleResponse>>, HTTPError> {
(status = 200, description = "Rolee trouvé", body = RoleResponse), let roles = match filters.server_id {
(status = 404, description = "Rolee non trouvé"), Some(server_id) => state.repositories.role.get_all_by_server(server_id).await?,
(status = 500, description = "Erreur interne du serveur") None => state.repositories.role.get_all().await?,
), };
params(
("id" = Uuid, Path, description = "ID du groupe") Ok(Json(roles.into_iter().map(mapper::role_model_to_role_response).collect()))
), }
tag = "Roles"
)] #[utoipa::path(get, path = "/roles/{id}", params(("id" = Uuid, Path)), responses((status = 200, body = RoleResponse), (status = 404)), tag = "Roles")]
pub async fn get_by_id( pub async fn get_by_id(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<RoleResponse>, HTTPError> { ) -> Result<Json<RoleResponse>, HTTPError> {
let group = state let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
.repositories Ok(Json(mapper::role_model_to_role_response(role)))
.role
.get_by_id(id)
.await?
.ok_or(HTTPError::NotFound)?;
Ok(Json(mapper::group_model_to_group_response(group)))
} }
/// Crée un nouveau groupe #[utoipa::path(post, path = "/roles", request_body = CreateRoleRequest, responses((status = 201, body = RoleResponse)), tag = "Roles", security(("bearerAuth" = [])))]
#[utoipa::path(
post,
path = "/groups",
request_body = CreateRoleRequest,
responses(
(status = 201, description = "Role créé avec succès", body = RoleResponse),
(status = 404, description = "Serveur non trouvé"),
(status = 500, description = "Erreur interne du serveur")
),
tag = "Roles",
security(
("bearerAuth" = [])
)
)]
pub async fn create( pub async fn create(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<CreateRoleRequest>, Json(payload): Json<CreateRoleRequest>,
) -> Result<(StatusCode, Json<RoleResponse>), HTTPError> { ) -> Result<(StatusCode, Json<RoleResponse>), HTTPError> {
// Vérifier que le serveur existe state.repositories.server.get_by_id(payload.server_id).await?
state
.repositories
.server
.get_by_id(payload.server_id)
.await?
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?; .ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
require_permission(&state, &user, payload.server_id, ServerPermission::MANAGE_ROLES).await?;
let active_model = mapper::create_request_to_am(payload); let role = state.services.role.create_role(mapper::create_request_to_am(payload)).await?;
let group = state.services.role.create_role(active_model).await?; Ok((StatusCode::CREATED, Json(mapper::role_model_to_role_response(role))))
Ok((
StatusCode::CREATED,
Json(mapper::group_model_to_group_response(group)),
))
} }
/// Met à jour un groupe existant #[utoipa::path(put, path = "/roles/{id}", request_body = UpdateRoleRequest, responses((status = 200, body = RoleResponse), (status = 404)), tag = "Roles", security(("bearerAuth" = [])))]
#[utoipa::path(
put,
path = "/groups/{id}",
request_body = UpdateRoleRequest,
responses(
(status = 200, description = "Role mis à jour avec succès", body = RoleResponse),
(status = 404, description = "Role non trouvé"),
(status = 500, description = "Erreur interne du serveur")
),
params(
("id" = Uuid, Path, description = "ID du groupe")
),
tag = "Roles",
security(
("bearerAuth" = [])
)
)]
pub async fn update( pub async fn update(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<UpdateRoleRequest>, Json(payload): Json<UpdateRoleRequest>,
) -> Result<Json<RoleResponse>, HTTPError> { ) -> Result<Json<RoleResponse>, HTTPError> {
// Vérifier l'existence let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
let group = state require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_ROLES).await?;
.repositories
.role
.get_by_id(id)
.await?
.ok_or(HTTPError::NotFound)?;
let active_model = mapper::update_request_to_am(group.id, group.server_id, payload); let role = state.services.role.update_role(mapper::update_request_to_am(role.id, role.server_id, payload)).await?;
let group = state.services.role.update_role(active_model).await?; Ok(Json(mapper::role_model_to_role_response(role)))
Ok(Json(mapper::group_model_to_group_response(group)))
} }
/// Supprime un groupe #[utoipa::path(delete, path = "/roles/{id}", responses((status = 204), (status = 404)), tag = "Roles", security(("bearerAuth" = [])))]
#[utoipa::path(
delete,
path = "/groups/{id}",
responses(
(status = 204, description = "Role supprimé avec succès"),
(status = 404, description = "Role non trouvé"),
(status = 500, description = "Erreur interne du serveur")
),
params(
("id" = Uuid, Path, description = "ID du groupe")
),
tag = "Roles",
security(
("bearerAuth" = [])
)
)]
pub async fn delete( pub async fn delete(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
if state.services.role.delete_role(id).await? { let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
Ok(StatusCode::NO_CONTENT) require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_ROLES).await?;
} else {
Err(HTTPError::NotFound) if state.services.role.delete_role(id).await? { Ok(StatusCode::NO_CONTENT) } else { Err(HTTPError::NotFound) }
} }
pub async fn get_members(
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<Vec<UserResponse>>, HTTPError> {
let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_MEMBERS).await?;
let members = state.repositories.role.get_members(id).await?;
Ok(Json(members.into_iter().map(user_mapper::user_model_to_user_response).collect()))
}
pub async fn add_member(
user: CurrentUser,
State(state): State<AppState>,
Path((id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, HTTPError> {
let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_MEMBERS).await?;
state.repositories.server.get_user(role.server_id, user_id).await?
.ok_or(HTTPError::BadRequest("User is not a member of this server".to_string()))?;
state.services.role.add_member(id, user_id, role.server_id).await?;
Ok(StatusCode::NO_CONTENT)
}
pub async fn remove_member(
user: CurrentUser,
State(state): State<AppState>,
Path((id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, HTTPError> {
let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_MEMBERS).await?;
if state.services.role.remove_member(id, user_id, role.server_id).await? { Ok(StatusCode::NO_CONTENT) } else { Err(HTTPError::NotFound) }
} }
+1 -1
View File
@@ -3,7 +3,7 @@ use crate::domain::dto::role::{CreateRoleRequest, RoleResponse, UpdateRoleReques
use sea_orm::Set; use sea_orm::Set;
use uuid::Uuid; use uuid::Uuid;
pub fn group_model_to_group_response(model: role::Model) -> RoleResponse { pub fn role_model_to_role_response(model: role::Model) -> RoleResponse {
RoleResponse { RoleResponse {
id: model.id, id: model.id,
server_id: model.server_id, server_id: model.server_id,
+5 -3
View File
@@ -1,15 +1,17 @@
use crate::core::state::AppState; use crate::core::state::AppState;
use axum::{routing::get, Router}; use axum::{routing::{get, put}, Router};
use super::handlers; use super::handlers;
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/groups", get(handlers::get_all).post(handlers::create)) .route("/roles", get(handlers::get_all).post(handlers::create))
.route( .route(
"/groups/{id}", "/roles/{id}",
get(handlers::get_by_id) get(handlers::get_by_id)
.put(handlers::update) .put(handlers::update)
.delete(handlers::delete), .delete(handlers::delete),
) )
.route("/roles/{id}/members", get(handlers::get_members))
.route("/roles/{id}/members/{user_id}", put(handlers::add_member).delete(handlers::remove_member))
} }
+67 -2
View File
@@ -5,6 +5,7 @@ use crate::domain::dto::server::{
}; };
use crate::http::context::{CurrentUser, Superuser}; use crate::http::context::{CurrentUser, Superuser};
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::permissions::ServerPermission;
use crate::routes::server::mapper; use crate::routes::server::mapper;
use axum::{ use axum::{
Json, Json,
@@ -13,6 +14,27 @@ use axum::{
}; };
use uuid::Uuid; use uuid::Uuid;
async fn require_server_permission(
state: &AppState,
user: &CurrentUser,
server_id: Uuid,
permission: ServerPermission,
) -> Result<(), HTTPError> {
if user.is_superuser {
return Ok(());
}
let granted = state
.repositories
.server
.get_user_permission(server_id, user.id)
.await?
.map(|value| ServerPermission::from_bits_truncate(value.permissions as u64))
.unwrap_or_default();
if granted.contains(permission) { Ok(()) } else { Err(HTTPError::Forbidden) }
}
/// Liste tous les serveurs /// Liste tous les serveurs
#[utoipa::path( #[utoipa::path(
get, get,
@@ -21,16 +43,29 @@ use uuid::Uuid;
(status = 200, description = "Liste des serveurs récupérée avec succès", body = [ServerResponse]), (status = 200, description = "Liste des serveurs récupérée avec succès", body = [ServerResponse]),
(status = 500, description = "Erreur interne du serveur") (status = 500, description = "Erreur interne du serveur")
), ),
security(("bearerAuth" = [])),
tag = "Servers" tag = "Servers"
)] )]
pub async fn get_all( pub async fn get_all(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
) -> Result<Json<Vec<ServerResponse>>, HTTPError> { ) -> Result<Json<Vec<ServerResponse>>, HTTPError> {
let servers = state.repositories.server.get_all().await?; let servers = state.repositories.server.get_all().await?;
let unread_counts = state
.repositories
.read_state
.unread_counts_by_server(user.id)
.await?;
Ok(Json( Ok(Json(
servers servers
.into_iter() .into_iter()
.map(mapper::server_model_to_server_response) .map(|server| {
let server_id = server.id;
mapper::server_model_to_server_response_with_unread_count(
server,
unread_counts.get(&server_id).copied().unwrap_or(0),
)
})
.collect(), .collect(),
)) ))
} }
@@ -112,7 +147,7 @@ pub async fn create(
) )
)] )]
pub async fn update( pub async fn update(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<UpdateServerRequest>, Json(payload): Json<UpdateServerRequest>,
@@ -125,6 +160,8 @@ pub async fn update(
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_server_permission(&state, &user, id, ServerPermission::MANAGE_SERVER).await?;
let server = state let server = state
.services .services
.server .server
@@ -192,6 +229,23 @@ pub async fn get_user_permission(
Ok(Json(mapper::server_user_permission_to_response(permission))) Ok(Json(mapper::server_user_permission_to_response(permission)))
} }
/// Liste les permissions directes des utilisateurs d'un serveur.
#[utoipa::path(
get,
path = "/servers/{server_id}/permissions/users",
params(("server_id" = Uuid, Path, description = "ID du serveur")),
responses((status = 200, body = [ServerUserPermissionResponse])),
tag = "Server Permissions"
)]
pub async fn list_user_permissions(
State(state): State<AppState>,
Path(server_id): Path<Uuid>,
) -> Result<Json<Vec<ServerUserPermissionResponse>>, HTTPError> {
state.repositories.server.get_by_id(server_id).await?.ok_or(HTTPError::NotFound)?;
let permissions = state.repositories.server.get_user_permissions(server_id).await?;
Ok(Json(permissions.into_iter().map(mapper::server_user_permission_to_response).collect()))
}
/// Définit ou remplace les permissions directes d'un utilisateur sur un serveur. /// Définit ou remplace les permissions directes d'un utilisateur sur un serveur.
#[utoipa::path( #[utoipa::path(
put, put,
@@ -208,10 +262,14 @@ pub async fn get_user_permission(
tag = "Server Permissions" tag = "Server Permissions"
)] )]
pub async fn set_user_permission( pub async fn set_user_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((server_id, user_id)): Path<(Uuid, Uuid)>, Path((server_id, user_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<SetServerPermissionRequest>, Json(payload): Json<SetServerPermissionRequest>,
) -> Result<Json<ServerUserPermissionResponse>, HTTPError> { ) -> Result<Json<ServerUserPermissionResponse>, HTTPError> {
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?;
state.repositories.server.get_user(server_id, user_id).await?
.ok_or(HTTPError::BadRequest("User is not a member of this server".to_string()))?;
state state
.repositories .repositories
.server .server
@@ -244,9 +302,11 @@ pub async fn set_user_permission(
tag = "Server Permissions" tag = "Server Permissions"
)] )]
pub async fn remove_user_permission( pub async fn remove_user_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((server_id, user_id)): Path<(Uuid, Uuid)>, Path((server_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?;
if state if state
.repositories .repositories
.server .server
@@ -311,10 +371,12 @@ pub async fn get_role_permission(
tag = "Server Permissions" tag = "Server Permissions"
)] )]
pub async fn set_role_permission( pub async fn set_role_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((server_id, role_id)): Path<(Uuid, Uuid)>, Path((server_id, role_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<SetServerPermissionRequest>, Json(payload): Json<SetServerPermissionRequest>,
) -> Result<Json<ServerRolePermissionResponse>, HTTPError> { ) -> Result<Json<ServerRolePermissionResponse>, HTTPError> {
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_ROLES).await?;
state state
.repositories .repositories
.server .server
@@ -347,9 +409,11 @@ pub async fn set_role_permission(
tag = "Server Permissions" tag = "Server Permissions"
)] )]
pub async fn remove_role_permission( pub async fn remove_role_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((server_id, role_id)): Path<(Uuid, Uuid)>, Path((server_id, role_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_ROLES).await?;
if state if state
.repositories .repositories
.server .server
@@ -405,5 +469,6 @@ pub async fn get_tree(
tree.orders, tree.orders,
tree.channels, tree.channels,
tree.categories, tree.categories,
tree.unread_counts,
))) )))
} }
+17 -2
View File
@@ -17,9 +17,19 @@ pub fn server_model_to_server_response(model: server::Model) -> ServerResponse {
is_default: model.is_default, is_default: model.is_default,
created_at: model.created_at, created_at: model.created_at,
updated_at: model.updated_at, updated_at: model.updated_at,
unread_count: None,
} }
} }
pub fn server_model_to_server_response_with_unread_count(
model: server::Model,
unread_count: u64,
) -> ServerResponse {
let mut response = server_model_to_server_response(model);
response.unread_count = Some(unread_count);
response
}
pub fn create_request_to_am(req: CreateServerRequest) -> server::ActiveModel { pub fn create_request_to_am(req: CreateServerRequest) -> server::ActiveModel {
server::ActiveModel { server::ActiveModel {
id: Set(Uuid::new_v4()), id: Set(Uuid::new_v4()),
@@ -66,6 +76,7 @@ pub fn build_server_tree(
orders: Vec<server_item_order::Model>, orders: Vec<server_item_order::Model>,
channels: Vec<ChannelWithPermissions>, channels: Vec<ChannelWithPermissions>,
categories: Vec<CategoryWithPermissions>, categories: Vec<CategoryWithPermissions>,
unread_counts: HashMap<Uuid, u64>,
) -> ServerTreeResponse { ) -> ServerTreeResponse {
let order_map: HashMap<(Option<Uuid>, Uuid), i64> = orders let order_map: HashMap<(Option<Uuid>, Uuid), i64> = orders
.into_iter() .into_iter()
@@ -119,7 +130,9 @@ pub fn build_server_tree(
.into_iter() .into_iter()
.map(|c| { .map(|c| {
let chan_perm_bits = c.permissions.map(|p| p.bits()).unwrap_or(0); let chan_perm_bits = c.permissions.map(|p| p.bits()).unwrap_or(0);
channel_model_to_channel_response_with_permission(c.channel, Some(chan_perm_bits)) let mut response = channel_model_to_channel_response_with_permission(c.channel, Some(chan_perm_bits));
response.unread_count = Some(*unread_counts.get(&response.id).unwrap_or(&0));
response
}) })
.collect(); .collect();
@@ -135,10 +148,11 @@ pub fn build_server_tree(
.copied() .copied()
.unwrap_or(i64::MAX); .unwrap_or(i64::MAX);
let chan_perm_bits = chan_with_perm.permissions.map(|p| p.bits()).unwrap_or(0); let chan_perm_bits = chan_with_perm.permissions.map(|p| p.bits()).unwrap_or(0);
let chan_response = channel_model_to_channel_response_with_permission( let mut chan_response = channel_model_to_channel_response_with_permission(
chan_with_perm.channel, chan_with_perm.channel,
Some(chan_perm_bits), Some(chan_perm_bits),
); );
chan_response.unread_count = Some(*unread_counts.get(&chan_response.id).unwrap_or(&0));
root_items.push(( root_items.push((
ServerExplorerItemResponse::Channel(chan_response), ServerExplorerItemResponse::Channel(chan_response),
order_key, order_key,
@@ -232,6 +246,7 @@ mod tests {
channel(second_id, Some(category_id), "first"), channel(second_id, Some(category_id), "first"),
], ],
vec![category], vec![category],
HashMap::new(),
); );
let ServerExplorerItemResponse::Category(_, channels) = &response.items[0] else { let ServerExplorerItemResponse::Category(_, channels) = &response.items[0] else {
+4
View File
@@ -12,6 +12,10 @@ pub fn router() -> Router<AppState> {
.delete(handlers::delete), .delete(handlers::delete),
) )
.route("/servers/{server_id}/tree", get(handlers::get_tree)) .route("/servers/{server_id}/tree", get(handlers::get_tree))
.route(
"/servers/{server_id}/permissions/users",
get(handlers::list_user_permissions),
)
.route( .route(
"/servers/{server_id}/permissions/users/{user_id}", "/servers/{server_id}/permissions/users/{user_id}",
get(handlers::get_user_permission) get(handlers::get_user_permission)
+13 -5
View File
@@ -1,11 +1,13 @@
use crate::core::state::AppState; use crate::core::state::AppState;
use crate::domain::dto::user::{
CreateUserRequest, UpdateUserRequest, UserQueryParams, UserResponse,
};
use crate::http::context::Superuser; use crate::http::context::Superuser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::domain::dto::user::{CreateUserRequest, UpdateUserRequest, UserResponse};
use crate::routes::user::mapper; use crate::routes::user::mapper;
use axum::{ use axum::{
Json, Json,
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -14,10 +16,10 @@ use uuid::Uuid;
#[utoipa::path( #[utoipa::path(
get, get,
path = "/users", path = "/users",
params(UserQueryParams),
responses( responses(
(status = 200, description = "Liste des utilisateurs récupérée avec succès", body = [UserResponse]), (status = 200, description = "Liste des utilisateurs récupérée avec succès", body = [UserResponse]),
(status = 401, description = "Non autorisé"), (status = 401, description = "Non autorisé"),
(status = 403, description = "Interdit"),
(status = 500, description = "Erreur interne du serveur") (status = 500, description = "Erreur interne du serveur")
), ),
tag = "Users", tag = "Users",
@@ -26,10 +28,16 @@ use uuid::Uuid;
) )
)] )]
pub async fn get_all( pub async fn get_all(
_admin: Superuser,
State(state): State<AppState>, State(state): State<AppState>,
Query(filters): Query<UserQueryParams>,
) -> Result<Json<Vec<UserResponse>>, HTTPError> { ) -> Result<Json<Vec<UserResponse>>, HTTPError> {
let users = state.repositories.user.get_all().await?; let users = state
.repositories
.user
.filter(crate::repositories::types::UserFilter {
server_id: filters.server_id,
})
.await?;
Ok(Json( Ok(Json(
users users
.into_iter() .into_iter()
+62 -8
View File
@@ -1,7 +1,10 @@
use crate::services::ServicesContext; use crate::domain::events::message::{
MessageCreatedEvent, MessageDeletedEvent, MessageUpdatedEvent,
};
use crate::models::{channel, message}; use crate::models::{channel, message};
use crate::services::ServicesContext;
use event_bus::Scope; use event_bus::Scope;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, TransactionTrait, Set}; use sea_orm::{ActiveModelTrait, EntityTrait, QuerySelect, Set, TransactionTrait};
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@@ -51,7 +54,15 @@ impl MessageService {
scopes.push(Scope::uuid("server", server_id)); scopes.push(Scope::uuid("server", server_id));
} }
event_bus.emit_scoped("message_created", scopes, msg.clone()); event_bus.emit_scoped(
"message_created",
scopes,
MessageCreatedEvent {
server_id,
channel_id: msg.channel_id,
message: msg.clone(),
},
);
Ok(msg) Ok(msg)
} }
@@ -78,7 +89,27 @@ impl MessageService {
txn.commit().await?; txn.commit().await?;
event_bus.emit("message_updated", msg.clone()); let server_id = channel::Entity::find_by_id(msg.channel_id)
.select_only()
.column(channel::Column::ServerId)
.into_tuple::<Option<Uuid>>()
.one(db)
.await?
.flatten();
let mut scopes = vec![Scope::uuid("channel", msg.channel_id)];
if let Some(server_id) = server_id {
scopes.push(Scope::uuid("server", server_id));
}
event_bus.emit_scoped(
"message_updated",
scopes,
MessageUpdatedEvent {
server_id,
channel_id: msg.channel_id,
message: msg.clone(),
},
);
Ok(msg) Ok(msg)
} }
@@ -89,16 +120,39 @@ impl MessageService {
let txn = db.begin().await?; let txn = db.begin().await?;
let res = message::Entity::delete_by_id(id) let existing = message::Entity::find_by_id(id).one(db).await?;
.exec(&txn) let Some(existing) = existing else {
.await?; return Ok(false);
};
let server_id = channel::Entity::find_by_id(existing.channel_id)
.select_only()
.column(channel::Column::ServerId)
.into_tuple::<Option<Uuid>>()
.one(db)
.await?
.flatten();
let res = message::Entity::delete_by_id(id).exec(&txn).await?;
let deleted = res.rows_affected > 0; let deleted = res.rows_affected > 0;
txn.commit().await?; txn.commit().await?;
if deleted { if deleted {
event_bus.emit("message_deleted", id); let mut scopes = vec![Scope::uuid("channel", existing.channel_id)];
if let Some(server_id) = server_id {
scopes.push(Scope::uuid("server", server_id));
}
event_bus.emit_scoped(
"message_deleted",
scopes,
MessageDeletedEvent {
server_id,
channel_id: existing.channel_id,
message: existing,
},
);
} }
Ok(deleted) Ok(deleted)
+4
View File
@@ -16,6 +16,7 @@ pub mod channel;
pub mod message; pub mod message;
mod permission; mod permission;
pub mod permission_sync; pub mod permission_sync;
pub mod realtime_registry;
pub mod role; pub mod role;
pub mod server; pub mod server;
mod server_order; mod server_order;
@@ -30,6 +31,7 @@ pub struct ServicesContext {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Services { pub struct Services {
pub realtime_registry: Arc<realtime_registry::RealtimeRegistry>,
pub permission_sync: Arc<PermissionSyncService>, pub permission_sync: Arc<PermissionSyncService>,
pub server_order: Arc<ServerOrderService>, pub server_order: Arc<ServerOrderService>,
pub channel: Arc<ChannelService>, pub channel: Arc<ChannelService>,
@@ -49,6 +51,7 @@ impl Services {
services: OnceLock::new(), services: OnceLock::new(),
}); });
let permission_sync = Arc::new(PermissionSyncService::new(service_context.clone())); let permission_sync = Arc::new(PermissionSyncService::new(service_context.clone()));
let realtime_registry = Arc::new(realtime_registry::RealtimeRegistry::default());
let server_order = Arc::new(ServerOrderService::new(service_context.clone())); let server_order = Arc::new(ServerOrderService::new(service_context.clone()));
let channel = Arc::new(ChannelService::new(service_context.clone())); let channel = Arc::new(ChannelService::new(service_context.clone()));
let server = Arc::new(ServerService::new(service_context.clone())); let server = Arc::new(ServerService::new(service_context.clone()));
@@ -59,6 +62,7 @@ impl Services {
let permission = Arc::new(PermissionService::new(service_context.clone())); let permission = Arc::new(PermissionService::new(service_context.clone()));
let services = Self { let services = Self {
realtime_registry,
permission_sync, permission_sync,
server_order, server_order,
channel, channel,
+1 -1
View File
@@ -62,8 +62,8 @@ impl PermissionSyncService {
/// Enregistre les listeners sur l'EventBus pour mettre à jour le cache /// Enregistre les listeners sur l'EventBus pour mettre à jour le cache
/// des permissions calculées lors des modifications de structure ou de droits. /// des permissions calculées lors des modifications de structure ou de droits.
pub async fn start_listen_event(&self) { pub async fn start_listen_event(&self) {
let event_bus = self.service_context.event_bus.clone();
let repositories = self.service_context.repositories.clone(); let repositories = self.service_context.repositories.clone();
let event_bus = self.service_context.event_bus.clone();
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Événements Serveur & Membres Serveur // Événements Serveur & Membres Serveur
+103
View File
@@ -0,0 +1,103 @@
use crate::models::computed_permission::PermissionScopeType;
use crate::permissions::ChannelPermission;
use crate::repositories::Repositories;
use event_bus::EventBus;
use parking_lot::RwLock;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use uuid::Uuid;
/// In-memory index of the users that can receive events for each channel.
#[derive(Debug, Default)]
pub struct RealtimeRegistry {
channel_users: RwLock<HashMap<Uuid, HashSet<Uuid>>>,
user_channels: RwLock<HashMap<Uuid, HashSet<Uuid>>>,
}
impl RealtimeRegistry {
pub async fn initialize(&self, repositories: &Repositories) -> anyhow::Result<()> {
let permissions = repositories.computed_permission.get_all().await?;
let mut channel_users = HashMap::<Uuid, HashSet<Uuid>>::new();
let mut user_channels = HashMap::<Uuid, HashSet<Uuid>>::new();
for permission in permissions {
if permission.scope_type != PermissionScopeType::Channel
|| !ChannelPermission::from_bits_retain(permission.permissions as u64)
.contains(ChannelPermission::READ_CHANNEL)
{
continue;
}
channel_users.entry(permission.resource_id).or_default().insert(permission.user_id);
user_channels.entry(permission.user_id).or_default().insert(permission.resource_id);
}
*self.channel_users.write() = channel_users;
*self.user_channels.write() = user_channels;
Ok(())
}
pub fn users_for_channel(&self, channel_id: Uuid) -> HashSet<Uuid> {
self.channel_users.read().get(&channel_id).cloned().unwrap_or_default()
}
pub fn set_user_channels(&self, user_id: Uuid, channels: impl IntoIterator<Item = Uuid>) {
let channels: HashSet<_> = channels.into_iter().collect();
let old = self.user_channels.write().insert(user_id, channels.clone()).unwrap_or_default();
let mut by_channel = self.channel_users.write();
for channel_id in old.difference(&channels) {
if let Some(users) = by_channel.get_mut(channel_id) {
users.remove(&user_id);
if users.is_empty() { by_channel.remove(channel_id); }
}
}
for channel_id in channels { by_channel.entry(channel_id).or_default().insert(user_id); }
}
pub fn remove_user(&self, user_id: Uuid) {
if let Some(channels) = self.user_channels.write().remove(&user_id) {
let mut by_channel = self.channel_users.write();
for channel_id in channels {
if let Some(users) = by_channel.get_mut(&channel_id) {
users.remove(&user_id);
if users.is_empty() { by_channel.remove(&channel_id); }
}
}
}
}
pub fn remove_channel(&self, channel_id: Uuid) {
if let Some(users) = self.channel_users.write().remove(&channel_id) {
let mut by_user = self.user_channels.write();
for user_id in users {
if let Some(channels) = by_user.get_mut(&user_id) {
channels.remove(&channel_id);
if channels.is_empty() { by_user.remove(&user_id); }
}
}
}
}
pub fn start_listening(self: &Arc<Self>, repositories: Arc<Repositories>, event_bus: Arc<EventBus>) {
let registry = Arc::clone(self);
event_bus.on_async_with("channel_user_permission_updated", repositories.clone(), move |repositories, (_channel_id, user_id, _permissions): (Uuid, Uuid, u64)| {
let registry = Arc::clone(&registry);
async move {
match repositories.computed_permission.get_all().await {
Ok(all) => registry.set_user_channels(user_id, all.into_iter().filter(|p| p.user_id == user_id && p.scope_type == PermissionScopeType::Channel && ChannelPermission::from_bits_retain(p.permissions as u64).contains(ChannelPermission::READ_CHANNEL)).map(|p| p.resource_id)),
Err(error) => tracing::error!(%user_id, ?error, "Unable to refresh realtime registry"),
}
}
});
let registry = Arc::clone(self);
let repositories = repositories.clone();
event_bus.on_async_with("server_user_permission_updated", repositories, move |repositories, (_server_id, user_id): (Uuid, Uuid)| {
let registry = Arc::clone(&registry);
async move {
if let Ok(all) = repositories.computed_permission.get_all().await {
registry.set_user_channels(user_id, all.into_iter().filter(|p| p.user_id == user_id && p.scope_type == PermissionScopeType::Channel && ChannelPermission::from_bits_retain(p.permissions as u64).contains(ChannelPermission::READ_CHANNEL)).map(|p| p.resource_id));
}
}
});
}
}
+28 -8
View File
@@ -1,6 +1,6 @@
use crate::services::ServicesContext; use crate::services::ServicesContext;
use crate::models::role; use crate::models::role;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, TransactionTrait, Set}; use sea_orm::{ActiveModelTrait, EntityTrait, TransactionTrait};
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@@ -23,13 +23,13 @@ impl RoleService {
let txn = db.begin().await?; let txn = db.begin().await?;
let group = active.insert(&txn).await?; let role = active.insert(&txn).await?;
txn.commit().await?; txn.commit().await?;
event_bus.emit("group_created", group.clone()); event_bus.emit("role_created", role.clone());
Ok(group) Ok(role)
} }
pub async fn update_role( pub async fn update_role(
@@ -41,13 +41,13 @@ impl RoleService {
let txn = db.begin().await?; let txn = db.begin().await?;
let group = active.update(&txn).await?; let role = active.update(&txn).await?;
txn.commit().await?; txn.commit().await?;
event_bus.emit("group_updated", group.clone()); event_bus.emit("role_updated", role.clone());
Ok(group) Ok(role)
} }
pub async fn delete_role(&self, id: Uuid) -> Result<bool, anyhow::Error> { pub async fn delete_role(&self, id: Uuid) -> Result<bool, anyhow::Error> {
@@ -65,9 +65,29 @@ impl RoleService {
txn.commit().await?; txn.commit().await?;
if deleted { if deleted {
event_bus.emit("group_deleted", id); event_bus.emit("role_deleted", id);
} }
Ok(deleted) Ok(deleted)
} }
pub async fn add_member(&self, role_id: Uuid, user_id: Uuid, server_id: Uuid) -> Result<bool, anyhow::Error> {
let added = self.service_context.repositories.role.add_member(role_id, user_id).await?;
if added {
self.service_context
.event_bus
.emit("role_user_created", (role_id, user_id, server_id));
}
Ok(added)
}
pub async fn remove_member(&self, role_id: Uuid, user_id: Uuid, server_id: Uuid) -> Result<bool, anyhow::Error> {
let removed = self.service_context.repositories.role.remove_member(role_id, user_id).await?;
if removed {
self.service_context
.event_bus
.emit("role_user_deleted", (role_id, user_id, server_id));
}
Ok(removed)
}
} }