Compare commits

..
17 Commits
Author SHA1 Message Date
Nell e6d6968e52 init 2026-08-09 20:07:00 +02:00
Nell 20beea24d5 init 2026-08-09 10:30:54 +02:00
Nell 93800e8460 init 2026-08-08 20:55:01 +02:00
Nell 8f3fd6a127 init 2026-08-08 20:19:33 +02:00
Nell 42ab990f7d init 2026-08-08 19:56:57 +02:00
Nell d1f9234457 init 2026-08-08 14:57:04 +02:00
Nell 0d8c86af16 init 2026-08-08 14:17:24 +02:00
Nell e9fe51363f init 2026-08-02 16:36:26 +02:00
Nell c10925b84b init 2026-08-02 13:11:11 +02:00
Nell bb4e17ba2f init 2026-08-02 12:07:51 +02:00
Nell aa486be6e5 init 2026-08-02 11:09:50 +02:00
Nell 659fd0f304 init 2026-08-02 10:34:18 +02:00
Nell 9dbb7ffd5b init 2026-08-02 10:13:49 +02:00
Nell ba51dde1c2 init 2026-08-02 09:17:39 +02:00
Nell 73379e9ca8 init 2026-08-01 22:26:43 +02:00
Nell b946cfd866 init 2026-08-01 14:11:19 +02:00
Nell 22b0bc36bb init 2026-08-01 08:59:35 +02:00
73 changed files with 4411 additions and 700 deletions
+1
View File
@@ -17,6 +17,7 @@
"dependencies": {
"@fontsource/roboto": "^5.2.10",
"@mdi/font": "7.4.47",
"highlight.js": "^11.11.1",
"markdown-it": "^14.3.0",
"pinia": "^3.0.4",
"vue": "^3.5.30",
+9 -2
View File
@@ -1,7 +1,14 @@
<template>
<router-view/>
<router-view />
<v-snackbar v-model="notification.visible" :timeout="5000" location="bottom right">
<div class="font-weight-bold">{{ notification.title }}</div>
<div>{{ notification.message }}</div>
</v-snackbar>
</template>
<script lang="ts" setup>
//
import {useNotificationStore} from "@/stores/notification.ts";
const notification = useNotificationStore();
</script>
@@ -0,0 +1,63 @@
<script lang="ts" setup>
import {storeToRefs} from 'pinia'
import {useUserStore} from '@/stores/user'
defineProps<{
modelValue: boolean
}>()
const userStore = useUserStore()
const {users} = storeToRefs(userStore)
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
}>()
const getUserInitials = (username: string): string => {
if (!username) return '?'
return username.trim().slice(0, 2).toUpperCase()
}
const close = () => emit('update:modelValue', false)
</script>
<template>
<v-navigation-drawer
:model-value="modelValue"
location="right"
temporary
width="280"
@update:model-value="emit('update:modelValue', $event)"
>
<v-toolbar density="compact" flat>
<v-toolbar-title>Utilisateurs</v-toolbar-title>
<v-btn
aria-label="Fermer la liste des utilisateurs"
icon="mdi-close"
size="small"
variant="text"
@click="close"
></v-btn>
</v-toolbar>
<v-divider></v-divider>
<v-list v-if="users.length" density="compact">
<v-list-item
v-for="user in users"
:key="user.id"
:title="user.username"
>
<template #prepend>
<v-avatar color="primary" size="32">
<span class="text-caption font-weight-medium">{{ getUserInitials(user.username) }}</span>
</v-avatar>
</template>
</v-list-item>
</v-list>
<div v-else class="pa-4 text-medium-emphasis text-body-2">
Aucun utilisateur à afficher.
</div>
</v-navigation-drawer>
</template>
@@ -0,0 +1,90 @@
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {useCategoryStore} from '@/stores/category'
const props = defineProps<{
modelValue: boolean
serverId: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'created', category: any): void
}>()
const categoryStore = useCategoryStore()
const name = ref('')
const isSubmitting = ref(false)
const resetForm = () => {
name.value = ''
}
const handleClose = () => {
emit('update:modelValue', false)
resetForm()
}
const handleSubmit = async () => {
if (!name.value.trim()) {
alert('Category name is required')
return
}
isSubmitting.value = true
try {
const newCategory = await categoryStore.createCategory({
name: name.value.trim(),
server_id: props.serverId
})
emit('created', newCategory)
handleClose()
} catch (error) {
console.error('Failed to create category:', error)
} finally {
isSubmitting.value = false
}
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
resetForm()
}
}
)
</script>
<template>
<v-dialog :model-value="modelValue" width="400" @update:model-value="handleClose">
<v-card>
<v-card-title>Create Category</v-card-title>
<v-card-text>
<v-text-field
v-model="name"
autofocus
density="compact"
label="Category Name"
outlined
@keyup.enter="handleSubmit"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn :disabled="isSubmitting" variant="text" @click="handleClose">
Cancel
</v-btn>
<v-btn
:disabled="!name.trim()"
:loading="isSubmitting"
color="primary"
variant="tonal"
@click="handleSubmit"
>
Create
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -0,0 +1,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>
+30
View File
@@ -0,0 +1,30 @@
import MarkdownIt from 'markdown-it'
import hljs from 'highlight.js'
// Déclaré hors de la fonction = instancié une seule fois pour toute l'application
const md = new MarkdownIt({
html: false,
linkify: true,
typographer: true,
breaks: true,
})
md.set({
highlight: (str: string, lang: string): string => {
if (lang && hljs.getLanguage(lang)) {
try {
return `<pre class="hljs"><code>${hljs.highlight(str, {
language: lang,
ignoreIllegals: true
}).value}</code></pre>`
} catch (__) {
}
}
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`
}
})
export function useMarkdown() {
const renderMarkdown = (content: string) => md.render(content)
return {renderMarkdown}
}
+24 -4
View File
@@ -1,6 +1,9 @@
import {useApi} from '@/composables/useApi'
import {
type ChannelRolePermission,
type ChannelPermissions,
type ChannelPermissionsDto,
channelPermissionsFromDto,
type ChannelRolePermissionDto,
channelRolePermissionFromDto,
type ChannelUserPermission,
@@ -22,10 +25,11 @@ export function usePermissions() {
async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const error = await response.json().catch(() => null)
throw new Error(
const exception = new Error(
error?.message || 'Erreur lors de la gestion des permissions',
)
) as Error & { status?: number }
exception.status = response.status
throw exception
}
if (response.status === 204) {
@@ -52,6 +56,14 @@ export function usePermissions() {
return serverUserPermissionFromDto(dto)
}
async function listServerUserPermissions(
serverId: string,
): Promise<ServerUserPermission[]> {
const response = await api.get(`/servers/${serverId}/permissions/users`)
const dtos = await parseResponse<ServerUserPermissionDto[]>(response)
return dtos.map(serverUserPermissionFromDto)
}
async function setServerUserPermission(
serverId: string,
userId: string,
@@ -129,6 +141,12 @@ export function usePermissions() {
// Permissions canal - utilisateur
// ---------------------------------------------------------------------------
async function getChannelPermissions(channelId: string): Promise<ChannelPermissions> {
const response = await api.get(`/channels/${channelId}/permissions`)
const dto = await parseResponse<ChannelPermissionsDto>(response)
return channelPermissionsFromDto(dto)
}
async function getChannelUserPermission(
channelId: string,
userId: string,
@@ -216,7 +234,9 @@ export function usePermissions() {
}
return {
getChannelPermissions,
getServerUserPermission,
listServerUserPermissions,
setServerUserPermission,
removeServerUserPermission,
@@ -232,4 +252,4 @@ export function usePermissions() {
setChannelRolePermission,
removeChannelRolePermission,
}
}
}
+83 -11
View File
@@ -1,15 +1,30 @@
<script lang="ts" setup>
import {storeToRefs} from 'pinia'
import {useServerStore} from '@/stores/server'
import {ref} from 'vue'
import {useRouter} from 'vue-router'
import {useServerStore, type Server} from '@/stores/server'
import {computed, ref, watch} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import ContextMenu from "@/components/ContextMenu.vue";
import UserListDrawer from '@/components/UserListDrawer.vue'
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
import {useContextMenu} from '@/composables/useContextMenu'
const serverStore = useServerStore()
const route = useRoute()
const router = useRouter()
const {openContextMenu} = useContextMenu()
const {servers} = storeToRefs(serverStore)
const showUsersDrawer = ref(false)
const showServerSettings = ref(false)
const selectedServerId = ref<string | null>(null)
const selectedServerName = ref('')
const isServerContext = computed(() => Boolean(route.params.serverId))
watch(isServerContext, (isActive) => {
if (!isActive) showUsersDrawer.value = false
})
const showDialog = ref(false)
const formData = ref({
name: '',
@@ -72,13 +87,25 @@ const getServerColor = (str: string): string => {
const hue = Math.abs(hash) % 360
return `hsl(${hue}, 60%, 45%)`
}
function onServerContextMenu(event: MouseEvent, server: Server) {
openContextMenu(event, [{
label: 'Gérer le serveur',
icon: 'mdi-cog',
action: () => {
selectedServerId.value = server.id
selectedServerName.value = server.name
showServerSettings.value = true
},
}])
}
</script>
<template>
<v-app @contextmenu.prevent>
<!--Top bar-->
<v-system-bar>
<v-spacer></v-spacer>
<v-icon>mdi-square</v-icon>
@@ -86,6 +113,15 @@ const getServerColor = (str: string): string => {
<v-icon>mdi-circle</v-icon>
<v-icon>mdi-triangle</v-icon>
<v-btn
v-if="isServerContext"
aria-label="Afficher les utilisateurs"
icon="mdi-account-group"
size="small"
variant="text"
@click="showUsersDrawer = !showUsersDrawer"
></v-btn>
</v-system-bar>
<v-navigation-drawer
@@ -107,14 +143,25 @@ const getServerColor = (str: string): string => {
v-for="server in servers"
:key="server.id"
:to="`/server/${server.id}`"
@contextmenu="onServerContextMenu($event, server)"
>
<v-avatar
:style="{ backgroundColor: getServerColor(server.name) }"
class="d-flex align-center justify-center mx-auto mb-9 font-weight-bold text-caption text-white"
size="28"
<v-badge
class="server-badge d-flex mx-auto mb-9"
:content="server.unread_count"
:model-value="(server.unread_count ?? 0) > 0"
color="primary"
location="bottom right"
offset-x="2"
offset-y="2"
>
{{ 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>
<v-btn
@@ -127,6 +174,18 @@ const getServerColor = (str: string): string => {
></v-btn>
</v-navigation-drawer>
<UserListDrawer
v-model="showUsersDrawer"
/>
<ServerSettingsDialog
v-if="selectedServerId"
v-model="showServerSettings"
:server-id="selectedServerId"
:server-name="selectedServerName"
/>
<router-view/>
<!-- Menu contextuel global -->
<ContextMenu/>
@@ -184,4 +243,17 @@ const getServerColor = (str: string): string => {
flex-direction: column;
gap: 1rem;
}
</style>
.server-badge {
height: 36px;
width: 36px;
}
.server-badge :deep(.v-badge__badge) {
min-width: 22px;
height: 22px;
padding: 0 5px;
font-size: 0.7rem;
line-height: 22px;
}
</style>
+220 -40
View File
@@ -1,10 +1,12 @@
<script lang="ts" setup>
import MarkdownIt from 'markdown-it'
import {computed, nextTick, onMounted, ref, watch} from 'vue';
import {useRoute} from 'vue-router';
import 'highlight.js/styles/github-dark.css'
import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue';
import {storeToRefs} from 'pinia';
import {useMessageStore} from '@/stores/message';
import {useServerStore} from '@/stores/server';
import {useUserStore} from "@/stores/user.ts";
import {useMarkdown} from '@/composables/useMarkdown'
import {onReloadAll} from '@/plugins/events.ts'
const props = defineProps<{
serverId: string
@@ -12,33 +14,154 @@ const props = defineProps<{
}>();
const channelId = computed(() => props.channelId);
const route = useRoute();
const messageStore = useMessageStore();
const serverStore = useServerStore();
const userStore = useUserStore();
const {renderMarkdown} = useMarkdown()
// Référence vers l'élément scrollable
const messageContainer = ref<HTMLElement | null>(null);
// "messages" ici est une référence réactive liée au store
const {messages, loading} = storeToRefs(messageStore);
const {messages, loading, loadingBefore, loadingAfter, hasMoreAfter, isAtBottom, newestId} = storeToRefs(messageStore);
const newMessage = ref('');
const md = new MarkdownIt({
html: false, // Désactive le HTML pur pour la sécurité
linkify: true, // Convertit automatiquement les URLs en liens
typographer: true,
breaks: true // Convertit les retours à la ligne en <br> (comportement type chat)
})
const renderMarkdown = (content: string) => {
return md.render(content);
const SCROLL_LOAD_THRESHOLD = 120;
const SCROLL_BOTTOM_TOLERANCE = 4;
const paginationLock = ref<'before' | 'after' | null>(null);
const paginationLockScrollTop = ref(0);
const lastScrollTop = ref(0);
const markedMessageByChannel = new Map<string, string>();
const markCurrentChannelRead = async (targetChannelId: string) => {
if (messageStore.activeChannelId !== targetChannelId || !newestId.value) return;
const messageId = newestId.value;
if (markedMessageByChannel.get(targetChannelId) === messageId) return;
try {
const readState = await messageStore.markChannelRead(targetChannelId, messageId);
if (messageStore.activeChannelId !== targetChannelId) return;
markedMessageByChannel.set(targetChannelId, messageId);
serverStore.applyChannelReadState(props.serverId, targetChannelId, readState.unread_count);
} catch (error) {
console.error('Erreur lors de la mise à jour de la lecture:', error);
}
};
const showRecentMessagesButton = computed(() =>
!loading.value && (hasMoreAfter.value || !isAtBottom.value),
);
interface ScrollAnchor {
id: string;
top: number;
}
const scrollToBottom = async () => {
await nextTick();
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
messageStore.setAtBottom(true);
paginationLock.value = null;
lastScrollTop.value = messageContainer.value.scrollTop;
}
};
const getMessageElements = () => Array.from(
messageContainer.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? [],
);
const captureAnchor = (edge: 'top' | 'bottom'): ScrollAnchor | null => {
const container = messageContainer.value;
if (!container) return null;
const containerRect = container.getBoundingClientRect();
const visible = getMessageElements().filter(element => {
const rect = element.getBoundingClientRect();
return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
});
const element = edge === 'top' ? visible[0] : visible[visible.length - 1];
if (!element?.dataset.messageId) return null;
return {
id: element.dataset.messageId,
top: element.getBoundingClientRect().top,
};
};
const restoreAnchor = async (anchor: ScrollAnchor | null) => {
if (!anchor || !messageContainer.value) return;
await nextTick();
const element = getMessageElements().find(item => item.dataset.messageId === anchor.id);
if (element) {
messageContainer.value.scrollTop += element.getBoundingClientRect().top - anchor.top;
}
};
const updateScrollState = () => {
const container = messageContainer.value;
if (!container) return;
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
messageStore.setAtBottom(distanceFromBottom <= SCROLL_BOTTOM_TOLERANCE);
};
const setPaginationLock = (direction: 'before' | 'after') => {
if (!messageContainer.value) return;
paginationLock.value = direction;
paginationLockScrollTop.value = messageContainer.value.scrollTop;
lastScrollTop.value = messageContainer.value.scrollTop;
};
const loadBefore = async () => {
const anchor = captureAnchor('top');
const change = await messageStore.fetchBefore(channelId.value);
if (change) {
await restoreAnchor(anchor);
setPaginationLock('before');
}
};
const loadAfter = async () => {
const anchor = captureAnchor('bottom');
const change = await messageStore.fetchAfter(channelId.value);
if (change) {
await restoreAnchor(anchor);
setPaginationLock('after');
}
};
const handleScroll = async () => {
const container = messageContainer.value;
if (!container) return;
const currentScrollTop = container.scrollTop;
const scrollDelta = currentScrollTop - lastScrollTop.value;
lastScrollTop.value = currentScrollTop;
if (paginationLock.value === 'after') {
if (scrollDelta < -1 || currentScrollTop > paginationLockScrollTop.value + 2) {
paginationLock.value = null;
} else {
return;
}
} else if (paginationLock.value === 'before') {
if (scrollDelta > 1 || currentScrollTop < paginationLockScrollTop.value - 2) {
paginationLock.value = null;
} else {
return;
}
}
updateScrollState();
if (container.scrollTop <= SCROLL_LOAD_THRESHOLD && !loadingBefore.value) {
await loadBefore();
} else {
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
if (distanceFromBottom <= SCROLL_LOAD_THRESHOLD && !loadingAfter.value) {
await loadAfter();
}
}
};
@@ -46,47 +169,78 @@ const sendMessage = async () => {
if (!newMessage.value.trim()) return;
const content = newMessage.value;
const wasAtBottom = messageStore.isAtBottom;
try {
await messageStore.sendMessage(channelId.value, content);
newMessage.value = ''; // On vide le champ après succès
await scrollToBottom();
newMessage.value = '';
if (wasAtBottom) await scrollToBottom();
} catch (e) {
// Gérer l'erreur (ex: notification toast)
console.error('Erreur lors de l\'envoi du message:', e);
}
};
const returnToRecentMessages = async () => {
paginationLock.value = null;
await messageStore.fetchMessages(channelId.value);
await scrollToBottom();
await markCurrentChannelRead(channelId.value);
};
let stopReloadAll: (() => void) | null = null;
onMounted(() => {
if (channelId.value) {
messageStore.fetchMessages(channelId.value);
stopReloadAll = onReloadAll(() => messageStore.fetchMessages(channelId.value));
});
onUnmounted(() => {
stopReloadAll?.();
});
// Only explicit initial loads and realtime messages received while at the
// bottom request an automatic scroll. Pagination restores its own anchor.
watch(messages, async () => {
if (messageStore.consumeScrollToBottomRequest()) {
await scrollToBottom();
}
}, {deep: true, flush: 'post'});
watch(isAtBottom, async (atBottom) => {
if (atBottom) {
await markCurrentChannelRead(channelId.value);
}
});
watch(channelId, (newChannelId) => {
watch(channelId, async (newChannelId) => {
if (newChannelId) {
messageStore.fetchMessages(newChannelId);
await messageStore.fetchMessages(newChannelId);
await scrollToBottom();
await markCurrentChannelRead(newChannelId);
}
}, {immediate: true})
// Scroll automatique quand la liste des messages change (nouveaux messages reçus)
watch(messages, () => {
scrollToBottom();
}, {deep: true});
</script>
<template>
<!-- Conteneur principal prenant toute la hauteur -->
<v-container class="pa-0 fill-height d-flex flex-column" fluid>
<!-- Zone des messages (scrollable) -->
<v-container class="pa-0 fill-height d-flex flex-column channel-layout" fluid>
<div
ref="messageContainer"
class="flex-grow-1 overflow-y-auto w-100 message-container"
@scroll.passive="handleScroll"
>
<v-progress-linear v-if="loadingBefore" color="primary" indeterminate />
<v-progress-circular
v-if="loading && !messages.length"
class="d-block mx-auto mt-4"
color="primary"
indeterminate
/>
<v-list bg-color="transparent" lines="three">
<v-list-item
v-for="msg in messages"
:key="msg.id"
:data-message-id="msg.id"
class="px-4 py-1"
>
<template v-slot:prepend>
@@ -100,14 +254,31 @@ watch(messages, () => {
<span class="text-caption text-grey">{{ msg.created_at }}</span>
</v-list-item-title>
<v-list-item-subtitle class="text-body-1 text-high-emphasis opacity-100">
<div class="text-body-1 text-high-emphasis opacity-100 mt-1">
<div class="markdown-content" v-html="renderMarkdown(msg.content)"></div>
</v-list-item-subtitle>
</div>
</v-list-item>
</v-list>
<v-progress-linear v-if="loadingAfter" color="primary" indeterminate />
</div>
<div v-if="showRecentMessagesButton" class="recent-messages-button">
<v-tooltip location="top" text="Revenir aux messages récents">
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="tooltipProps"
aria-label="Revenir aux messages récents"
color="primary"
elevation="4"
icon="mdi-arrow-down-bold"
:loading="loading"
@click="returnToRecentMessages"
/>
</template>
</v-tooltip>
</div>
<!-- Zone de saisie fixe en bas -->
<v-sheet class="pa-4 flex-shrink-0" width="100%">
<v-textarea
v-model="newMessage"
@@ -133,16 +304,25 @@ watch(messages, () => {
</template>
</v-textarea>
</v-sheet>
</v-container>
</template>
<style scoped>
.message-container {
/* Assure que la zone gère son scroll indépendamment */
height: 0;
}
.channel-layout {
position: relative;
}
.recent-messages-button {
position: absolute;
right: 16px;
bottom: 92px;
z-index: 2;
}
.markdown-content :deep(p) {
margin-bottom: 0;
}
@@ -162,4 +342,4 @@ watch(messages, () => {
margin: 8px 0;
overflow-x: auto;
}
</style>
</style>
+126 -24
View File
@@ -2,12 +2,17 @@
import {storeToRefs} from 'pinia'
import {useChannelStore} from '@/stores/channel'
import {useCategoryStore} from '@/stores/category'
import {ref, watch} from 'vue'
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
import {useRoute} from 'vue-router'
import CreateChannelDialog from '@/components/channel/CreateChannelDialog.vue'
import CreateCategoryDialog from '@/components/category/CreateCategoryDialog.vue'
import {type MenuItem, useContextMenu} from '@/composables/useContextMenu'
import {useUserStore} from "@/stores/user.ts";
import {useServerStore} from "@/stores/server.ts";
import ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue'
import {useAuthStore} from '@/stores/auth'
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
import {onReloadAll} from '@/plugins/events.ts'
const props = defineProps<{
serverId: string
@@ -21,6 +26,11 @@ const userStore = useUserStore()
const serverStore = useServerStore()
const {currentTree} = storeToRefs(serverStore)
const {openContextMenu} = useContextMenu()
const authStore = useAuthStore()
const showPermissionsDialog = ref(false)
const selectedChannel = ref<any | null>(null)
const showServerSettings = ref(false)
const serverName = computed(() => serverStore.servers.find(server => server.id === props.serverId)?.name || 'Serveur')
const loadServerData = async (targetServerId: string) => {
@@ -33,11 +43,22 @@ const loadServerData = async (targetServerId: string) => {
userStore.fetchUsers(targetServerId),
serverStore.fetchServerTree(targetServerId)
])
syncOpenedCategories()
} catch (error) {
console.error('Failed to load server-scoped channels and categories:', error)
}
}
let stopReloadAll: (() => void) | null = null
onMounted(() => {
stopReloadAll = onReloadAll(() => loadServerData(props.serverId))
})
onUnmounted(() => {
stopReloadAll?.()
})
watch(
() => props.serverId,
async (newServerId) => {
@@ -48,7 +69,21 @@ watch(
{immediate: true}
)
const showDialog = ref(false)
const showChannelDialog = ref(false)
const showCategoryDialog = ref(false)
const selectedCategoryId = ref<string | null>(null)
const openedCategories = ref<string[]>([])
function syncOpenedCategories() {
openedCategories.value = currentTree.value
.filter((item) => 'Category' in item)
.map((item) => item.Category[0].id)
}
async function refreshServerTree() {
await serverStore.fetchServerTree(props.serverId)
syncOpenedCategories()
}
// Right click menu (sidebar)
function onSidebarContextMenu(event: MouseEvent) {
@@ -56,17 +91,34 @@ function onSidebarContextMenu(event: MouseEvent) {
{
label: 'Nouveau canal',
icon: 'mdi-plus',
action: () => console.log('Nouveau canal'),
action: () => {
selectedCategoryId.value = null
showChannelDialog.value = true
},
},
{
label: 'Nouvelle catégorie',
icon: 'mdi-folder-plus',
action: () => console.log('Nouvelle catégorie'),
action: () => { showCategoryDialog.value = true },
}
]
openContextMenu(event, menuItems);
}
function onCategoryContextMenu(event: MouseEvent, category: any) {
const menuItems: MenuItem[] = [
{
label: 'Nouveau canal',
icon: 'mdi-plus',
action: () => {
selectedCategoryId.value = category.id
showChannelDialog.value = true
},
},
]
openContextMenu(event, menuItems)
}
// Right click menu (channel)
async function openEditDialog(channel: any) {
console.log("edit dialog clicked")
@@ -78,6 +130,14 @@ async function deleteChannel(channelId: string) {
function onChannelContextMenu(event: MouseEvent, channel: any) {
const menuItems: MenuItem[] = [
...(authStore.isAdmin ? [{
label: 'Gérer les permissions',
icon: 'mdi-shield-key',
action: () => {
selectedChannel.value = channel
showPermissionsDialog.value = true
},
}] : []),
{
label: 'Marquer comme lu',
icon: 'mdi-check',
@@ -103,28 +163,24 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
<template>
<v-navigation-drawer width="244" @contextmenu="onSidebarContextMenu">
<v-sheet
color="grey-lighten-5"
height="128"
width="100%"
></v-sheet>
<v-sheet color="grey-lighten-5" height="128" width="100%" class="pa-3">
<v-btn block variant="text" prepend-icon="mdi-cog" @click="showServerSettings = true">Gérer le serveur</v-btn>
</v-sheet>
<v-btn
block
class="ma-2"
prepend-icon="mdi-plus"
variant="text"
@click="showDialog = true"
<v-list
v-model:opened="openedCategories"
open-strategy="multiple"
density="compact"
>
New Channel
</v-btn>
<v-list density="compact">
<template v-for="(item, index) in currentTree" :key="index">
<!-- Catégorie et ses canaux enfants -->
<v-list-group v-if="'Category' in item" :value="item.Category[0].id">
<template #activator="{ props: groupProps }">
<v-list-item :title="item.Category[0].name" v-bind="groupProps"/>
<v-list-item
:title="item.Category[0].name"
v-bind="groupProps"
@contextmenu="onCategoryContextMenu($event, item.Category[0])"
/>
</template>
<v-list-item
@@ -132,9 +188,22 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
:key="channel.id"
:title="channel.name"
:to="`/server/${serverId}/channel/${channel.id}`"
:class="{ 'font-weight-bold': (channel.unread_count ?? 0) > 0 }"
link
@contextmenu="onChannelContextMenu($event, channel)"
/>
>
<template #append>
<v-chip
v-if="(channel.unread_count ?? 0) > 0"
color="primary"
density="compact"
size="small"
variant="flat"
>
{{ channel.unread_count }}
</v-chip>
</template>
</v-list-item>
</v-list-group>
<!-- Canal orphelin (racine) -->
@@ -143,19 +212,52 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
:key="item.Channel.id"
:title="item.Channel.name"
:to="`/server/${serverId}/channel/${item.Channel.id}`"
:class="{ 'font-weight-bold': (item.Channel.unread_count ?? 0) > 0 }"
link
@contextmenu="onChannelContextMenu($event, item.Channel)"
/>
>
<template #append>
<v-chip
v-if="(item.Channel.unread_count ?? 0) > 0"
color="primary"
density="compact"
size="small"
variant="flat"
>
{{ item.Channel.unread_count }}
</v-chip>
</template>
</v-list-item>
</template>
</v-list>
</v-navigation-drawer>
<CreateChannelDialog
v-model="showDialog"
v-model="showChannelDialog"
:category-id="selectedCategoryId"
:server-id="serverId"
@created="refreshServerTree"
/>
<CreateCategoryDialog
v-model="showCategoryDialog"
:server-id="serverId"
@created="refreshServerTree"
/>
<ChannelPermissionsDialog
v-model="showPermissionsDialog"
:channel="selectedChannel"
:server-id="serverId"
/>
<ServerSettingsDialog
v-model="showServerSettings"
:server-id="serverId"
:server-name="serverName"
/>
<v-main>
<router-view/>
</v-main>
</template>
</template>
+22 -1
View File
@@ -1,5 +1,9 @@
export const bus = new EventTarget();
type ReloadAllHandler = () => void | Promise<void>;
const reloadAllHandlers = new Set<ReloadAllHandler>();
export function emitGatewayEvent(namespace: string, action: string, content: any) {
// On construit le nom de l'événement de manière cohérente : gateway:message
const eventName = `gateway:${namespace.toLowerCase()}`;
@@ -16,4 +20,21 @@ export function onGatewayEvent(namespace: string, callback: (payload: { action:
// Retourne une fonction pour se désabonner facilement si besoin
return () => bus.removeEventListener(eventName, wrapper);
}
}
export function onReloadAll(handler: ReloadAllHandler) {
reloadAllHandlers.add(handler);
return () => reloadAllHandlers.delete(handler);
}
export async function emitReloadAll() {
const results = await Promise.allSettled(
Array.from(reloadAllHandlers, handler => Promise.resolve().then(handler)),
);
for (const result of results) {
if (result.status === 'rejected') {
console.error('Reload after WebSocket reconnection failed:', result.reason);
}
}
}
+28 -2
View File
@@ -11,7 +11,9 @@ interface Category {
export const useCategoryStore = defineStore("category", {
state: () => ({
categories: [] as Category[]
categories: [] as Category[],
loading: false,
error: null as string | null
}),
actions: {
async fetchCategories(serverId?: string) {
@@ -23,8 +25,32 @@ export const useCategoryStore = defineStore("category", {
let response = await api.get(url);
this.categories = await response.json();
},
async createCategory(payload: { server_id: string; name: string }) {
this.loading = true;
this.error = null;
try {
const api = useApi();
const response = await api.post("/categories", payload);
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to create category');
}
const newCategory = await response.json();
this.categories.push(newCategory);
return newCategory;
} catch (err) {
this.error = err instanceof Error ? err.message : 'Unknown error';
throw err;
} finally {
this.loading = false;
}
},
reset() {
this.categories = [];
this.loading = false;
this.error = null;
}
}
});
});
+2 -1
View File
@@ -9,6 +9,7 @@ interface Channel {
category_id?: string | null
created_at: string
updated_at: string
unread_count?: number
}
/*
@@ -65,4 +66,4 @@ export const useChannelStore = defineStore('channel', {
this.error = null;
}
}
})
})
+16 -5
View File
@@ -1,6 +1,6 @@
import {defineStore} from 'pinia';
import {useAppStore} from "@/stores/app.ts";
import {emitGatewayEvent} from "@/plugins/events.ts";
import {emitGatewayEvent, emitReloadAll} from "@/plugins/events.ts";
type GatewayStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
@@ -10,6 +10,7 @@ export const useGatewayStore = defineStore('gateway', {
status: 'disconnected' as GatewayStatus,
reconnectAttempts: 0,
shouldReconnect: false,
reloadOnConnect: false,
reconnectTimer: null as number | null,
}),
@@ -34,22 +35,31 @@ export const useGatewayStore = defineStore('gateway', {
const socket = new WebSocket(wsUrl)
socket.onopen = () => {
if (this.socket !== socket) return
const shouldReload = this.reloadOnConnect
this.status = 'connected'
this.reconnectAttempts = 0
this.reloadOnConnect = false
if (shouldReload) {
void emitReloadAll()
}
}
socket.onclose = () => {
if (this.socket !== socket) return
this.status = 'disconnected'
if (this.socket === socket) {
this.socket = null
}
this.socket = null
if (this.shouldReconnect) {
this.reloadOnConnect = true
this.scheduleReconnect()
}
}
socket.onerror = () => {
if (this.socket !== socket) return
this.status = 'error'
}
@@ -70,6 +80,7 @@ export const useGatewayStore = defineStore('gateway', {
this.socket = null
this.status = 'disconnected'
this.reconnectAttempts = 0
this.reloadOnConnect = false
},
async send(payload: object) {
@@ -100,4 +111,4 @@ export const useGatewayStore = defineStore('gateway', {
}, delay)
},
}
});
});
+307 -41
View File
@@ -1,9 +1,18 @@
import {defineStore} from "pinia";
import {useApi} from "@/composables/useApi.ts";
import {onGatewayEvent} from "@/plugins/events.ts";
import {useServerStore} from "@/stores/server.ts";
import {useAuthStore} from "@/stores/auth.ts";
import {useNotificationStore} from "@/stores/notification.ts";
interface Message {
// Change this value to adjust the maximum number of messages kept in the DOM.
// Directional loads automatically use half of this window.
export const MESSAGE_WINDOW_SIZE = 50;
export const MESSAGE_SHIFT_SIZE = Math.max(1, Math.floor(MESSAGE_WINDOW_SIZE / 2));
export interface Message {
id: string;
server_id: string | null;
channel_id: string;
user_id: string;
content: string;
@@ -12,78 +21,335 @@ interface Message {
reply_to_id: string | null;
}
export interface ReadStateResponse {
channel_id: string;
last_read_message_id: string | null;
updated_at: string | null;
unread_count: number;
}
interface MessagePage {
messages: Message[];
oldest_id: string | null;
newest_id: string | null;
has_more_before: boolean;
has_more_after: boolean;
}
interface WindowChange {
addedIds: string[];
removedIds: string[];
}
function compareMessages(left: Message, right: Message): number {
if (left.id < right.id) return -1;
if (left.id > right.id) return 1;
return 0;
}
function mergeMessages(messages: Message[]): Message[] {
const byId = new Map<string, Message>();
for (const message of messages) {
byId.set(message.id, message);
}
return Array.from(byId.values()).sort(compareMessages);
}
async function requestPage(
channelId: string,
params: { limit: number; before_id?: string; after_id?: string },
): Promise<MessagePage> {
const query = new URLSearchParams({
channel_id: channelId,
limit: String(params.limit),
});
if (params.before_id) query.set("before_id", params.before_id);
if (params.after_id) query.set("after_id", params.after_id);
const response = await useApi().get(`/messages?${query.toString()}`);
if (!response.ok) {
throw new Error(`Message loading failed (${response.status})`);
}
return response.json() as Promise<MessagePage>;
}
export const useMessageStore = defineStore("message", {
state: () => ({
messages: [] as Message[],
activeChannelId: null as string | null,
oldestId: null as string | null,
newestId: null as string | null,
hasMoreBefore: false,
hasMoreAfter: false,
loading: false,
loadingBefore: false,
loadingAfter: false,
isAtBottom: true,
scrollToBottomRequested: false,
requestVersion: 0,
seenRealtimeMessageIds: new Set<string>(),
}),
actions: {
async fetchMessages(channel_id: string) {
updateBoundaries(page: MessagePage) {
this.oldestId = page.oldest_id ?? this.messages[0]?.id ?? null;
this.newestId = page.newest_id ?? this.messages[this.messages.length - 1]?.id ?? null;
this.hasMoreBefore = page.has_more_before;
this.hasMoreAfter = page.has_more_after;
},
updateLocalBoundaries() {
this.oldestId = this.messages[0]?.id ?? null;
this.newestId = this.messages[this.messages.length - 1]?.id ?? null;
},
async fetchMessages(channelId: string) {
const requestVersion = ++this.requestVersion;
this.activeChannelId = channelId;
this.messages = [];
this.oldestId = null;
this.newestId = null;
this.hasMoreBefore = false;
this.hasMoreAfter = false;
this.isAtBottom = true;
this.loading = true;
// Query params
let params = new URLSearchParams();
params.append("channel_id", channel_id);
const queryString = params.toString();
try {
const api = useApi();
// Utilisation du paramètre pour cibler le channel
const response = await api.get(`/messages${queryString ? `?${queryString}` : ""}`);
this.messages = await response.json();
const page = await requestPage(channelId, {limit: MESSAGE_WINDOW_SIZE});
if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return;
this.messages = mergeMessages(page.messages).slice(-MESSAGE_WINDOW_SIZE);
this.updateBoundaries(page);
this.scrollToBottomRequested = true;
} catch (error) {
console.error("Erreur lors du chargement des messages:", error);
if (requestVersion === this.requestVersion) {
console.error("Erreur lors du chargement des messages:", error);
}
} finally {
this.loading = false;
if (requestVersion === this.requestVersion) {
this.loading = false;
}
}
},
async sendMessage(channelId: string, content: string) {
const api = useApi();
console.log("channelId", channelId);
try {
// Envoi au serveur pour persistance
const response = await api.post('/messages', {
channel_id: channelId,
content: content,
reply_to_id: null
});
const newMessage = await response.json();
// Ajout local immédiat (optimistic update)
// this.messages.push(newMessage);
async fetchBefore(channelId: string): Promise<WindowChange | null> {
if (
this.activeChannelId !== channelId ||
!this.oldestId ||
!this.hasMoreBefore ||
this.loadingBefore ||
this.loadingAfter
) return null;
const requestVersion = this.requestVersion;
const previousIds = new Set(this.messages.map(message => message.id));
this.loadingBefore = true;
try {
const page = await requestPage(channelId, {
limit: MESSAGE_SHIFT_SIZE,
before_id: this.oldestId,
});
if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return null;
const incoming = mergeMessages(page.messages);
const merged = mergeMessages([...incoming, ...this.messages]);
this.messages = merged.slice(0, MESSAGE_WINDOW_SIZE);
this.updateBoundaries(page);
this.oldestId = this.messages[0]?.id ?? null;
this.newestId = this.messages[this.messages.length - 1]?.id ?? null;
return {
addedIds: incoming.filter(message => !previousIds.has(message.id)).map(message => message.id),
removedIds: merged.slice(0, -MESSAGE_WINDOW_SIZE).map(message => message.id),
};
} catch (error) {
console.error("Erreur lors du chargement des messages précédents:", error);
return null;
} finally {
if (requestVersion === this.requestVersion) {
this.loadingBefore = false;
}
}
},
async fetchAfter(channelId: string): Promise<WindowChange | null> {
if (
this.activeChannelId !== channelId ||
!this.newestId ||
!this.hasMoreAfter ||
this.loadingBefore ||
this.loadingAfter
) return null;
const requestVersion = this.requestVersion;
const previousIds = new Set(this.messages.map(message => message.id));
this.loadingAfter = true;
try {
const page = await requestPage(channelId, {
limit: MESSAGE_SHIFT_SIZE,
after_id: this.newestId,
});
if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return null;
const incoming = mergeMessages(page.messages);
const merged = mergeMessages([...this.messages, ...incoming]);
this.messages = merged.slice(-MESSAGE_WINDOW_SIZE);
this.updateBoundaries(page);
this.oldestId = this.messages[0]?.id ?? null;
this.newestId = this.messages[this.messages.length - 1]?.id ?? null;
return {
addedIds: incoming.filter(message => !previousIds.has(message.id)).map(message => message.id),
removedIds: merged.slice(0, Math.max(0, merged.length - MESSAGE_WINDOW_SIZE)).map(message => message.id),
};
} catch (error) {
console.error("Erreur lors du chargement des messages suivants:", error);
return null;
} finally {
if (requestVersion === this.requestVersion) {
this.loadingAfter = false;
}
}
},
async sendMessage(channelId: string, content: string) {
try {
const response = await useApi().post("/messages", {
channel_id: channelId,
content,
reply_to_id: null,
});
if (!response.ok) {
throw new Error(`Message sending failed (${response.status})`);
}
const newMessage = await response.json() as Message;
this.addRealtimeMessage(newMessage);
return newMessage;
} catch (error) {
console.error("Erreur lors de l'envoi du message:", error);
throw error;
}
},
async markChannelRead(channelId: string, messageId: string): Promise<ReadStateResponse> {
const response = await useApi().put(`/channels/${channelId}/read-state`, {
last_read_message_id: messageId,
});
if (!response.ok) {
throw new Error(`Read state update failed (${response.status})`);
}
return await response.json() as ReadStateResponse;
},
addRealtimeMessage(message: Message, fromGateway = false) {
const serverStore = useServerStore();
const authStore = useAuthStore();
const notificationStore = useNotificationStore();
if (fromGateway) {
if (this.seenRealtimeMessageIds.has(message.id)) return;
this.seenRealtimeMessageIds.add(message.id);
if (this.seenRealtimeMessageIds.size > 1000) {
const oldest = this.seenRealtimeMessageIds.values().next().value;
if (oldest) this.seenRealtimeMessageIds.delete(oldest);
}
}
const isOwnMessage = authStore.currentUser?.id === message.user_id;
const isActiveChannel = message.channel_id === this.activeChannelId;
if (!isActiveChannel) {
if (fromGateway && !isOwnMessage) {
serverStore.applyIncomingMessage(message.server_id, message.channel_id);
notificationStore.show("Nouveau message", "Un nouveau message est arrivé dans un autre canal.");
}
return;
}
const existingIndex = this.messages.findIndex(current => current.id === message.id);
if (existingIndex !== -1) {
this.messages[existingIndex] = message;
return;
}
if (!this.isAtBottom && this.newestId && message.id > this.newestId) {
this.hasMoreAfter = true;
if (fromGateway && !isOwnMessage) {
serverStore.applyIncomingMessage(message.server_id, message.channel_id);
}
return;
}
this.messages = mergeMessages([...this.messages, message]).slice(-MESSAGE_WINDOW_SIZE);
this.updateLocalBoundaries();
this.hasMoreAfter = false;
if (this.isAtBottom) {
this.scrollToBottomRequested = true;
} else if (fromGateway && !isOwnMessage) {
serverStore.applyIncomingMessage(message.server_id, message.channel_id);
}
},
updateMessage(message: Message) {
if (message.channel_id !== this.activeChannelId) return;
const index = this.messages.findIndex(current => current.id === message.id);
if (index !== -1) this.messages[index] = message;
},
removeMessage(id: string) {
const index = this.messages.findIndex(message => message.id === id);
if (index === -1) return;
this.messages.splice(index, 1);
this.updateLocalBoundaries();
},
setAtBottom(value: boolean) {
this.isAtBottom = value;
},
consumeScrollToBottomRequest() {
const requested = this.scrollToBottomRequested;
this.scrollToBottomRequested = false;
return requested;
},
reset() {
this.requestVersion += 1;
this.messages = [];
}
}
this.activeChannelId = null;
this.oldestId = null;
this.newestId = null;
this.hasMoreBefore = false;
this.hasMoreAfter = false;
this.loading = false;
this.loadingBefore = false;
this.loadingAfter = false;
this.isAtBottom = true;
this.scrollToBottomRequested = false;
this.seenRealtimeMessageIds.clear();
},
},
});
onGatewayEvent("Message", (payload) => {
const store = useMessageStore();
switch (payload.action) {
case "add":
const exists = store.messages.some(m => m.id === payload.content.id);
if (!exists) {
store.messages.push(payload.content);
}
store.addRealtimeMessage(payload.content as Message, true);
break;
case "update":
const updateIndex = store.messages.findIndex(m => m.id === payload.content.id);
if (updateIndex !== -1) {
store.messages[updateIndex] = payload.content;
}
store.updateMessage(payload.content as Message);
break;
case "remove":
const removeIndex = store.messages.findIndex(m => m.id === payload.content);
if (removeIndex !== -1) {
store.messages.splice(removeIndex, 1);
}
store.removeMessage(String(payload.content));
break;
default:
console.warn("Action non gérée :", payload.action);
}
})
});
+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 {useCategoryStore} from "@/stores/category.ts";
interface Server {
export interface Server {
id: string
name: string
is_default: boolean
created_at: string
updated_at: string
unread_count?: number
}
export const useServerStore = defineStore("server", {
@@ -24,6 +25,22 @@ export const useServerStore = defineStore("server", {
const response = await api.get("/servers");
this.servers = await response.json();
},
async fetchServer(serverId: string) {
const response = await useApi().get(`/servers/${serverId}`);
if (!response.ok) {
const error = await response.json().catch(() => null);
throw new Error(error?.message || 'Failed to load server');
}
const server: Server = await response.json();
const index = this.servers.findIndex(item => item.id === server.id);
if (index >= 0) {
server.unread_count ??= this.servers[index].unread_count ?? 0;
this.servers[index] = server;
}
else this.servers.push(server);
return server;
},
async createServer(payload: { name: string; password?: string | null; is_default?: boolean }) {
this.loading = true;
this.error = null;
@@ -44,6 +61,25 @@ export const useServerStore = defineStore("server", {
this.loading = false;
}
},
async updateServer(serverId: string, payload: { name: string; is_default?: boolean }) {
const api = useApi();
const response = await api.put(`/servers/${serverId}`, {
name: payload.name,
password: null,
is_default: payload.is_default ?? false,
});
if (!response.ok) {
const error = await response.json().catch(() => null);
throw new Error(error?.message || 'Failed to update server');
}
const updated = await response.json();
const index = this.servers.findIndex(server => server.id === serverId);
if (index >= 0) {
updated.unread_count ??= this.servers[index].unread_count ?? 0;
this.servers[index] = updated;
}
return updated;
},
async fetchServerTree(serverId: string) {
const api = useApi();
const channelStore = useChannelStore();
@@ -74,10 +110,46 @@ export const useServerStore = defineStore("server", {
return tree.items;
},
applyChannelReadState(serverId: string, channelId: string, unreadCount: number) {
let previousUnreadCount = 0;
for (const item of this.currentTree) {
const channels = "Category" in item ? item.Category[1] : "Channel" in item ? [item.Channel] : [];
const channel = channels.find((candidate: { id: string }) => candidate.id === channelId);
if (channel) {
previousUnreadCount = channel.unread_count ?? 0;
channel.unread_count = unreadCount;
break;
}
}
const server = this.servers.find(candidate => candidate.id === serverId);
if (server) {
server.unread_count = Math.max(
0,
(server.unread_count ?? 0) - previousUnreadCount + unreadCount,
);
}
},
applyIncomingMessage(serverId: string | null, channelId: string) {
if (!serverId) return;
for (const item of this.currentTree) {
const channels = "Category" in item ? item.Category[1] : "Channel" in item ? [item.Channel] : [];
const channel = channels.find((candidate: { id: string }) => candidate.id === channelId);
if (channel) {
channel.unread_count = (channel.unread_count ?? 0) + 1;
break;
}
}
const server = this.servers.find(candidate => candidate.id === serverId);
if (server) server.unread_count = (server.unread_count ?? 0) + 1;
},
reset() {
this.servers = [];
this.loading = false;
this.error = null;
}
}
});
});
+18 -1
View File
@@ -5,8 +5,10 @@ import {useServerStore} from '@/stores/server.ts'
import {useCategoryStore} from '@/stores/category.ts'
import {useChannelStore} from '@/stores/channel.ts'
import {useMessageStore} from '@/stores/message.ts'
import {onReloadAll} from '@/plugins/events.ts'
let bootstrapPromise: Promise<void> | null = null
let reloadServersPromise: Promise<void> | null = null
export const useSessionStore = defineStore('session', {
state: () => ({
@@ -77,6 +79,19 @@ export const useSessionStore = defineStore('session', {
])
},
async reloadServers() {
if (reloadServersPromise) {
return reloadServersPromise
}
const serverStore = useServerStore()
reloadServersPromise = serverStore.fetchServers().finally(() => {
reloadServersPromise = null
})
return reloadServersPromise
},
async login(username: string, password: string) {
const authStore = useAuthStore()
@@ -93,4 +108,6 @@ export const useSessionStore = defineStore('session', {
this.isReady = true
},
},
})
})
onReloadAll(() => useSessionStore().reloadServers())
+20 -1
View File
@@ -52,6 +52,11 @@ export interface ChannelRolePermission {
permissions: ChannelPermissionMask
}
export interface ChannelPermissions {
users: ChannelUserPermission[]
roles: ChannelRolePermission[]
}
export interface ServerUserPermissionDto {
id: string
server_id: string
@@ -80,6 +85,11 @@ export interface ChannelRolePermissionDto {
permissions: number | string
}
export interface ChannelPermissionsDto {
users: ChannelUserPermissionDto[]
roles: ChannelRolePermissionDto[]
}
function asServerPermissionMask(value: bigint): ServerPermissionMask {
return value as ServerPermissionMask
}
@@ -442,4 +452,13 @@ export function channelRolePermissionFromDto(
role_id: dto.role_id,
permissions: toChannelPermissionMask(dto.permissions),
}
}
}
export function channelPermissionsFromDto(
dto: ChannelPermissionsDto,
): ChannelPermissions {
return {
users: dto.users.map(channelUserPermissionFromDto),
roles: dto.roles.map(channelRolePermissionFromDto),
}
}
+7
View File
@@ -0,0 +1,7 @@
export interface Role {
id: string
server_id: string
name: string
is_default: boolean
created_at: string
}
+5
View File
@@ -1660,6 +1660,11 @@ has-flag@^4.0.0:
resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz"
integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
highlight.js@^11.11.1:
version "11.11.1"
resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.11.1.tgz#fca06fa0e5aeecf6c4d437239135fabc15213585"
integrity sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==
hookable@^5.5.3:
version "5.5.3"
resolved "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz"
+12 -12
View File
@@ -1,12 +1,12 @@
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20220101_000001_create_table::Migration)]
}
}
pub use sea_orm_migration::prelude::*;
mod m20220101_000001_create_table;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20220101_000001_create_table::Migration)]
}
}
+174 -65
View File
@@ -138,13 +138,18 @@ impl MigrationTrait for Migration {
.to(Alias::new("user"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade),
)
.index(
Index::create()
.name("uq_server_user")
.col(Alias::new("server_id"))
.col(Alias::new("user_id"))
.unique(),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("uq_server_user")
.table(Alias::new("server_user"))
.col(Alias::new("server_id"))
.col(Alias::new("user_id"))
.unique()
.to_owned(),
)
.await?;
@@ -185,13 +190,18 @@ impl MigrationTrait for Migration {
.to(Alias::new("server"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade),
)
.index(
Index::create()
.name("uq_role_server_name")
.col(Alias::new("server_id"))
.col(Alias::new("name"))
.unique(),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("uq_role_server_name")
.table(Alias::new("role"))
.col(Alias::new("server_id"))
.col(Alias::new("name"))
.unique()
.to_owned(),
)
.await?;
@@ -376,14 +386,6 @@ impl MigrationTrait for Migration {
.to(Alias::new("category"), Alias::new("id"))
.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(),
)
.await?;
@@ -400,6 +402,19 @@ impl MigrationTrait for Migration {
)
.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
// ---------------------------------------------------------------------
@@ -443,13 +458,82 @@ impl MigrationTrait for Migration {
.to(Alias::new("user"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade),
)
.index(
Index::create()
.name("uq_channel_user")
.col(Alias::new("channel_id"))
.col(Alias::new("user_id"))
.unique(),
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("uq_channel_user")
.table(Alias::new("channel_user"))
.col(Alias::new("channel_id"))
.col(Alias::new("user_id"))
.unique()
.to_owned(),
)
.await?;
// ---------------------------------------------------------------------
// Position de lecture des utilisateurs
// ---------------------------------------------------------------------
manager
.create_table(
Table::create()
.table(Alias::new("channel_user_read_state"))
.if_not_exists()
.col(
ColumnDef::new(Alias::new("id"))
.uuid()
.not_null()
.primary_key(),
)
.col(ColumnDef::new(Alias::new("channel_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("user_id")).uuid().not_null())
.col(
ColumnDef::new(Alias::new("last_read_message_id"))
.uuid()
.null(),
)
.col(
ColumnDef::new(Alias::new("updated_at"))
.timestamp_with_time_zone()
.not_null()
.default(Expr::current_timestamp()),
)
.foreign_key(
ForeignKey::create()
.name("fk_channel_user_read_state_channel")
.from(
Alias::new("channel_user_read_state"),
Alias::new("channel_id"),
)
.to(Alias::new("channel"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade),
)
.foreign_key(
ForeignKey::create()
.name("fk_channel_user_read_state_user")
.from(
Alias::new("channel_user_read_state"),
Alias::new("user_id"),
)
.to(Alias::new("user"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("uq_channel_user_read_state")
.table(Alias::new("channel_user_read_state"))
.col(Alias::new("channel_id"))
.col(Alias::new("user_id"))
.unique()
.to_owned(),
)
.await?;
@@ -509,6 +593,17 @@ impl MigrationTrait for Migration {
)
.await?;
manager
.create_index(
Index::create()
.name("idx_message_channel_id_id")
.table(Alias::new("message"))
.col(Alias::new("channel_id"))
.col(Alias::new("id"))
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
@@ -568,13 +663,6 @@ impl MigrationTrait for Migration {
.not_null()
.default(0),
)
.index(
Index::create()
.name("uq_server_user_permission")
.col(Alias::new("server_id"))
.col(Alias::new("user_id"))
.unique(),
)
.foreign_key(
ForeignKey::create()
.name("fk_server_user_permission_server")
@@ -596,6 +684,18 @@ impl MigrationTrait for Migration {
)
.await?;
manager
.create_index(
Index::create()
.name("uq_server_user_permission")
.table(Alias::new("server_user_permission"))
.col(Alias::new("server_id"))
.col(Alias::new("user_id"))
.unique()
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
@@ -632,13 +732,18 @@ impl MigrationTrait for Migration {
.to(Alias::new("role"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade),
)
.index(
Index::create()
.name("uq_server_role_permission")
.col(Alias::new("server_id"))
.col(Alias::new("role_id"))
.unique(),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("uq_server_role_permission")
.table(Alias::new("server_role_permission"))
.col(Alias::new("server_id"))
.col(Alias::new("role_id"))
.unique()
.to_owned(),
)
.await?;
@@ -679,13 +784,18 @@ impl MigrationTrait for Migration {
.to(Alias::new("role"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade),
)
.index(
Index::create()
.name("uq_channel_role_permission")
.col(Alias::new("channel_id"))
.col(Alias::new("role_id"))
.unique(),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("uq_channel_role_permission")
.table(Alias::new("channel_role_permission"))
.col(Alias::new("channel_id"))
.col(Alias::new("role_id"))
.unique()
.to_owned(),
)
.await?;
@@ -726,13 +836,18 @@ impl MigrationTrait for Migration {
.to(Alias::new("user"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade),
)
.index(
Index::create()
.name("uq_channel_user_permission")
.col(Alias::new("channel_id"))
.col(Alias::new("user_id"))
.unique(),
)
.to_owned(),
)
.await?;
manager
.create_index(
Index::create()
.name("uq_channel_user_permission")
.table(Alias::new("channel_user_permission"))
.col(Alias::new("channel_id"))
.col(Alias::new("user_id"))
.unique()
.to_owned(),
)
.await?;
@@ -746,6 +861,7 @@ impl MigrationTrait for Migration {
Table::create()
.table(Alias::new("computed_permission"))
.if_not_exists()
.col(ColumnDef::new(Alias::new("id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("user_id")).uuid().not_null())
.col(ColumnDef::new(Alias::new("server_id")).uuid().not_null())
.col(
@@ -762,6 +878,7 @@ impl MigrationTrait for Migration {
)
.primary_key(
Index::create()
.col(Alias::new("id"))
.col(Alias::new("user_id"))
.col(Alias::new("server_id"))
.col(Alias::new("scope_type"))
@@ -791,6 +908,7 @@ impl MigrationTrait for Migration {
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let tables = [
"computed_permission",
"channel_user_read_state",
"channel_user_permission",
"channel_role_permission",
"server_user_permission",
@@ -819,15 +937,6 @@ impl MigrationTrait for Migration {
.await?;
}
manager
.drop_index(
Index::drop()
.name("idx_server_item_order_scope")
.table(Alias::new("server_item_order"))
.to_owned(),
)
.await?;
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()));
// Initialize gateway manager
let gateway = Arc::new(GatewayManager::default());
// Init one server if no one exist
let default_server = match repositories.server.get_default().await? {
@@ -68,6 +67,11 @@ impl App {
let metrics = AppMetrics::new();
let services = Arc::new(Services::new(repositories.clone(), event_bus.clone()));
services.permission_sync.start_listen_event().await;
services.realtime_registry.initialize(&repositories).await?;
services.realtime_registry.start_listening(repositories.clone(), event_bus.clone());
let gateway = Arc::new(GatewayManager::new(services.clone()));
gateway.start(event_bus.clone());
let state = AppState {
db,
+6 -3
View File
@@ -10,9 +10,7 @@ pub struct CategoryQueryParams {
impl Default for CategoryQueryParams {
fn default() -> Self {
Self {
server_id: None,
}
Self { server_id: None }
}
}
@@ -36,4 +34,9 @@ pub struct CategoryResponse {
pub name: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
/// None : contexte sans permissions (champ ignoré dans le JSON).
/// Some(value) : valeur de computed_permission (0 si absente).
#[serde(skip_serializing_if = "Option::is_none")]
pub permission: Option<u64>,
}
+27
View File
@@ -41,6 +41,27 @@ pub struct ChannelResponse {
pub name: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unread_count: Option<u64>,
/// None : contexte sans permissions (champ ignoré dans le JSON).
/// Some(value) : valeur de computed_permission (0 si absente).
#[serde(skip_serializing_if = "Option::is_none")]
pub permission: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ReadStateResponse {
pub channel_id: Uuid,
pub last_read_message_id: Option<Uuid>,
pub updated_at: Option<DateTime<Utc>>,
pub unread_count: u64,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SetReadStateRequest {
pub last_read_message_id: Option<Uuid>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
@@ -65,3 +86,9 @@ pub struct ChannelRolePermissionResponse {
pub role_id: Uuid,
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)]
pub struct MessageResponse {
pub id: Uuid,
pub server_id: Option<Uuid>,
pub channel_id: Uuid,
pub user_id: Uuid,
pub content: String,
@@ -14,6 +15,15 @@ pub struct MessageResponse {
pub reply_to_id: Option<Uuid>,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct MessagePageResponse {
pub messages: Vec<MessageResponse>,
pub oldest_id: Option<Uuid>,
pub newest_id: Option<Uuid>,
pub has_more_before: bool,
pub has_more_after: bool,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateMessageRequest {
pub channel_id: Uuid,
@@ -26,19 +36,10 @@ pub struct UpdateMessageRequest {
pub content: String,
}
#[derive(serde::Deserialize, utoipa::IntoParams)]
#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
pub struct MessageQueryParams {
pub channel_id: Option<uuid::Uuid>,
pub channel_id: Uuid,
pub before_id: Option<Uuid>,
pub after_id: Option<Uuid>,
pub limit: Option<u64>,
}
impl Default for MessageQueryParams {
fn default() -> Self {
Self {
channel_id: None,
before_id: None,
limit: Some(50),
}
}
}
+5
View File
@@ -3,6 +3,11 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, ToSchema, utoipa::IntoParams)]
pub struct RoleQueryParams {
pub server_id: Option<Uuid>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateRoleRequest {
pub server_id: Uuid,
+2
View File
@@ -28,6 +28,8 @@ pub struct ServerResponse {
pub is_default: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unread_count: Option<u64>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
+1 -3
View File
@@ -1,7 +1,5 @@
use migration::{Migrator, MigratorTrait};
use oxspeak_server_lib::config::AppConfig;
use oxspeak_server_lib::core::App;
use oxspeak_server_lib::database::Database;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -10,7 +8,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.with_env_filter(
std::env::var("RUST_LOG")
// .unwrap_or_else(|_| "info,sqlx=debug,sea_orm=debug,sea_orm_migration=info".into()),
.unwrap_or_else(|_| "info,sqlx=info,sea_orm=info,sea_orm_migration=info".into()),
.unwrap_or_else(|_| "info,sqlx=debug,sea_orm=debug,sea_orm_migration=debug".into()),
)
.with_target(true)
.with_level(true)
+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 {}
-3
View File
@@ -48,9 +48,6 @@ pub struct Model {
on_delete = "Cascade"
)]
pub user: HasOne<super::user::Entity>,
#[sea_orm(has_many)]
pub computed_permissions: HasMany<super::computed_permission::Entity>,
}
#[async_trait]
+21 -20
View File
@@ -1,20 +1,21 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
pub mod prelude;
pub mod attachment;
pub mod category;
pub mod channel;
pub mod channel_role_permission;
pub mod channel_user;
pub mod channel_user_permission;
pub mod computed_permission;
pub mod message;
pub mod role;
pub mod role_user;
pub mod server;
pub mod server_item_order;
pub mod server_role_permission;
pub mod server_user;
pub mod server_user_permission;
pub mod user;
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
pub mod prelude;
pub mod attachment;
pub mod category;
pub mod channel;
pub mod channel_role_permission;
pub mod channel_user;
pub mod channel_user_read_state;
pub mod channel_user_permission;
pub mod computed_permission;
pub mod message;
pub mod role;
pub mod role_user;
pub mod server;
pub mod server_item_order;
pub mod server_role_permission;
pub mod server_user;
pub mod server_user_permission;
pub mod user;
+17 -16
View File
@@ -1,16 +1,17 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
pub use super::attachment::Entity as Attachment;
pub use super::category::Entity as Category;
pub use super::channel::Entity as Channel;
pub use super::channel_user::Entity as ChannelUser;
pub use super::computed_permission::Entity as ComputedPermission;
pub use super::message::Entity as Message;
pub use super::role::Entity as Group;
pub use super::role_user::Entity as GroupMember;
pub use super::server::Entity as Server;
pub use super::server_item_order::Entity as ServerItemOrder;
pub use super::server_role_permission::Entity as ServerRolePermission;
pub use super::server_user::Entity as ServerUser;
pub use super::server_user_permission::Entity as ServerUserPermission;
pub use super::user::Entity as User;
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
pub use super::attachment::Entity as Attachment;
pub use super::category::Entity as Category;
pub use super::channel::Entity as Channel;
pub use super::channel_user::Entity as ChannelUser;
pub use super::channel_user_read_state::Entity as ChannelUserReadState;
pub use super::computed_permission::Entity as ComputedPermission;
pub use super::message::Entity as Message;
pub use super::role::Entity as Group;
pub use super::role_user::Entity as GroupMember;
pub use super::server::Entity as Server;
pub use super::server_item_order::Entity as ServerItemOrder;
pub use super::server_role_permission::Entity as ServerRolePermission;
pub use super::server_user::Entity as ServerUser;
pub use super::server_user_permission::Entity as ServerUserPermission;
pub use super::user::Entity as User;
+1 -1
View File
@@ -5,7 +5,7 @@ use sea_orm::{NotSet, Set};
#[sea_orm::model]
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "server_group_permission")]
#[sea_orm(table_name = "server_role_permission")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
+20
View File
@@ -59,6 +59,16 @@ impl ChannelRepository {
.await?)
}
pub async fn list_user_permissions(
&self,
channel_id: Uuid,
) -> AnyResult<Vec<channel_user_permission::Model>> {
Ok(channel_user_permission::Entity::find()
.filter(channel_user_permission::Column::ChannelId.eq(channel_id))
.all(&self.context.db)
.await?)
}
pub async fn set_user_permission(
&self,
channel_id: Uuid,
@@ -109,6 +119,16 @@ impl ChannelRepository {
.await?)
}
pub async fn list_role_permissions(
&self,
channel_id: Uuid,
) -> AnyResult<Vec<channel_role_permission::Model>> {
Ok(channel_role_permission::Entity::find()
.filter(channel_role_permission::Column::ChannelId.eq(channel_id))
.all(&self.context.db)
.await?)
}
pub async fn set_role_permission(
&self,
channel_id: Uuid,
+14
View File
@@ -13,6 +13,7 @@ use std::sync::{Arc, OnceLock};
use uuid::Uuid;
use crate::models::computed_permission::PermissionScopeType;
use crate::repositories::types::PermissionResource;
use crate::utils::ScopedLockManager;
// Instance globale du manager de verrous scopés par Server ID
@@ -35,6 +36,19 @@ impl ComputedPermissionRepository {
.await?)
}
pub async fn get_for_resource(
&self,
user_id: Uuid,
resource: PermissionResource,
) -> AnyResult<Option<computed_permission::Model>> {
Ok(computed_permission::Entity::find()
.filter(computed_permission::Column::UserId.eq(user_id))
.filter(computed_permission::Column::ScopeType.eq(resource.scope_type()))
.filter(computed_permission::Column::ResourceId.eq(resource.resource_id()))
.one(&self.context.db)
.await?)
}
/// Vérifie si l'utilisateur possède au moins une entrée de permission sur une ressource.
pub async fn had_perm_on(&self, user_id: Uuid, resource_id: Uuid) -> AnyResult<bool> {
Ok(computed_permission::Entity::find()
+88 -6
View File
@@ -1,9 +1,18 @@
use super::types::MessageFilter;
use crate::models::{channel, message};
use crate::models::message;
use crate::repositories::{AnyResult, RepositoryContext};
use event_bus::Scope;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use std::sync::Arc;
use uuid::Uuid;
const DEFAULT_MESSAGE_LIMIT: u64 = 20;
pub const MAX_MESSAGE_LIMIT: u64 = 100;
pub struct MessagePage {
pub messages: Vec<message::Model>,
pub has_more_before: bool,
pub has_more_after: bool,
}
#[derive(Clone, Debug)]
pub struct MessageRepository {
@@ -21,7 +30,11 @@ impl MessageRepository {
.await?)
}
pub async fn filter(&self, filter: MessageFilter) -> AnyResult<Vec<message::Model>> {
pub async fn filter(&self, filter: MessageFilter) -> AnyResult<MessagePage> {
let limit = filter
.limit
.unwrap_or(DEFAULT_MESSAGE_LIMIT)
.clamp(1, MAX_MESSAGE_LIMIT);
let mut query = message::Entity::find();
if let Some(channel_id) = filter.channel_id {
@@ -32,11 +45,80 @@ impl MessageRepository {
query = query.filter(message::Column::Id.lt(before_id));
}
if let Some(limit) = filter.limit {
query = query.order_by_desc(message::Column::Id).limit(limit);
if let Some(after_id) = filter.after_id {
query = query
.filter(message::Column::Id.gt(after_id))
.order_by_asc(message::Column::Id);
} else {
query = query.order_by_desc(message::Column::Id);
}
Ok(query.all(&self.context.db).await?)
let mut messages = query.limit(limit + 1).all(&self.context.db).await?;
let has_more_in_direction = messages.len() > limit as usize;
messages.truncate(limit as usize);
// Queries that walk backwards are executed in descending order so the
// database can stop as soon as it has found the requested rows. The UI
// always receives chronological order.
if filter.after_id.is_none() {
messages.reverse();
}
let (has_more_before, has_more_after) = if filter.after_id.is_some() {
let has_messages_before = self
.exists_on_or_before(filter.channel_id, filter.after_id.unwrap())
.await?;
(has_messages_before, has_more_in_direction)
} else if filter.before_id.is_some() {
let has_messages_after = self
.exists_on_or_after(filter.channel_id, filter.before_id.unwrap())
.await?;
(has_more_in_direction, has_messages_after)
} else {
(has_more_in_direction, false)
};
Ok(MessagePage {
messages,
has_more_before,
has_more_after,
})
}
async fn exists_on_or_before(&self, channel_id: Option<Uuid>, id: Uuid) -> AnyResult<bool> {
let mut query = message::Entity::find()
.select_only()
.column(message::Column::Id)
.filter(message::Column::Id.lte(id));
if let Some(channel_id) = channel_id {
query = query.filter(message::Column::ChannelId.eq(channel_id));
}
Ok(query
.limit(1)
.into_tuple::<Uuid>()
.one(&self.context.db)
.await?
.is_some())
}
async fn exists_on_or_after(&self, channel_id: Option<Uuid>, id: Uuid) -> AnyResult<bool> {
let mut query = message::Entity::find()
.select_only()
.column(message::Column::Id)
.filter(message::Column::Id.gte(id));
if let Some(channel_id) = channel_id {
query = query.filter(message::Column::ChannelId.eq(channel_id));
}
Ok(query
.limit(1)
.into_tuple::<Uuid>()
.one(&self.context.db)
.await?
.is_some())
}
pub async fn get_by_channel(&self, channel_id: uuid::Uuid) -> AnyResult<Vec<message::Model>> {
+7 -1
View File
@@ -4,6 +4,7 @@ use crate::repositories::category::CategoryRepository;
use crate::repositories::channel::ChannelRepository;
use crate::repositories::computed_permission::ComputedPermissionRepository;
use crate::repositories::message::MessageRepository;
use crate::repositories::read_state::ReadStateRepository;
use crate::repositories::role::RoleRepository;
use crate::repositories::server::ServerRepository;
use crate::repositories::server_item_order::ServerItemOrderRepository;
@@ -16,12 +17,13 @@ mod category;
mod channel;
mod computed_permission;
mod message;
mod read_state;
mod role;
mod server;
mod server_item_order;
mod server_tree;
pub mod types;
mod user;
pub mod user;
#[derive(Clone, Debug)]
pub struct RepositoryContext {
@@ -35,6 +37,7 @@ pub struct Repositories {
pub channel: ChannelRepository,
pub role: RoleRepository,
pub message: MessageRepository,
pub read_state: ReadStateRepository,
pub user: UserRepository,
pub computed_permission: ComputedPermissionRepository,
pub server_item_order: ServerItemOrderRepository,
@@ -61,6 +64,9 @@ impl Repositories {
message: MessageRepository {
context: context.clone(),
},
read_state: ReadStateRepository {
context: context.clone(),
},
user: UserRepository {
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 sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use std::sync::Arc;
@@ -63,4 +63,49 @@ impl RoleRepository {
.await?;
Ok(res.rows_affected > 0)
}
pub async fn get_members(&self, role_id: Uuid) -> AnyResult<Vec<user::Model>> {
let memberships = role_user::Entity::find()
.filter(role_user::Column::RoleId.eq(role_id))
.all(&self.context.db)
.await?;
let mut members = Vec::with_capacity(memberships.len());
for membership in memberships {
if let Some(user) = user::Entity::find_by_id(membership.user_id)
.one(&self.context.db)
.await?
{
members.push(user);
}
}
Ok(members)
}
pub async fn add_member(&self, role_id: Uuid, user_id: Uuid) -> AnyResult<bool> {
if role_user::Entity::find_by_id((role_id, user_id))
.one(&self.context.db)
.await?
.is_some()
{
return Ok(false);
}
role_user::ActiveModel {
role_id: Set(role_id),
user_id: Set(user_id),
}
.insert(&self.context.db)
.await?;
Ok(true)
}
pub async fn remove_member(&self, role_id: Uuid, user_id: Uuid) -> AnyResult<bool> {
let result = role_user::Entity::delete_by_id((role_id, user_id))
.exec(&self.context.db)
.await?;
Ok(result.rows_affected > 0)
}
}
+18
View File
@@ -84,6 +84,14 @@ impl ServerRepository {
Ok(true)
}
pub async fn get_user(&self, server_id: Uuid, user_id: Uuid) -> AnyResult<Option<server_user::Model>> {
Ok(server_user::Entity::find()
.filter(server_user::Column::ServerId.eq(server_id))
.filter(server_user::Column::UserId.eq(user_id))
.one(&self.context.db)
.await?)
}
pub async fn delete(&self, id: Uuid) -> AnyResult<bool> {
let res = server::Entity::delete_by_id(id)
.exec(&self.context.db)
@@ -108,6 +116,16 @@ impl ServerRepository {
.await?)
}
pub async fn get_user_permissions(
&self,
server_id: Uuid,
) -> AnyResult<Vec<server_user_permission::Model>> {
Ok(server_user_permission::Entity::find()
.filter(server_user_permission::Column::ServerId.eq(server_id))
.all(&self.context.db)
.await?)
}
pub async fn set_user_permission(
&self,
server_id: Uuid,
+2 -25
View File
@@ -1,9 +1,6 @@
use crate::models::server_item_order;
use crate::models::server_item_order::OrderedResourceType;
use crate::repositories::{AnyResult, RepositoryContext};
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, TransactionTrait,
};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use std::sync::Arc;
use uuid::Uuid;
@@ -17,29 +14,9 @@ impl ServerItemOrderRepository {
pub async fn get_by_server(&self, server_id: Uuid) -> AnyResult<Vec<server_item_order::Model>> {
Ok(server_item_order::Entity::find()
.filter(server_item_order::Column::ServerId.eq(server_id))
.order_by_asc(server_item_order::Column::ParentCategoryId)
.order_by_asc(server_item_order::Column::OrderKey)
.all(&self.context.db)
.await?)
}
/// Réorganise en bloc (reorder) une liste d'éléments dans un serveur ou une catégorie
pub async fn update_orders(
&self,
server_id: Uuid,
items: Vec<(Uuid, OrderedResourceType, Option<Uuid>, i64)>, // (resource_id, resource_type, parent_category_id, order_key)
) -> AnyResult<()> {
self.context
.db
.transaction::<_, (), anyhow::Error>(|txn| {
Box::pin(async move {
for (resource_id, res_type, parent_cat_id, order_key) in items {
// Logique de mise à jour / upsert des order_key
}
Ok(())
})
})
.await?;
Ok(())
}
}
+80 -46
View File
@@ -1,23 +1,12 @@
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::repositories::types::{CategoryWithPermissions, ChannelWithPermissions, ServerTreeData};
use crate::repositories::{AnyResult, RepositoryContext};
use sea_orm::{ColumnTrait, EntityTrait, JoinType, QueryFilter, QuerySelect};
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct ChannelWithPermissions {
pub channel: channel::Model,
pub permissions: ChannelPermission,
}
#[derive(Debug, Clone)]
pub struct ServerTreeData {
pub orders: Vec<server_item_order::Model>,
pub categories: Vec<category::Model>,
pub channels: Vec<ChannelWithPermissions>,
}
#[derive(Clone, Debug)]
pub struct ServerTreeRepository {
pub context: Arc<RepositoryContext>,
@@ -25,11 +14,13 @@ pub struct ServerTreeRepository {
impl ServerTreeRepository {
pub async fn get_for_user(&self, server_id: Uuid, user_id: Uuid) -> AnyResult<ServerTreeData> {
let (orders, categories, channel_rows) = tokio::try_join!(
let (orders, categories_models, channel_models, computed_permissions) = tokio::try_join!(
async {
Ok::<_, anyhow::Error>(
server_item_order::Entity::find()
.filter(server_item_order::Column::ServerId.eq(server_id))
.order_by_asc(server_item_order::Column::ParentCategoryId)
.order_by_asc(server_item_order::Column::OrderKey)
.all(&self.context.db)
.await?,
)
@@ -42,16 +33,82 @@ impl ServerTreeRepository {
.await?,
)
},
self.find_channels_with_permissions(server_id, user_id),
async {
Ok::<_, anyhow::Error>(
channel::Entity::find()
.filter(channel::Column::ServerId.eq(server_id))
.all(&self.context.db)
.await?,
)
},
async {
Ok::<_, anyhow::Error>(
computed_permission::Entity::find()
.filter(computed_permission::Column::UserId.eq(user_id))
.filter(computed_permission::Column::ServerId.eq(server_id))
.all(&self.context.db)
.await?,
)
},
)?;
let channels = channel_rows
let perm_map: HashMap<Uuid, u64> = computed_permissions
.into_iter()
.filter_map(|(channel, permission)| {
permission.map(|permission| ChannelWithPermissions {
.map(|cp| (cp.resource_id, cp.permissions as u64))
.collect();
let categories = categories_models
.into_iter()
.map(|category| {
let permissions = perm_map
.get(&category.id)
.map(|&p| ChannelPermission::from_bits_retain(p));
CategoryWithPermissions {
category,
permissions,
}
})
.collect();
let channel_ids: Vec<Uuid> = channel_models.iter().map(|channel| channel.id).collect();
let read_states = channel_user_read_state::Entity::find()
.filter(channel_user_read_state::Column::UserId.eq(user_id))
.filter(channel_user_read_state::Column::ChannelId.is_in(channel_ids.clone()))
.all(&self.context.db)
.await?;
let cursors: HashMap<Uuid, Option<Uuid>> = read_states
.into_iter()
.map(|state| (state.channel_id, state.last_read_message_id))
.collect();
let messages = message::Entity::find()
.select_only()
.column(message::Column::ChannelId)
.column(message::Column::Id)
.filter(message::Column::ChannelId.is_in(channel_ids))
.into_tuple::<(Uuid, Uuid)>()
.all(&self.context.db)
.await?;
let mut unread_counts = HashMap::new();
for (channel_id, message_id) in messages {
let unread = match cursors.get(&channel_id) {
Some(Some(cursor)) => message_id > *cursor,
_ => true,
};
if unread {
*unread_counts.entry(channel_id).or_insert(0) += 1;
}
}
let channels = channel_models
.into_iter()
.map(|channel| {
let permissions = perm_map
.get(&channel.id)
.map(|&p| ChannelPermission::from_bits_retain(p));
ChannelWithPermissions {
channel,
permissions: ChannelPermission::from_bits_retain(permission.permissions as u64),
})
permissions,
}
})
.collect();
@@ -59,30 +116,7 @@ impl ServerTreeRepository {
orders,
categories,
channels,
unread_counts,
})
}
async fn find_channels_with_permissions(
&self,
server_id: Uuid,
user_id: Uuid,
) -> AnyResult<Vec<(channel::Model, Option<computed_permission::Model>)>> {
let rows = channel::Entity::find()
.join(
JoinType::InnerJoin,
channel::Relation::ComputedPermission.def(),
)
.filter(channel::Column::ServerId.eq(server_id))
.filter(computed_permission::Column::UserId.eq(user_id))
.filter(computed_permission::Column::ServerId.eq(server_id))
.filter(
computed_permission::Column::ScopeType
.eq(computed_permission::PermissionScopeType::Channel),
)
.select_also(computed_permission::Entity)
.all(&self.context.db)
.await?;
Ok(rows)
}
}
+47 -1
View File
@@ -1,4 +1,6 @@
use crate::models::{category, channel};
use crate::models::{category, channel, computed_permission, server_item_order};
use crate::permissions::ChannelPermission;
use uuid::Uuid;
pub enum ServerExplorerItem {
Category(category::Model, Vec<channel::Model>),
@@ -22,6 +24,7 @@ pub struct ServerTree {
pub struct MessageFilter {
pub channel_id: Option<uuid::Uuid>,
pub before_id: Option<uuid::Uuid>,
pub after_id: Option<uuid::Uuid>,
pub limit: Option<u64>,
}
@@ -32,3 +35,46 @@ pub struct ChannelFilter {
pub struct UserFilter {
pub server_id: Option<uuid::Uuid>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermissionResource {
Server(Uuid),
Category(Uuid),
Channel(Uuid),
}
impl PermissionResource {
pub fn scope_type(self) -> computed_permission::PermissionScopeType {
match self {
Self::Server(_) => computed_permission::PermissionScopeType::Server,
Self::Category(_) => computed_permission::PermissionScopeType::Category,
Self::Channel(_) => computed_permission::PermissionScopeType::Channel,
}
}
pub fn resource_id(self) -> Uuid {
match self {
Self::Server(id) | Self::Category(id) | Self::Channel(id) => id,
}
}
}
#[derive(Debug, Clone)]
pub struct CategoryWithPermissions {
pub category: category::Model,
pub permissions: Option<ChannelPermission>,
}
#[derive(Debug, Clone)]
pub struct ChannelWithPermissions {
pub channel: channel::Model,
pub permissions: Option<ChannelPermission>,
}
#[derive(Debug, Clone)]
pub struct ServerTreeData {
pub orders: Vec<server_item_order::Model>,
pub categories: Vec<CategoryWithPermissions>,
pub channels: Vec<ChannelWithPermissions>,
pub unread_counts: std::collections::HashMap<Uuid, u64>,
}
+9 -1
View File
@@ -1,17 +1,25 @@
use crate::models::category;
use crate::domain::dto::category::{
CategoryResponse, CreateCategoryRequest, UpdateCategoryRequest,
};
use crate::models::category;
use sea_orm::Set;
use uuid::Uuid;
pub fn category_model_to_category_response(model: category::Model) -> CategoryResponse {
category_model_to_category_response_with_permission(model, None)
}
pub fn category_model_to_category_response_with_permission(
model: category::Model,
permission: Option<u64>,
) -> CategoryResponse {
CategoryResponse {
id: model.id,
server_id: model.server_id,
name: model.name,
created_at: model.created_at,
updated_at: model.updated_at,
permission,
}
}
+109 -3
View File
@@ -1,9 +1,10 @@
use crate::core::state::AppState;
use crate::http::context::Superuser;
use crate::http::context::{CurrentUser, Superuser};
use crate::http::error::HTTPError;
use crate::domain::dto::channel::{
ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
ChannelUserPermissionResponse, CreateChannelRequest, SetChannelPermissionRequest,
ChannelQueryParams, ChannelResponse, ChannelPermissionsResponse, ChannelRolePermissionResponse,
ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse,
SetChannelPermissionRequest, SetReadStateRequest,
UpdateChannelRequest,
};
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
#[utoipa::path(
get,
@@ -69,6 +155,26 @@ pub async fn get_by_id(
Ok(Json(mapper::channel_model_to_channel_response(channel)))
}
/// Liste les permissions directes configurées pour un canal.
#[utoipa::path(
get,
path = "/channels/{channel_id}/permissions",
params(("channel_id" = Uuid, Path, description = "ID du canal")),
responses((status = 200, body = ChannelPermissionsResponse), (status = 404, description = "Canal non trouvé")),
tag = "Channel Permissions"
)]
pub async fn list_permissions(
State(state): State<AppState>,
Path(channel_id): Path<Uuid>,
) -> Result<Json<ChannelPermissionsResponse>, HTTPError> {
state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?;
let (users, roles) = tokio::try_join!(
state.repositories.channel.list_user_permissions(channel_id),
state.repositories.channel.list_role_permissions(channel_id),
)?;
Ok(Json(mapper::channel_permissions_to_response(users, roles)))
}
/// Crée un nouveau channel
#[utoipa::path(
post,
+22 -3
View File
@@ -1,13 +1,20 @@
use crate::models::{channel, channel_role_permission, channel_user_permission};
use crate::repositories::types::ChannelFilter;
use crate::domain::dto::channel::{
ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
ChannelPermissionsResponse, ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
ChannelUserPermissionResponse, CreateChannelRequest, UpdateChannelRequest,
};
use crate::models::{channel, channel_role_permission, channel_user_permission};
use crate::repositories::types::ChannelFilter;
use sea_orm::Set;
use uuid::Uuid;
pub fn channel_model_to_channel_response(model: channel::Model) -> ChannelResponse {
channel_model_to_channel_response_with_permission(model, None)
}
pub fn channel_model_to_channel_response_with_permission(
model: channel::Model,
permission: Option<u64>,
) -> ChannelResponse {
ChannelResponse {
id: model.id,
server_id: model.server_id,
@@ -16,6 +23,8 @@ pub fn channel_model_to_channel_response(model: channel::Model) -> ChannelRespon
name: model.name,
created_at: model.created_at,
updated_at: model.updated_at,
unread_count: None,
permission,
}
}
@@ -63,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 {
ChannelFilter {
server_id: params.server_id,
+8
View File
@@ -11,6 +11,10 @@ pub fn router() -> Router<AppState> {
.put(handlers::update)
.delete(handlers::delete),
)
.route(
"/channels/{channel_id}/permissions",
get(handlers::list_permissions),
)
.route(
"/channels/{channel_id}/permissions/users/{user_id}",
get(handlers::get_user_permission)
@@ -23,4 +27,8 @@ pub fn router() -> Router<AppState> {
.put(handlers::set_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 axum::{
extract::{
ws::{Message, WebSocket, WebSocketUpgrade},
State,
ws::{Message, WebSocket, WebSocketUpgrade},
},
response::IntoResponse,
};
@@ -56,7 +56,6 @@ async fn handle_socket(socket: WebSocket, state: AppState, user: User) {
// Task pour recevoir les messages du WebSocket
let client_clone = client.clone();
let state_clone = state.clone();
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(message)) = receiver.next().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(),
};
state.gateway.remove_client(client.clone());
state.gateway.remove_client(&client);
// // Déconnexion (Disconnect)
client.on_disconnect().await;
}
+101 -42
View File
@@ -1,11 +1,10 @@
use crate::models::category;
use crate::models::channel;
use crate::models::message;
use crate::models::server;
use crate::domain::events::message::{
MessageCreatedEvent, MessageDeletedEvent, MessageUpdatedEvent,
};
use crate::models::user::Model as User;
use crate::routes::category::mapper::category_model_to_category_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 axum::extract::ws::Message;
use event_bus::EventBus;
@@ -16,15 +15,22 @@ use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::services::Services;
pub mod events;
pub mod handlers;
pub mod routes;
#[derive(Debug, Default)]
#[derive(Debug)]
pub struct GatewayManager {
// {UserID: {connection_id: GatewayClient}}
pub clients: RwLock<HashMap<Uuid, HashMap<Uuid, GatewayClient>>>,
pub clients: RwLock<HashMap<ConnectionKey, 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)]
@@ -37,19 +43,82 @@ pub struct GatewayClient {
}
impl GatewayManager {
fn add_client(&self, gateway_client: GatewayClient) {
let mut clients = self.clients.write();
let user_id = gateway_client.user.id;
clients
.entry(user_id)
.or_insert_with(HashMap::new)
.insert(gateway_client.connection_id, gateway_client);
pub fn new(services: Arc<Services>) -> Self {
Self { clients: RwLock::new(HashMap::new()), services }
}
/// Démarre les routeurs centraux des événements de messages.
pub fn start(self: &Arc<Self>, event_bus: Arc<EventBus>) {
let manager = Arc::clone(self);
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) {
let mut clients = self.clients.write();
if let Some(client_list) = clients.get_mut(&gateway_client.user.id) {
client_list.remove(&gateway_client.connection_id);
pub(crate) fn add_client(&self, gateway_client: GatewayClient) {
let key = gateway_client.key();
self.clients.write().insert(key, gateway_client);
}
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>,
event_bus: Arc<EventBus>,
) -> Self {
let connection_id = Uuid::new_v4();
Self {
user,
connection_id,
connection_id: Uuid::new_v4(),
sender,
event_bus,
_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>(
&self,
event_name: &'static str,
@@ -105,22 +180,8 @@ impl GatewayClient {
pub fn subscribe_to_events(&mut self) {
let mut handles = Vec::new();
// Message
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));
// Les messages sont routés par GatewayManager selon le channel_id.
// Channel
handles.push(self.subscribe_event(
"channel_created",
"Channel",
@@ -135,7 +196,6 @@ impl GatewayClient {
));
handles.push(self.subscribe_event("channel_deleted", "Channel", "remove", |id: Uuid| id));
// Category
handles.push(self.subscribe_event(
"category_created",
"Category",
@@ -150,7 +210,6 @@ impl GatewayClient {
));
handles.push(self.subscribe_event("category_deleted", "Category", "remove", |id: Uuid| id));
// Server
handles.push(self.subscribe_event(
"server_created",
"Server",
@@ -175,20 +234,20 @@ impl GatewayClient {
}
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();
}
async fn on_disconnect(&mut self) {
tracing::info!("Client disconnected: {:?}", self.user);
tracing::info!(user_id = %self.user.id, "Client disconnected");
self.unsubscribe_all();
}
async fn on_message(&self, message: Message) {
match message {
Message::Binary(content) => {}
Message::Binary(_) => {}
Message::Text(content) => {
tracing::info!("Received text message: {}", content);
tracing::info!(user_id = %self.user.id, "Received text message: {}", content);
}
Message::Ping(_) => {}
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::domain::dto::message::{
CreateMessageRequest, MessagePageResponse, MessageQueryParams, MessageResponse,
UpdateMessageRequest,
};
use crate::http::context::CurrentUser;
use crate::http::error::HTTPError;
use crate::routes::message::mapper;
use axum::{
Json,
extract::{Path, Query, State},
http::StatusCode,
Json,
};
use uuid::Uuid;
/// Liste tous les messages
/// Liste une fenêtre paginée de messages
#[utoipa::path(
get,
path = "/messages",
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")
),
params(
@@ -26,15 +30,29 @@ use uuid::Uuid;
pub async fn get_all(
State(state): State<AppState>,
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 messages = state.repositories.message.filter(params).await?;
Ok(Json(
messages
let page = state.repositories.message.filter(params).await?;
let oldest_id = page.messages.first().map(|message| message.id);
let newest_id = page.messages.last().map(|message| message.id);
Ok(Json(MessagePageResponse {
messages: page
.messages
.into_iter()
.map(mapper::message_model_to_message_response)
.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
@@ -86,7 +104,7 @@ pub async fn create(
Json(payload): Json<CreateMessageRequest>,
) -> Result<(StatusCode, Json<MessageResponse>), HTTPError> {
// Vérifier que le canal existe
state
let channel = state
.repositories
.channel
.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((
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);
}
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)))
}
+12 -3
View File
@@ -1,15 +1,23 @@
use crate::models::message;
use crate::repositories::types::MessageFilter;
use crate::domain::dto::message::{
CreateMessageRequest, MessageQueryParams, MessageResponse, UpdateMessageRequest,
};
use crate::models::message;
use crate::repositories::types::MessageFilter;
use chrono::Utc;
use sea_orm::Set;
use uuid::Uuid;
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 {
id: model.id,
server_id,
channel_id: model.channel_id,
user_id: model.user_id,
content: model.content,
@@ -48,8 +56,9 @@ pub fn update_request_to_am(
pub fn query_params_to_message_filter(params: MessageQueryParams) -> MessageFilter {
MessageFilter {
channel_id: params.channel_id,
channel_id: Some(params.channel_id),
before_id: params.before_id,
after_id: params.after_id,
limit: params.limit,
}
}
+2
View File
@@ -60,6 +60,8 @@ use utoipa::{Modify, OpenApi};
crate::domain::dto::channel::ChannelResponse,
crate::domain::dto::channel::CreateChannelRequest,
crate::domain::dto::channel::UpdateChannelRequest,
crate::domain::dto::channel::ReadStateResponse,
crate::domain::dto::channel::SetReadStateRequest,
crate::domain::dto::role::RoleResponse,
crate::domain::dto::role::CreateRoleRequest,
crate::domain::dto::role::UpdateRoleRequest,
+97 -128
View File
@@ -1,162 +1,131 @@
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::domain::dto::role::{CreateRoleRequest, RoleResponse, UpdateRoleRequest};
use crate::permissions::ServerPermission;
use crate::routes::role::mapper;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use crate::routes::user::mapper as user_mapper;
use axum::{Json, extract::{Path, Query, State}, http::StatusCode};
use uuid::Uuid;
/// Liste tous les groupes
#[utoipa::path(
get,
path = "/groups",
responses(
(status = 200, description = "Liste des groupes récupérée avec succès", body = [RoleResponse]),
(status = 500, description = "Erreur interne du serveur")
),
tag = "Roles"
)]
pub async fn get_all(State(state): State<AppState>) -> Result<Json<Vec<RoleResponse>>, HTTPError> {
let groups = state.repositories.role.get_all().await?;
Ok(Json(
groups
.into_iter()
.map(mapper::group_model_to_group_response)
.collect(),
))
async fn require_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)
}
}
/// Récupère un groupe par son ID
#[utoipa::path(
get,
path = "/groups/{id}",
responses(
(status = 200, description = "Rolee trouvé", body = RoleResponse),
(status = 404, description = "Rolee non trouvé"),
(status = 500, description = "Erreur interne du serveur")
),
params(
("id" = Uuid, Path, description = "ID du groupe")
),
tag = "Roles"
)]
#[utoipa::path(get, path = "/roles", params(RoleQueryParams), responses((status = 200, body = [RoleResponse])), tag = "Roles")]
pub async fn get_all(
State(state): State<AppState>,
Query(filters): Query<RoleQueryParams>,
) -> Result<Json<Vec<RoleResponse>>, HTTPError> {
let roles = match filters.server_id {
Some(server_id) => state.repositories.role.get_all_by_server(server_id).await?,
None => state.repositories.role.get_all().await?,
};
Ok(Json(roles.into_iter().map(mapper::role_model_to_role_response).collect()))
}
#[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(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<RoleResponse>, HTTPError> {
let group = state
.repositories
.role
.get_by_id(id)
.await?
.ok_or(HTTPError::NotFound)?;
Ok(Json(mapper::group_model_to_group_response(group)))
let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
Ok(Json(mapper::role_model_to_role_response(role)))
}
/// Crée un nouveau groupe
#[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" = [])
)
)]
#[utoipa::path(post, path = "/roles", request_body = CreateRoleRequest, responses((status = 201, body = RoleResponse)), tag = "Roles", security(("bearerAuth" = [])))]
pub async fn create(
_admin: Superuser,
user: CurrentUser,
State(state): State<AppState>,
Json(payload): Json<CreateRoleRequest>,
) -> 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()))?;
require_permission(&state, &user, payload.server_id, ServerPermission::MANAGE_ROLES).await?;
let active_model = mapper::create_request_to_am(payload);
let group = state.services.role.create_role(active_model).await?;
Ok((
StatusCode::CREATED,
Json(mapper::group_model_to_group_response(group)),
))
let role = state.services.role.create_role(mapper::create_request_to_am(payload)).await?;
Ok((StatusCode::CREATED, Json(mapper::role_model_to_role_response(role))))
}
/// Met à jour un groupe existant
#[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" = [])
)
)]
#[utoipa::path(put, path = "/roles/{id}", request_body = UpdateRoleRequest, responses((status = 200, body = RoleResponse), (status = 404)), tag = "Roles", security(("bearerAuth" = [])))]
pub async fn update(
_admin: Superuser,
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateRoleRequest>,
) -> Result<Json<RoleResponse>, HTTPError> {
// Vérifier l'existence
let group = state
.repositories
.role
.get_by_id(id)
.await?
.ok_or(HTTPError::NotFound)?;
let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_ROLES).await?;
let active_model = mapper::update_request_to_am(group.id, group.server_id, payload);
let group = state.services.role.update_role(active_model).await?;
Ok(Json(mapper::group_model_to_group_response(group)))
let role = state.services.role.update_role(mapper::update_request_to_am(role.id, role.server_id, payload)).await?;
Ok(Json(mapper::role_model_to_role_response(role)))
}
/// Supprime un groupe
#[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" = [])
)
)]
#[utoipa::path(delete, path = "/roles/{id}", responses((status = 204), (status = 404)), tag = "Roles", security(("bearerAuth" = [])))]
pub async fn delete(
_admin: Superuser,
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, HTTPError> {
if state.services.role.delete_role(id).await? {
Ok(StatusCode::NO_CONTENT)
} else {
Err(HTTPError::NotFound)
}
let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_ROLES).await?;
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 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 {
id: model.id,
server_id: model.server_id,
+5 -3
View File
@@ -1,15 +1,17 @@
use crate::core::state::AppState;
use axum::{routing::get, Router};
use axum::{routing::{get, put}, Router};
use super::handlers;
pub fn router() -> Router<AppState> {
Router::new()
.route("/groups", get(handlers::get_all).post(handlers::create))
.route("/roles", get(handlers::get_all).post(handlers::create))
.route(
"/groups/{id}",
"/roles/{id}",
get(handlers::get_by_id)
.put(handlers::update)
.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))
}
+79 -19
View File
@@ -3,9 +3,9 @@ use crate::domain::dto::server::{
CreateServerRequest, ServerResponse, ServerRolePermissionResponse, ServerTreeResponse,
ServerUserPermissionResponse, SetServerPermissionRequest, UpdateServerRequest,
};
use crate::http::context::Superuser;
use crate::http::context::{CurrentUser, Superuser};
use crate::http::error::HTTPError;
use crate::repositories::types::ChannelFilter;
use crate::permissions::ServerPermission;
use crate::routes::server::mapper;
use axum::{
Json,
@@ -14,6 +14,27 @@ use axum::{
};
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
#[utoipa::path(
get,
@@ -22,16 +43,29 @@ use uuid::Uuid;
(status = 200, description = "Liste des serveurs récupérée avec succès", body = [ServerResponse]),
(status = 500, description = "Erreur interne du serveur")
),
security(("bearerAuth" = [])),
tag = "Servers"
)]
pub async fn get_all(
user: CurrentUser,
State(state): State<AppState>,
) -> Result<Json<Vec<ServerResponse>>, HTTPError> {
let servers = state.repositories.server.get_all().await?;
let unread_counts = state
.repositories
.read_state
.unread_counts_by_server(user.id)
.await?;
Ok(Json(
servers
.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(),
))
}
@@ -113,7 +147,7 @@ pub async fn create(
)
)]
pub async fn update(
_admin: Superuser,
user: CurrentUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateServerRequest>,
@@ -126,6 +160,8 @@ pub async fn update(
.await?
.ok_or(HTTPError::NotFound)?;
require_server_permission(&state, &user, id, ServerPermission::MANAGE_SERVER).await?;
let server = state
.services
.server
@@ -193,6 +229,23 @@ pub async fn get_user_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.
#[utoipa::path(
put,
@@ -209,10 +262,14 @@ pub async fn get_user_permission(
tag = "Server Permissions"
)]
pub async fn set_user_permission(
user: CurrentUser,
State(state): State<AppState>,
Path((server_id, user_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<SetServerPermissionRequest>,
) -> 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
.repositories
.server
@@ -245,9 +302,11 @@ pub async fn set_user_permission(
tag = "Server Permissions"
)]
pub async fn remove_user_permission(
user: CurrentUser,
State(state): State<AppState>,
Path((server_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, HTTPError> {
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?;
if state
.repositories
.server
@@ -312,10 +371,12 @@ pub async fn get_role_permission(
tag = "Server Permissions"
)]
pub async fn set_role_permission(
user: CurrentUser,
State(state): State<AppState>,
Path((server_id, role_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<SetServerPermissionRequest>,
) -> Result<Json<ServerRolePermissionResponse>, HTTPError> {
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_ROLES).await?;
state
.repositories
.server
@@ -348,9 +409,11 @@ pub async fn set_role_permission(
tag = "Server Permissions"
)]
pub async fn remove_role_permission(
user: CurrentUser,
State(state): State<AppState>,
Path((server_id, role_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, HTTPError> {
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_ROLES).await?;
if state
.repositories
.server
@@ -384,6 +447,7 @@ pub async fn remove_role_permission(
tag = "Servers"
)]
pub async fn get_tree(
user: CurrentUser,
State(state): State<AppState>,
Path(server_id): Path<Uuid>,
) -> Result<Json<ServerTreeResponse>, HTTPError> {
@@ -395,20 +459,16 @@ pub async fn get_tree(
.await?
.ok_or(HTTPError::NotFound)?;
// Exécution parallèle des 3 requêtes via leurs repositories respectifs
let (orders, channels, categories) = tokio::try_join!(
state
.repositories
.server_item_order
.get_by_server(server_id),
state.repositories.channel.filter(ChannelFilter {
server_id: Some(server_id)
}),
state.repositories.category.get_by_server(server_id),
)?;
let tree = state
.repositories
.server_tree
.get_for_user(server_id, user.id)
.await?;
// Assemblage du ServerTreeResponse via le mapper
let tree = mapper::build_server_tree(orders, channels, categories);
Ok(Json(tree))
Ok(Json(mapper::build_server_tree(
tree.orders,
tree.channels,
tree.categories,
tree.unread_counts,
)))
}
+155 -26
View File
@@ -2,11 +2,10 @@ use crate::domain::dto::server::{
CreateServerRequest, ServerExplorerItemResponse, ServerResponse, ServerRolePermissionResponse,
ServerTreeResponse, ServerUserPermissionResponse, UpdateServerRequest,
};
use crate::models::{
category, channel, server, server_item_order, server_role_permission, server_user_permission,
};
use crate::routes::category::mapper::category_model_to_category_response;
use crate::routes::channel::mapper::channel_model_to_channel_response;
use crate::models::{server, server_item_order, server_role_permission, server_user_permission};
use crate::repositories::types::{CategoryWithPermissions, ChannelWithPermissions};
use crate::routes::category::mapper::category_model_to_category_response_with_permission;
use crate::routes::channel::mapper::channel_model_to_channel_response_with_permission;
use sea_orm::Set;
use std::collections::HashMap;
use uuid::Uuid;
@@ -18,9 +17,19 @@ pub fn server_model_to_server_response(model: server::Model) -> ServerResponse {
is_default: model.is_default,
created_at: model.created_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 {
server::ActiveModel {
id: Set(Uuid::new_v4()),
@@ -65,44 +74,66 @@ pub fn server_role_permission_to_response(
pub fn build_server_tree(
orders: Vec<server_item_order::Model>,
channels: Vec<channel::Model>,
categories: Vec<category::Model>,
channels: Vec<ChannelWithPermissions>,
categories: Vec<CategoryWithPermissions>,
unread_counts: HashMap<Uuid, u64>,
) -> ServerTreeResponse {
// Map d'accès rapide : resource_id -> order_key
let order_map: HashMap<Uuid, i64> = orders
let order_map: HashMap<(Option<Uuid>, Uuid), i64> = orders
.into_iter()
.map(|o| (o.resource_id, o.order_key))
.map(|order| {
(
(order.parent_category_id, order.resource_id),
order.order_key,
)
})
.collect();
// Regrouper les canaux par catégorie ou orphelins
let mut category_channels: HashMap<Uuid, Vec<channel::Model>> = HashMap::new();
let mut orphan_channels: Vec<channel::Model> = Vec::new();
let mut category_channels: HashMap<Uuid, Vec<ChannelWithPermissions>> = HashMap::new();
let mut orphan_channels: Vec<ChannelWithPermissions> = Vec::new();
for chan in channels {
if let Some(cat_id) = chan.category_id {
category_channels.entry(cat_id).or_default().push(chan);
for channel_with_permissions in channels {
if let Some(category_id) = channel_with_permissions.channel.category_id {
category_channels
.entry(category_id)
.or_default()
.push(channel_with_permissions);
} else {
orphan_channels.push(chan);
orphan_channels.push(channel_with_permissions);
}
}
// Trier les canaux dans chaque catégorie par leur order_key
for chans in category_channels.values_mut() {
chans.sort_by_key(|c| order_map.get(&c.id).copied().unwrap_or(i64::MAX));
chans.sort_by_key(|c| {
order_map
.get(&(c.channel.category_id, c.channel.id))
.copied()
.unwrap_or(i64::MAX)
});
}
// Préparer les éléments racine (catégories & canaux orphelins) avec leur order_key
let mut root_items: Vec<(ServerExplorerItemResponse, i64)> = Vec::new();
for cat in categories {
let cat_id = cat.id;
let order_key = order_map.get(&cat_id).copied().unwrap_or(i64::MAX);
for cat_with_perm in categories {
let cat_id = cat_with_perm.category.id;
let order_key = order_map.get(&(None, cat_id)).copied().unwrap_or(i64::MAX);
let chans = category_channels.remove(&cat_id).unwrap_or_default();
let cat_response = category_model_to_category_response(cat);
let cat_perm_bits = cat_with_perm.permissions.map(|p| p.bits()).unwrap_or(0);
let cat_response = category_model_to_category_response_with_permission(
cat_with_perm.category,
Some(cat_perm_bits),
);
let chan_responses = chans
.into_iter()
.map(channel_model_to_channel_response)
.map(|c| {
let chan_perm_bits = c.permissions.map(|p| p.bits()).unwrap_or(0);
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();
root_items.push((
@@ -111,9 +142,17 @@ pub fn build_server_tree(
));
}
for chan in orphan_channels {
let order_key = order_map.get(&chan.id).copied().unwrap_or(i64::MAX);
let chan_response = channel_model_to_channel_response(chan);
for chan_with_perm in orphan_channels {
let order_key = order_map
.get(&(None, chan_with_perm.channel.id))
.copied()
.unwrap_or(i64::MAX);
let chan_perm_bits = chan_with_perm.permissions.map(|p| p.bits()).unwrap_or(0);
let mut chan_response = channel_model_to_channel_response_with_permission(
chan_with_perm.channel,
Some(chan_perm_bits),
);
chan_response.unread_count = Some(*unread_counts.get(&chan_response.id).unwrap_or(&0));
root_items.push((
ServerExplorerItemResponse::Channel(chan_response),
order_key,
@@ -127,3 +166,93 @@ pub fn build_server_tree(
ServerTreeResponse { items }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{category, channel};
use crate::permissions::ChannelPermission;
use chrono::Utc;
fn channel(id: Uuid, category_id: Option<Uuid>, name: &str) -> ChannelWithPermissions {
ChannelWithPermissions {
channel: channel::Model {
id,
server_id: Some(Uuid::new_v4()),
category_id,
channel_type: channel::ChannelType::Text,
name: Some(name.to_owned()),
created_at: Utc::now(),
updated_at: Utc::now(),
},
permissions: Some(ChannelPermission::empty()),
}
}
#[test]
fn sorts_channels_by_order_key_within_each_category() {
let category_id = Uuid::new_v4();
let first_id = Uuid::new_v4();
let second_id = Uuid::new_v4();
let category = CategoryWithPermissions {
category: category::Model {
id: category_id,
server_id: Uuid::new_v4(),
name: "Discussion".to_owned(),
created_at: Utc::now(),
updated_at: Utc::now(),
},
permissions: Some(ChannelPermission::empty()),
};
let orders = vec![
server_item_order::Model {
id: Uuid::new_v4(),
server_id: category.category.server_id,
resource_id: category_id,
resource_type: server_item_order::OrderedResourceType::Category,
parent_category_id: None,
order_key: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
},
server_item_order::Model {
id: Uuid::new_v4(),
server_id: category.category.server_id,
resource_id: first_id,
resource_type: server_item_order::OrderedResourceType::Channel,
parent_category_id: Some(category_id),
order_key: 2,
created_at: Utc::now(),
updated_at: Utc::now(),
},
server_item_order::Model {
id: Uuid::new_v4(),
server_id: category.category.server_id,
resource_id: second_id,
resource_type: server_item_order::OrderedResourceType::Channel,
parent_category_id: Some(category_id),
order_key: 1,
created_at: Utc::now(),
updated_at: Utc::now(),
},
];
let response = build_server_tree(
orders,
vec![
channel(first_id, Some(category_id), "second"),
channel(second_id, Some(category_id), "first"),
],
vec![category],
HashMap::new(),
);
let ServerExplorerItemResponse::Category(_, channels) = &response.items[0] else {
panic!("expected a category");
};
assert_eq!(channels[0].name.as_deref(), Some("first"));
assert_eq!(channels[1].name.as_deref(), Some("second"));
}
}
+4
View File
@@ -12,6 +12,10 @@ pub fn router() -> Router<AppState> {
.delete(handlers::delete),
)
.route("/servers/{server_id}/tree", get(handlers::get_tree))
.route(
"/servers/{server_id}/permissions/users",
get(handlers::list_user_permissions),
)
.route(
"/servers/{server_id}/permissions/users/{user_id}",
get(handlers::get_user_permission)
+13 -5
View File
@@ -1,11 +1,13 @@
use crate::core::state::AppState;
use crate::domain::dto::user::{
CreateUserRequest, UpdateUserRequest, UserQueryParams, UserResponse,
};
use crate::http::context::Superuser;
use crate::http::error::HTTPError;
use crate::domain::dto::user::{CreateUserRequest, UpdateUserRequest, UserResponse};
use crate::routes::user::mapper;
use axum::{
Json,
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
};
use uuid::Uuid;
@@ -14,10 +16,10 @@ use uuid::Uuid;
#[utoipa::path(
get,
path = "/users",
params(UserQueryParams),
responses(
(status = 200, description = "Liste des utilisateurs récupérée avec succès", body = [UserResponse]),
(status = 401, description = "Non autorisé"),
(status = 403, description = "Interdit"),
(status = 500, description = "Erreur interne du serveur")
),
tag = "Users",
@@ -26,10 +28,16 @@ use uuid::Uuid;
)
)]
pub async fn get_all(
_admin: Superuser,
State(state): State<AppState>,
Query(filters): Query<UserQueryParams>,
) -> 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(
users
.into_iter()
+34 -12
View File
@@ -1,6 +1,7 @@
use crate::services::ServicesContext;
use crate::models::category;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, TransactionTrait, Set};
use crate::models::server_item_order::OrderedResourceType;
use crate::services::ServicesContext;
use sea_orm::{ActiveModelTrait, EntityTrait, Set, TransactionTrait};
use std::sync::Arc;
use uuid::Uuid;
@@ -21,17 +22,32 @@ impl CategoryService {
) -> Result<category::Model, anyhow::Error> {
let db = &self.service_context.repositories.server.context.db;
let event_bus = &self.service_context.event_bus;
let server_order = self
.service_context
.services
.get()
.expect("services initialized")
.server_order
.clone();
let txn = db.begin().await?;
let cat = db
.transaction::<_, category::Model, anyhow::Error>(|txn| {
Box::pin(async move {
let active = category::ActiveModel {
server_id: Set(server_id),
name: Set(name),
..Default::default()
};
let cat = active.insert(txn).await?;
let active = category::ActiveModel {
server_id: Set(server_id),
name: Set(name),
..Default::default()
};
let cat = active.insert(&txn).await?;
server_order
.append(txn, server_id, cat.id, OrderedResourceType::Category, None)
.await?;
txn.commit().await?;
Ok(cat)
})
})
.await?;
event_bus.emit("category_created", cat.clone());
@@ -71,10 +87,16 @@ impl CategoryService {
let txn = db.begin().await?;
let res = category::Entity::delete_by_id(id)
.exec(&txn)
self.service_context
.services
.get()
.expect("services initialized")
.server_order
.remove(&txn, id, OrderedResourceType::Category)
.await?;
let res = category::Entity::delete_by_id(id).exec(&txn).await?;
let deleted = res.rows_affected > 0;
txn.commit().await?;
+91 -60
View File
@@ -1,9 +1,12 @@
use crate::repositories::Repositories;
use crate::services::ServicesContext;
use crate::domain::dto::channel::{CreateChannelRequest, UpdateChannelRequest};
use crate::models::channel;
use event_bus::Scope;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, QueryOrder, TransactionTrait, Set, PaginatorTrait};
use crate::models::server_item_order::OrderedResourceType;
use crate::models::{channel, role};
use crate::permissions::PermissionSet;
use crate::services::ServicesContext;
use crate::services::permission::PermissionService;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait,
};
use std::sync::Arc;
use uuid::Uuid;
@@ -23,48 +26,68 @@ impl ChannelService {
) -> Result<channel::Model, anyhow::Error> {
let db = &self.service_context.repositories.server.context.db;
let event_bus = &self.service_context.event_bus;
let server_order = self
.service_context
.services
.get()
.expect("services initialized")
.server_order
.clone();
// Start transaction
let txn = db.begin().await?;
let channel = db
.transaction::<_, channel::Model, anyhow::Error>(|txn| {
Box::pin(async move {
// 1. Insertion du canal
let active_model = channel::ActiveModel {
server_id: Set(payload.server_id),
category_id: Set(payload.category_id),
channel_type: Set(payload.channel_type),
name: Set(payload.name),
..Default::default()
};
// 1. Insert channel within transaction
let active_model = channel::ActiveModel {
server_id: Set(payload.server_id),
category_id: Set(payload.category_id),
channel_type: Set(payload.channel_type),
name: Set(payload.name),
..Default::default()
};
let channel = active_model.insert(txn).await?;
let channel = active_model.insert(&txn).await?;
// 2. Si server_id est présent, enregistrement de l'ordre d'affichage
if let Some(server_id) = payload.server_id {
server_order
.append(
txn,
server_id,
channel.id,
OrderedResourceType::Channel,
payload.category_id,
)
.await?;
// 2. If server_id is present, insert into server_item_order within transaction
if let Some(server_id) = payload.server_id {
// Get max order key or determine order key
let max_order: Option<i64> = crate::models::server_item_order::Entity::find()
.filter(crate::models::server_item_order::Column::ServerId.eq(server_id))
.select_only()
.column_as(crate::models::server_item_order::Column::OrderKey.max(), "max_key")
.into_tuple::<Option<i64>>()
.one(&txn)
.await?
.flatten();
// 3. Attribution des permissions par défaut au rôle par défaut
if let Some(default_role) = role::Entity::find()
.filter(role::Column::ServerId.eq(server_id))
.filter(role::Column::IsDefault.eq(true))
.one(txn)
.await?
{
let default_channel_permissions =
PermissionSet::DEFAULT.channel.bits() as i64;
let next_order = max_order.unwrap_or(0) + 1;
let role_perm = crate::models::channel_role_permission::ActiveModel {
channel_id: Set(channel.id),
role_id: Set(default_role.id),
permissions: Set(default_channel_permissions),
..Default::default()
};
role_perm.insert(txn).await?;
}
let order_item = crate::models::server_item_order::ActiveModel {
server_id: Set(server_id),
resource_id: Set(channel.id),
resource_type: Set(crate::models::server_item_order::OrderedResourceType::Channel),
parent_category_id: Set(payload.category_id),
order_key: Set(next_order),
..Default::default()
};
order_item.insert(&txn).await?;
}
// 4. Refresh des permissions (Global pour l'instant)
// TODO: Cibler/focaliser le spectre du recalcul (ex: uniquement les membres impactés ou le canal spécifique)
PermissionService::sync_server_with_db(txn, server_id).await?;
}
// Commit transaction
txn.commit().await?;
Ok(channel)
})
})
.await?;
// Post-commit event emission
event_bus.emit("channel_created", channel.clone());
@@ -95,17 +118,19 @@ impl ChannelService {
let channel = active.update(&txn).await?;
// Update server_item_order parent_category_id or server_id if needed
if let Some(server_id) = channel.server_id {
crate::models::server_item_order::Entity::update_many()
.set(crate::models::server_item_order::ActiveModel {
parent_category_id: Set(channel.category_id),
..Default::default()
})
.filter(crate::models::server_item_order::Column::ResourceId.eq(channel.id))
.exec(&txn)
.await?;
}
self.service_context
.services
.get()
.expect("services initialized")
.server_order
.sync(
&txn,
channel.id,
OrderedResourceType::Channel,
channel.server_id,
channel.category_id,
)
.await?;
txn.commit().await?;
@@ -120,15 +145,15 @@ impl ChannelService {
let txn = db.begin().await?;
// Delete associated order records
crate::models::server_item_order::Entity::delete_many()
.filter(crate::models::server_item_order::Column::ResourceId.eq(id))
.exec(&txn)
self.service_context
.services
.get()
.expect("services initialized")
.server_order
.remove(&txn, id, OrderedResourceType::Channel)
.await?;
let res = channel::Entity::delete_by_id(id)
.exec(&txn)
.await?;
let res = channel::Entity::delete_by_id(id).exec(&txn).await?;
let deleted = res.rows_affected > 0;
@@ -173,7 +198,10 @@ impl ChannelService {
txn.commit().await?;
event_bus.emit("channel_user_permission_created", (channel_id, user_id, permissions));
event_bus.emit(
"channel_user_permission_created",
(channel_id, user_id, permissions),
);
Ok(())
}
@@ -233,7 +261,10 @@ impl ChannelService {
txn.commit().await?;
event_bus.emit("channel_role_permission_created", (channel_id, role_id, permissions));
event_bus.emit(
"channel_role_permission_created",
(channel_id, role_id, permissions),
);
Ok(())
}
+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::services::ServicesContext;
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 uuid::Uuid;
@@ -51,7 +54,15 @@ impl MessageService {
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)
}
@@ -78,7 +89,27 @@ impl MessageService {
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)
}
@@ -89,16 +120,39 @@ impl MessageService {
let txn = db.begin().await?;
let res = message::Entity::delete_by_id(id)
.exec(&txn)
.await?;
let existing = message::Entity::find_by_id(id).one(db).await?;
let Some(existing) = existing else {
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;
txn.commit().await?;
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)
+19 -10
View File
@@ -1,23 +1,26 @@
use crate::repositories::Repositories;
use crate::services::permission_sync::PermissionSyncService;
use crate::services::server_order::ServerOrderService;
use crate::services::channel::ChannelService;
use crate::services::server::ServerService;
use crate::services::category::CategoryService;
use crate::services::channel::ChannelService;
use crate::services::message::MessageService;
use crate::services::user::UserService;
use crate::services::permission::PermissionService;
use crate::services::permission_sync::PermissionSyncService;
use crate::services::role::RoleService;
use crate::services::server::ServerService;
use crate::services::server_order::ServerOrderService;
use crate::services::user::UserService;
use event_bus::EventBus;
use std::sync::{Arc, OnceLock};
pub mod permission_sync;
mod server_order;
pub mod channel;
pub mod server;
pub mod category;
pub mod channel;
pub mod message;
pub mod user;
mod permission;
pub mod permission_sync;
pub mod realtime_registry;
pub mod role;
pub mod server;
mod server_order;
pub mod user;
#[derive(Debug, Clone)]
pub struct ServicesContext {
@@ -28,6 +31,7 @@ pub struct ServicesContext {
#[derive(Debug, Clone)]
pub struct Services {
pub realtime_registry: Arc<realtime_registry::RealtimeRegistry>,
pub permission_sync: Arc<PermissionSyncService>,
pub server_order: Arc<ServerOrderService>,
pub channel: Arc<ChannelService>,
@@ -36,6 +40,7 @@ pub struct Services {
pub message: Arc<MessageService>,
pub user: Arc<UserService>,
pub role: Arc<RoleService>,
pub permission: Arc<PermissionService>,
}
impl Services {
@@ -46,6 +51,7 @@ impl Services {
services: OnceLock::new(),
});
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 channel = Arc::new(ChannelService::new(service_context.clone()));
let server = Arc::new(ServerService::new(service_context.clone()));
@@ -53,8 +59,10 @@ impl Services {
let message = Arc::new(MessageService::new(service_context.clone()));
let user = Arc::new(UserService::new(service_context.clone()));
let role = Arc::new(RoleService::new(service_context.clone()));
let permission = Arc::new(PermissionService::new(service_context.clone()));
let services = Self {
realtime_registry,
permission_sync,
server_order,
channel,
@@ -63,6 +71,7 @@ impl Services {
message,
user,
role,
permission,
};
let _ = service_context.services.set(services.clone());
services
+438
View File
@@ -0,0 +1,438 @@
use crate::models::computed_permission::PermissionScopeType;
use crate::models::{
channel, channel_role_permission, channel_user_permission, computed_permission, role_user,
server_role_permission, server_user, server_user_permission,
};
use crate::permissions::{ChannelPermission, ServerPermission};
use crate::services::ServicesContext;
use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QuerySelect, Set};
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct PermissionService {
service_context: Arc<ServicesContext>,
}
impl PermissionService {
pub fn new(service_context: Arc<ServicesContext>) -> Self {
Self { service_context }
}
/// Recalcule de manière globale et optimisée `computed_permission` pour TOUS les utilisateurs
/// et TOUTES les ressources d'un serveur donné.
pub async fn sync_server(&self, server_id: Uuid) -> Result<(), anyhow::Error> {
let db = &self.service_context.repositories.server.context.db;
Self::sync_server_with_db(db, server_id).await
}
/// Recalcule TOUTES les entrées `computed_permission` d'un utilisateur sur un serveur.
pub async fn sync_user(&self, user_id: Uuid, server_id: Uuid) -> Result<(), anyhow::Error> {
let db = &self.service_context.repositories.server.context.db;
Self::sync_user_with_db(db, user_id, server_id).await
}
/// Recalcule le `computed_permission` pour UN utilisateur et UN canal spécifique.
pub async fn sync_channel_user_permission(
&self,
channel_id: Uuid,
user_id: Uuid,
) -> Result<(), anyhow::Error> {
let db = &self.service_context.repositories.server.context.db;
Self::sync_channel_user_permission_with_db(db, channel_id, user_id).await
}
}
impl PermissionService {
pub async fn sync_server_with_db<C>(db: &C, server_id: Uuid) -> Result<(), anyhow::Error>
where
C: ConnectionTrait,
{
// 1. Charger tous les canaux du serveur
let channels = channel::Entity::find()
.filter(channel::Column::ServerId.eq(server_id))
.all(db)
.await?;
let channel_ids: Vec<Uuid> = channels.iter().map(|c| c.id).collect();
// 2. Charger tous les utilisateurs du serveur
let user_ids: Vec<Uuid> = server_user::Entity::find()
.filter(server_user::Column::ServerId.eq(server_id))
.select_only()
.column(server_user::Column::UserId)
.into_tuple::<Uuid>()
.all(db)
.await?;
if user_ids.is_empty() {
// Aucun membre, nettoyer simplement le cache de ce serveur
computed_permission::Entity::delete_many()
.filter(computed_permission::Column::ServerId.eq(server_id))
.exec(db)
.await?;
return Ok(());
}
// 3. Charger en lot toutes les affectations de rôles des membres
let user_roles_models = role_user::Entity::find()
.filter(role_user::Column::UserId.is_in(user_ids.clone()))
.all(db)
.await?;
let mut roles_by_user: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
for ur in user_roles_models {
roles_by_user
.entry(ur.user_id)
.or_default()
.push(ur.role_id);
}
// 4. Charger toutes les permissions serveur (Rôles & Utilisateurs)
let server_role_perms = server_role_permission::Entity::find()
.filter(server_role_permission::Column::ServerId.eq(server_id))
.all(db)
.await?;
let mut server_perm_by_role: HashMap<Uuid, ServerPermission> = HashMap::new();
for srp in server_role_perms {
server_perm_by_role.insert(
srp.role_id,
ServerPermission::from_bits_retain(srp.permissions as u64),
);
}
let server_user_perms = server_user_permission::Entity::find()
.filter(server_user_permission::Column::ServerId.eq(server_id))
.filter(server_user_permission::Column::UserId.is_in(user_ids.clone()))
.all(db)
.await?;
let mut server_perm_by_user: HashMap<Uuid, ServerPermission> = HashMap::new();
for sup in server_user_perms {
server_perm_by_user.insert(
sup.user_id,
ServerPermission::from_bits_retain(sup.permissions as u64),
);
}
// 5. Charger toutes les permissions de canaux (Rôles & Utilisateurs)
let channel_role_perms = if channel_ids.is_empty() {
Vec::new()
} else {
channel_role_permission::Entity::find()
.filter(channel_role_permission::Column::ChannelId.is_in(channel_ids.clone()))
.all(db)
.await?
};
let mut channel_role_perm_map: HashMap<(Uuid, Uuid), ChannelPermission> = HashMap::new();
for crp in channel_role_perms {
channel_role_perm_map.insert(
(crp.channel_id, crp.role_id),
ChannelPermission::from_bits_retain(crp.permissions as u64),
);
}
let channel_user_perms = if channel_ids.is_empty() {
Vec::new()
} else {
channel_user_permission::Entity::find()
.filter(channel_user_permission::Column::ChannelId.is_in(channel_ids.clone()))
.filter(channel_user_permission::Column::UserId.is_in(user_ids.clone()))
.all(db)
.await?
};
let mut channel_user_perm_map: HashMap<(Uuid, Uuid), ChannelPermission> = HashMap::new();
for cup in channel_user_perms {
channel_user_perm_map.insert(
(cup.channel_id, cup.user_id),
ChannelPermission::from_bits_retain(cup.permissions as u64),
);
}
// 6. Calcul en RAM pour l'ensemble des paires (Utilisateur x Ressource)
let mut to_insert: Vec<computed_permission::ActiveModel> = Vec::new();
for user_id in user_ids {
let user_roles = roles_by_user.get(&user_id);
// A. Permission Serveur
let mut final_server_perm = ServerPermission::empty();
if let Some(roles) = user_roles {
for role_id in roles {
if let Some(p) = server_perm_by_role.get(role_id) {
final_server_perm |= *p;
}
}
}
if let Some(p) = server_perm_by_user.get(&user_id) {
final_server_perm |= *p;
}
to_insert.push(computed_permission::ActiveModel {
user_id: Set(user_id),
server_id: Set(server_id),
scope_type: Set(PermissionScopeType::Server),
resource_id: Set(server_id),
permissions: Set(final_server_perm.bits() as i64),
..Default::default()
});
// B. Permissions par Canal
for channel in &channels {
let mut final_channel_perm = ChannelPermission::empty();
if let Some(roles) = user_roles {
for role_id in roles {
if let Some(p) = channel_role_perm_map.get(&(channel.id, *role_id)) {
final_channel_perm |= *p;
}
}
}
if let Some(p) = channel_user_perm_map.get(&(channel.id, user_id)) {
final_channel_perm |= *p;
}
to_insert.push(computed_permission::ActiveModel {
user_id: Set(user_id),
server_id: Set(server_id),
scope_type: Set(PermissionScopeType::Channel),
resource_id: Set(channel.id),
permissions: Set(final_channel_perm.bits() as i64),
..Default::default()
});
}
}
// 7. Remplacement atomique complet pour le serveur en BDD
computed_permission::Entity::delete_many()
.filter(computed_permission::Column::ServerId.eq(server_id))
.exec(db)
.await?;
if !to_insert.is_empty() {
// Insertion par lots (chunking de 1000 pour éviter les limites de paramètres SQL)
for chunk in to_insert.chunks(1000) {
computed_permission::Entity::insert_many(chunk.to_vec())
.exec(db)
.await?;
}
}
Ok(())
}
pub async fn sync_user_with_db<C>(
db: &C,
user_id: Uuid,
server_id: Uuid,
) -> Result<(), anyhow::Error>
where
C: ConnectionTrait,
{
// 1. Rôles de l'utilisateur
let role_ids = role_user::Entity::find()
.filter(role_user::Column::UserId.eq(user_id))
.select_only()
.column(role_user::Column::RoleId)
.into_tuple::<Uuid>()
.all(db)
.await?;
// 2. Permissions serveur des rôles
let mut server_permissions = ServerPermission::empty();
if !role_ids.is_empty() {
let role_permissions = server_role_permission::Entity::find()
.filter(server_role_permission::Column::ServerId.eq(server_id))
.filter(server_role_permission::Column::RoleId.is_in(role_ids.clone()))
.all(db)
.await?;
for permission in role_permissions {
server_permissions |=
ServerPermission::from_bits_retain(permission.permissions as u64);
}
}
// 3. Permissions serveur directes
if let Some(permission) = server_user_permission::Entity::find()
.filter(server_user_permission::Column::ServerId.eq(server_id))
.filter(server_user_permission::Column::UserId.eq(user_id))
.one(db)
.await?
{
server_permissions |= ServerPermission::from_bits_retain(permission.permissions as u64);
}
// 4. Canaux du serveur
let channels = channel::Entity::find()
.filter(channel::Column::ServerId.eq(server_id))
.all(db)
.await?;
let channel_ids: Vec<Uuid> = channels.iter().map(|c| c.id).collect();
// 5. Permissions de rôles pour les canaux
let role_channel_permissions = if role_ids.is_empty() || channel_ids.is_empty() {
Vec::new()
} else {
channel_role_permission::Entity::find()
.filter(channel_role_permission::Column::ChannelId.is_in(channel_ids.clone()))
.filter(channel_role_permission::Column::RoleId.is_in(role_ids))
.all(db)
.await?
};
let mut permissions_by_channel: HashMap<Uuid, ChannelPermission> = HashMap::new();
for permission in role_channel_permissions {
permissions_by_channel
.entry(permission.channel_id)
.or_default()
.insert(ChannelPermission::from_bits_retain(
permission.permissions as u64,
));
}
// 6. Permissions directes utilisateur pour les canaux
let user_channel_permissions = if channel_ids.is_empty() {
Vec::new()
} else {
channel_user_permission::Entity::find()
.filter(channel_user_permission::Column::UserId.eq(user_id))
.filter(channel_user_permission::Column::ChannelId.is_in(channel_ids))
.all(db)
.await?
};
for permission in user_channel_permissions {
permissions_by_channel
.entry(permission.channel_id)
.or_default()
.insert(ChannelPermission::from_bits_retain(
permission.permissions as u64,
));
}
// 7. Modèles à insérer
let mut computed_permissions = Vec::with_capacity(channels.len().saturating_add(1));
computed_permissions.push(computed_permission::ActiveModel {
user_id: Set(user_id),
server_id: Set(server_id),
scope_type: Set(PermissionScopeType::Server),
resource_id: Set(server_id),
permissions: Set(server_permissions.bits() as i64),
..Default::default()
});
for channel in channels {
let channel_permissions = permissions_by_channel
.remove(&channel.id)
.unwrap_or_else(ChannelPermission::empty);
computed_permissions.push(computed_permission::ActiveModel {
user_id: Set(user_id),
server_id: Set(server_id),
scope_type: Set(PermissionScopeType::Channel),
resource_id: Set(channel.id),
permissions: Set(channel_permissions.bits() as i64),
..Default::default()
});
}
// 8. Remplacement atomique BDD
computed_permission::Entity::delete_many()
.filter(computed_permission::Column::UserId.eq(user_id))
.filter(computed_permission::Column::ServerId.eq(server_id))
.exec(db)
.await?;
if !computed_permissions.is_empty() {
computed_permission::Entity::insert_many(computed_permissions)
.exec(db)
.await?;
}
Ok(())
}
pub async fn sync_channel_user_permission_with_db<C>(
db: &C,
channel_id: Uuid,
user_id: Uuid,
) -> Result<(), anyhow::Error>
where
C: ConnectionTrait,
{
// 1. Récupérer le canal pour connaître son server_id
let channel = channel::Entity::find_by_id(channel_id)
.one(db)
.await?
.ok_or_else(|| anyhow::anyhow!("Canal non trouvé"))?;
let server_id = match channel.server_id {
Some(sid) => sid,
None => return Ok(()), // Canal DM ou sans serveur
};
// 2. Rôles de l'utilisateur sur le serveur
let role_ids = role_user::Entity::find()
.filter(role_user::Column::UserId.eq(user_id))
.select_only()
.column(role_user::Column::RoleId)
.into_tuple::<Uuid>()
.all(db)
.await?;
// 3. Permissions cumulées des rôles sur ce canal
let mut computed = ChannelPermission::empty();
if !role_ids.is_empty() {
let role_perms = channel_role_permission::Entity::find()
.filter(channel_role_permission::Column::ChannelId.eq(channel_id))
.filter(channel_role_permission::Column::RoleId.is_in(role_ids))
.all(db)
.await?;
for p in role_perms {
computed |= ChannelPermission::from_bits_retain(p.permissions as u64);
}
}
// 4. Override direct utilisateur sur le canal
if let Some(user_perm) = channel_user_permission::Entity::find()
.filter(channel_user_permission::Column::ChannelId.eq(channel_id))
.filter(channel_user_permission::Column::UserId.eq(user_id))
.one(db)
.await?
{
computed |= ChannelPermission::from_bits_retain(user_perm.permissions as u64);
}
// 5. Suppression & insertion atomique de l'entrée computed_permission
computed_permission::Entity::delete_many()
.filter(computed_permission::Column::UserId.eq(user_id))
.filter(computed_permission::Column::ResourceId.eq(channel_id))
.exec(db)
.await?;
let active_model = computed_permission::ActiveModel {
user_id: Set(user_id),
server_id: Set(server_id),
scope_type: Set(PermissionScopeType::Channel),
resource_id: Set(channel_id),
permissions: Set(computed.bits() as i64),
..Default::default()
};
computed_permission::Entity::insert(active_model)
.exec(db)
.await?;
Ok(())
}
}
+1 -1
View File
@@ -62,8 +62,8 @@ impl PermissionSyncService {
/// Enregistre les listeners sur l'EventBus pour mettre à jour le cache
/// des permissions calculées lors des modifications de structure ou de droits.
pub async fn start_listen_event(&self) {
let event_bus = self.service_context.event_bus.clone();
let repositories = self.service_context.repositories.clone();
let event_bus = self.service_context.event_bus.clone();
// ---------------------------------------------------------------------
// É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::models::role;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, TransactionTrait, Set};
use sea_orm::{ActiveModelTrait, EntityTrait, TransactionTrait};
use std::sync::Arc;
use uuid::Uuid;
@@ -23,13 +23,13 @@ impl RoleService {
let txn = db.begin().await?;
let group = active.insert(&txn).await?;
let role = active.insert(&txn).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(
@@ -41,13 +41,13 @@ impl RoleService {
let txn = db.begin().await?;
let group = active.update(&txn).await?;
let role = active.update(&txn).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> {
@@ -65,9 +65,29 @@ impl RoleService {
txn.commit().await?;
if deleted {
event_bus.emit("group_deleted", id);
event_bus.emit("role_deleted", id);
}
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)
}
}
+299
View File
@@ -1,5 +1,10 @@
use crate::models::server_item_order::{self, OrderedResourceType};
use crate::services::ServicesContext;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter, QuerySelect, Set,
};
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Clone)]
pub struct ServerOrderService {
@@ -11,9 +16,303 @@ impl ServerOrderService {
Self { service_context }
}
pub(crate) async fn append(
&self,
txn: &DatabaseTransaction,
server_id: Uuid,
resource_id: Uuid,
resource_type: OrderedResourceType,
parent_category_id: Option<Uuid>,
) -> Result<server_item_order::Model, anyhow::Error> {
self.sync(
txn,
resource_id,
resource_type,
Some(server_id),
parent_category_id,
)
.await?
.ok_or_else(|| anyhow::anyhow!("Unable to create server order"))
}
pub(crate) async fn sync(
&self,
txn: &DatabaseTransaction,
resource_id: Uuid,
resource_type: OrderedResourceType,
server_id: Option<Uuid>,
parent_category_id: Option<Uuid>,
) -> Result<Option<server_item_order::Model>, anyhow::Error> {
let existing = server_item_order::Entity::find()
.filter(server_item_order::Column::ResourceId.eq(resource_id))
.filter(server_item_order::Column::ResourceType.eq(resource_type))
.one(txn)
.await?;
let Some(server_id) = server_id else {
if let Some(order) = existing {
server_item_order::Entity::delete_by_id(order.id)
.exec(txn)
.await?;
}
return Ok(None);
};
let order_key = match existing.as_ref() {
Some(order)
if order.server_id == server_id
&& order.parent_category_id == parent_category_id =>
{
order.order_key
}
_ => {
self.next_order_key(txn, server_id, parent_category_id)
.await?
}
};
let order = if let Some(order) = existing {
let mut active: server_item_order::ActiveModel = order.into();
active.server_id = Set(server_id);
active.parent_category_id = Set(parent_category_id);
active.order_key = Set(order_key);
active.update(txn).await?
} else {
server_item_order::ActiveModel {
server_id: Set(server_id),
resource_id: Set(resource_id),
resource_type: Set(resource_type),
parent_category_id: Set(parent_category_id),
order_key: Set(order_key),
..Default::default()
}
.insert(txn)
.await?
};
Ok(Some(order))
}
pub(crate) async fn remove(
&self,
txn: &DatabaseTransaction,
resource_id: Uuid,
resource_type: OrderedResourceType,
) -> Result<(), anyhow::Error> {
server_item_order::Entity::delete_many()
.filter(server_item_order::Column::ResourceId.eq(resource_id))
.filter(server_item_order::Column::ResourceType.eq(resource_type))
.exec(txn)
.await?;
Ok(())
}
async fn next_order_key(
&self,
txn: &DatabaseTransaction,
server_id: Uuid,
parent_category_id: Option<Uuid>,
) -> Result<i64, anyhow::Error> {
let mut query = server_item_order::Entity::find()
.filter(server_item_order::Column::ServerId.eq(server_id));
query = match parent_category_id {
Some(category_id) => {
query.filter(server_item_order::Column::ParentCategoryId.eq(category_id))
}
None => query.filter(server_item_order::Column::ParentCategoryId.is_null()),
};
let max_order = query
.select_only()
.column_as(server_item_order::Column::OrderKey.max(), "max_key")
.into_tuple::<Option<i64>>()
.one(txn)
.await?
.flatten()
.unwrap_or(0);
Ok(max_order + 1)
}
pub async fn exemple_call_other_service(&self) {
if let Some(services) = self.service_context.services.get() {
services.permission_sync.start_listen_event().await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{category, server};
use crate::repositories::Repositories;
use event_bus::EventBus;
use migration::{Migrator, MigratorTrait};
use sea_orm::{
ActiveModelTrait, ColumnTrait, Database, DatabaseConnection, EntityTrait, Set,
TransactionTrait,
};
async fn service() -> ServerOrderService {
let db = Database::connect("sqlite::memory:").await.unwrap();
Migrator::up(&db, None).await.unwrap();
let context = Arc::new(ServicesContext {
repositories: Arc::new(Repositories::new(db)),
event_bus: Arc::new(EventBus::new()),
services: std::sync::OnceLock::new(),
});
ServerOrderService::new(context)
}
async fn seed_server(db: &DatabaseConnection, server_id: Uuid) {
server::ActiveModel {
id: Set(server_id),
name: Set("test".to_owned()),
is_default: Set(false),
..Default::default()
}
.insert(db)
.await
.unwrap();
}
#[tokio::test]
async fn sync_uses_scoped_keys_and_preserves_position() {
let service = service().await;
let db = &service.service_context.repositories.server.context.db;
let server_id = Uuid::new_v4();
let category_id = Uuid::new_v4();
let category_resource_id = Uuid::new_v4();
let root_channel_id = Uuid::new_v4();
let nested_channel_id = Uuid::new_v4();
seed_server(db, server_id).await;
category::ActiveModel {
id: Set(category_id),
server_id: Set(server_id),
name: Set("category".to_owned()),
..Default::default()
}
.insert(db)
.await
.unwrap();
let txn = db.begin().await.unwrap();
service
.append(
&txn,
server_id,
category_resource_id,
OrderedResourceType::Category,
None,
)
.await
.unwrap();
service
.append(
&txn,
server_id,
root_channel_id,
OrderedResourceType::Channel,
None,
)
.await
.unwrap();
service
.append(
&txn,
server_id,
nested_channel_id,
OrderedResourceType::Channel,
Some(category_id),
)
.await
.unwrap();
txn.commit().await.unwrap();
let txn = db.begin().await.unwrap();
let unchanged = service
.sync(
&txn,
nested_channel_id,
OrderedResourceType::Channel,
Some(server_id),
Some(category_id),
)
.await
.unwrap()
.unwrap();
assert_eq!(unchanged.order_key, 1);
let moved = service
.sync(
&txn,
nested_channel_id,
OrderedResourceType::Channel,
Some(server_id),
None,
)
.await
.unwrap()
.unwrap();
assert_eq!(moved.order_key, 3);
txn.commit().await.unwrap();
}
#[tokio::test]
async fn sync_none_removes_order_and_rollback_is_atomic() {
let service = service().await;
let db = &service.service_context.repositories.server.context.db;
let server_id = Uuid::new_v4();
let resource_id = Uuid::new_v4();
seed_server(db, server_id).await;
let txn = db.begin().await.unwrap();
service
.append(
&txn,
server_id,
resource_id,
OrderedResourceType::Channel,
None,
)
.await
.unwrap();
txn.rollback().await.unwrap();
assert!(
server_item_order::Entity::find()
.filter(server_item_order::Column::ResourceId.eq(resource_id))
.one(db)
.await
.unwrap()
.is_none()
);
let txn = db.begin().await.unwrap();
service
.append(
&txn,
server_id,
resource_id,
OrderedResourceType::Channel,
None,
)
.await
.unwrap();
service
.sync(&txn, resource_id, OrderedResourceType::Channel, None, None)
.await
.unwrap();
txn.commit().await.unwrap();
assert!(
server_item_order::Entity::find()
.filter(server_item_order::Column::ResourceId.eq(resource_id))
.one(db)
.await
.unwrap()
.is_none()
);
}
}