Compare commits
12
Commits
9dbb7ffd5b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6d6968e52 | ||
|
|
20beea24d5 | ||
|
|
93800e8460 | ||
|
|
8f3fd6a127 | ||
|
|
42ab990f7d | ||
|
|
d1f9234457 | ||
|
|
0d8c86af16 | ||
|
|
e9fe51363f | ||
|
|
c10925b84b | ||
|
|
bb4e17ba2f | ||
|
|
aa486be6e5 | ||
|
|
659fd0f304 |
@@ -1,7 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<router-view/>
|
<router-view />
|
||||||
|
|
||||||
|
<v-snackbar v-model="notification.visible" :timeout="5000" location="bottom right">
|
||||||
|
<div class="font-weight-bold">{{ notification.title }}</div>
|
||||||
|
<div>{{ notification.message }}</div>
|
||||||
|
</v-snackbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
//
|
import {useNotificationStore} from "@/stores/notification.ts";
|
||||||
|
|
||||||
|
const notification = useNotificationStore();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import {computed, ref, watch} from 'vue'
|
||||||
|
import {useUserStore} from '@/stores/user'
|
||||||
|
import {useApi} from '@/composables/useApi'
|
||||||
|
import {usePermissions} from '@/composables/usePermissions'
|
||||||
|
import ChannelPermissionEditor from './ChannelPermissionEditor.vue'
|
||||||
|
import {toChannelPermissionMask, type ChannelPermissionMask, type ChannelPermissions} from '@/types/permissions'
|
||||||
|
|
||||||
|
interface Role { id: string; server_id: string; name: string; is_default: boolean }
|
||||||
|
interface Channel { id: string; name?: string | null }
|
||||||
|
type TargetType = 'role' | 'user'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean; channel: Channel | null; serverId: string }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
|
||||||
|
const users = useUserStore()
|
||||||
|
const api = useApi()
|
||||||
|
const permissionsApi = usePermissions()
|
||||||
|
const roles = ref<Role[]>([])
|
||||||
|
const configured = ref<ChannelPermissions>({users: [], roles: []})
|
||||||
|
const targetType = ref<TargetType>('role')
|
||||||
|
const targetId = ref<string | null>(null)
|
||||||
|
const addTargetId = ref<string | null>(null)
|
||||||
|
const mask = ref<ChannelPermissionMask>(toChannelPermissionMask(0))
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const configuredEntries = computed(() => targetType.value === 'role' ? configured.value.roles : configured.value.users)
|
||||||
|
type PermissionEntry = ChannelPermissions['roles'][number] | ChannelPermissions['users'][number]
|
||||||
|
function entryTargetId(entry: PermissionEntry): string {
|
||||||
|
return 'role_id' in entry ? entry.role_id : entry.user_id
|
||||||
|
}
|
||||||
|
function entryName(entry: PermissionEntry): string {
|
||||||
|
const id = entryTargetId(entry)
|
||||||
|
return targetType.value === 'role'
|
||||||
|
? roles.value.find(role => role.id === id)?.name || id
|
||||||
|
: users.users.find(user => user.id === id)?.username || id
|
||||||
|
}
|
||||||
|
const availableTargets = computed(() => {
|
||||||
|
const used = new Set(configuredEntries.value.map(entryTargetId))
|
||||||
|
return targetType.value === 'role'
|
||||||
|
? roles.value.filter(role => !used.has(role.id))
|
||||||
|
: users.users.filter(user => !used.has(user.id))
|
||||||
|
})
|
||||||
|
const selectedName = computed(() => {
|
||||||
|
if (!targetId.value) return ''
|
||||||
|
if (targetType.value === 'role') return roles.value.find(role => role.id === targetId.value)?.name || targetId.value
|
||||||
|
return users.users.find(user => user.id === targetId.value)?.username || targetId.value
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.modelValue, async open => {
|
||||||
|
if (open) await load()
|
||||||
|
else reset()
|
||||||
|
})
|
||||||
|
watch(targetType, () => { targetId.value = null; addTargetId.value = null; mask.value = toChannelPermissionMask(0); error.value = null })
|
||||||
|
watch(addTargetId, id => {
|
||||||
|
if (!id) return
|
||||||
|
targetId.value = id
|
||||||
|
mask.value = toChannelPermissionMask(0)
|
||||||
|
addTargetId.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!props.channel) return
|
||||||
|
loading.value = true; error.value = null
|
||||||
|
try {
|
||||||
|
const [roleResponse] = await Promise.all([api.get(`/roles?server_id=${props.serverId}`), users.fetchUsers(props.serverId)])
|
||||||
|
if (!roleResponse.ok) throw new Error('Impossible de charger les rôles')
|
||||||
|
roles.value = (await roleResponse.json() as Role[]).filter(role => role.server_id === props.serverId)
|
||||||
|
configured.value = await permissionsApi.getChannelPermissions(props.channel.id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur lors du chargement des permissions'
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectExisting(id: string) {
|
||||||
|
targetId.value = id
|
||||||
|
const entry = configuredEntries.value.find(item => entryTargetId(item) === id)
|
||||||
|
mask.value = entry?.permissions ?? toChannelPermissionMask(0)
|
||||||
|
error.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(value: ChannelPermissionMask) {
|
||||||
|
if (!props.channel || !targetId.value) return
|
||||||
|
saving.value = true; error.value = null
|
||||||
|
try {
|
||||||
|
if (targetType.value === 'role') await permissionsApi.setChannelRolePermission(props.channel.id, targetId.value, value)
|
||||||
|
else await permissionsApi.setChannelUserPermission(props.channel.id, targetId.value, value)
|
||||||
|
configured.value = await permissionsApi.getChannelPermissions(props.channel.id)
|
||||||
|
mask.value = value
|
||||||
|
} catch (e) { error.value = e instanceof Error ? e.message : 'Erreur lors de la sauvegarde' }
|
||||||
|
finally { saving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPermission() {
|
||||||
|
if (!props.channel || !targetId.value) return
|
||||||
|
saving.value = true; error.value = null
|
||||||
|
try {
|
||||||
|
if (targetType.value === 'role') await permissionsApi.removeChannelRolePermission(props.channel.id, targetId.value)
|
||||||
|
else await permissionsApi.removeChannelUserPermission(props.channel.id, targetId.value)
|
||||||
|
configured.value = await permissionsApi.getChannelPermissions(props.channel.id)
|
||||||
|
targetId.value = null
|
||||||
|
mask.value = toChannelPermissionMask(0)
|
||||||
|
} catch (e) {
|
||||||
|
const status = e instanceof Error ? (e as Error & {status?: number}).status : undefined
|
||||||
|
if (status !== 404) error.value = e instanceof Error ? e.message : 'Erreur lors de la réinitialisation'
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() { emit('update:modelValue', false) }
|
||||||
|
function reset() { configured.value = {users: [], roles: []}; targetId.value = null; addTargetId.value = null; mask.value = toChannelPermissionMask(0); error.value = null }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog :model-value="modelValue" max-width="760" @update:model-value="emit('update:modelValue', $event)">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>Permissions — {{ channel?.name || 'Canal' }}</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-alert v-if="error" class="mb-4" type="error" density="compact">{{ error }}</v-alert>
|
||||||
|
<v-tabs v-model="targetType" class="mb-4">
|
||||||
|
<v-tab value="role">Rôles</v-tab>
|
||||||
|
<v-tab value="user">Membres</v-tab>
|
||||||
|
</v-tabs>
|
||||||
|
<v-row class="permission-layout" dense>
|
||||||
|
<v-col cols="12" md="4" class="permission-sidebar">
|
||||||
|
<div class="text-subtitle-2 mb-2">Cibles configurées</div>
|
||||||
|
<v-list v-if="!loading && configuredEntries.length" density="compact" lines="one" border class="permission-list">
|
||||||
|
<v-list-item
|
||||||
|
v-for="entry in configuredEntries"
|
||||||
|
:key="entry.id"
|
||||||
|
:active="targetId === entryTargetId(entry)"
|
||||||
|
@click="selectExisting(entryTargetId(entry))"
|
||||||
|
>
|
||||||
|
<v-list-item-title>{{ entryName(entry) }}</v-list-item-title>
|
||||||
|
<template #append><v-chip size="x-small" variant="tonal">{{ entry.permissions.toString(2).replace(/^0+/, '').length || 0 }}</v-chip></template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<div v-else-if="!loading" class="text-medium-emphasis text-body-2 py-4">Aucune permission directe configurée.</div>
|
||||||
|
<v-skeleton-loader v-else type="list-item-two-line" />
|
||||||
|
<v-select
|
||||||
|
v-model="addTargetId"
|
||||||
|
:items="availableTargets"
|
||||||
|
:item-title="targetType === 'role' ? 'name' : 'username'"
|
||||||
|
item-value="id"
|
||||||
|
:label="targetType === 'role' ? 'Ajouter un rôle' : 'Ajouter un membre'"
|
||||||
|
:loading="loading"
|
||||||
|
clearable
|
||||||
|
class="mt-3"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="8">
|
||||||
|
<div v-if="targetId" class="permission-editor">
|
||||||
|
<v-chip class="mb-3" color="primary" size="small">{{ selectedName }}</v-chip>
|
||||||
|
<ChannelPermissionEditor
|
||||||
|
v-model="mask"
|
||||||
|
:loading="saving"
|
||||||
|
title="Permissions du canal"
|
||||||
|
@save="save"
|
||||||
|
/>
|
||||||
|
<v-btn class="mt-3" color="error" variant="text" :loading="saving" @click="resetPermission">Réinitialiser les permissions directes</v-btn>
|
||||||
|
</div>
|
||||||
|
<v-sheet v-else class="empty-editor d-flex align-center justify-center text-center text-medium-emphasis" rounded border>
|
||||||
|
Sélectionnez une cible à gauche ou ajoutez-en une pour modifier ses permissions.
|
||||||
|
</v-sheet>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions><v-spacer/><v-btn variant="text" @click="close">Fermer</v-btn></v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.permission-layout {
|
||||||
|
min-height: 390px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-sidebar {
|
||||||
|
border-right: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-list {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-editor {
|
||||||
|
min-height: 360px;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 959px) {
|
||||||
|
.permission-sidebar {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-editor {
|
||||||
|
min-height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import {computed, ref, watch} from 'vue'
|
||||||
|
import {storeToRefs} from 'pinia'
|
||||||
|
import {useServerStore} from '@/stores/server'
|
||||||
|
import {useRoleStore} from '@/stores/role'
|
||||||
|
import {useUserStore} from '@/stores/user'
|
||||||
|
import {usePermissions} from '@/composables/usePermissions'
|
||||||
|
import {toServerPermissionMask, type ServerPermissionMask, type ServerUserPermission} from '@/types/permissions'
|
||||||
|
import ServerPermissionEditor from '@/components/permissions/ServerPermissionEditor.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{modelValue: boolean; serverId: string; serverName: string}>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
const serverStore = useServerStore()
|
||||||
|
const roleStore = useRoleStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const permissionsApi = usePermissions()
|
||||||
|
const {roles} = storeToRefs(roleStore)
|
||||||
|
const activeTab = ref('general')
|
||||||
|
const selectedRoleId = ref<string | null>(null)
|
||||||
|
const selectedUserId = ref<string | null>(null)
|
||||||
|
const name = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const roleForm = ref('')
|
||||||
|
const rolePermissions = ref<ServerPermissionMask>(toServerPermissionMask(0))
|
||||||
|
const memberPermissions = ref<ServerPermissionMask>(toServerPermissionMask(0))
|
||||||
|
const userPermissions = ref<Record<string, ServerUserPermission>>({})
|
||||||
|
|
||||||
|
const selectedRole = computed(() => roles.value.find(role => role.id === selectedRoleId.value) || null)
|
||||||
|
const selectedMembers = computed(() => selectedRoleId.value ? roleStore.members[selectedRoleId.value] || [] : [])
|
||||||
|
const availableUsers = computed(() => userStore.users.filter(user => !selectedMembers.value.some(member => member.id === user.id)))
|
||||||
|
const memberToAdd = ref<string | null>(null)
|
||||||
|
const selectedUser = computed(() => userStore.users.find(user => user.id === selectedUserId.value) || null)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, async open => {
|
||||||
|
if (open) await load()
|
||||||
|
}, {immediate: true})
|
||||||
|
watch(selectedRoleId, async roleId => {
|
||||||
|
if (!roleId) return
|
||||||
|
roleForm.value = selectedRole.value?.name || ''
|
||||||
|
rolePermissions.value = toServerPermissionMask(0)
|
||||||
|
try {
|
||||||
|
await roleStore.fetchMembers(roleId)
|
||||||
|
const permission = await permissionsApi.getServerRolePermission(props.serverId, roleId)
|
||||||
|
rolePermissions.value = permission.permissions
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error & {status?: number}).status !== 404) error.value = e instanceof Error ? e.message : 'Erreur de chargement'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
watch(selectedUserId, userId => {
|
||||||
|
memberPermissions.value = userId && userPermissions.value[userId]
|
||||||
|
? userPermissions.value[userId].permissions
|
||||||
|
: toServerPermissionMask(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
name.value = props.serverName
|
||||||
|
try {
|
||||||
|
const [server, , , permissions] = await Promise.all([
|
||||||
|
serverStore.fetchServer(props.serverId),
|
||||||
|
roleStore.fetchRoles(props.serverId),
|
||||||
|
userStore.fetchUsers(props.serverId),
|
||||||
|
permissionsApi.listServerUserPermissions(props.serverId),
|
||||||
|
])
|
||||||
|
name.value = server.name
|
||||||
|
userPermissions.value = Object.fromEntries(permissions.map(permission => [permission.user_id, permission]))
|
||||||
|
} catch (e) {
|
||||||
|
name.value = ''
|
||||||
|
error.value = e instanceof Error ? e.message : 'Erreur de chargement'
|
||||||
|
}
|
||||||
|
finally { loading.value = false }
|
||||||
|
}
|
||||||
|
async function saveServer() {
|
||||||
|
if (!name.value.trim()) return
|
||||||
|
saving.value = true
|
||||||
|
try { await serverStore.updateServer(props.serverId, {name: name.value.trim()}) }
|
||||||
|
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de sauvegarde' }
|
||||||
|
finally { saving.value = false }
|
||||||
|
}
|
||||||
|
async function createRole() {
|
||||||
|
if (!roleForm.value.trim()) return
|
||||||
|
try { const role = await roleStore.createRole({server_id: props.serverId, name: roleForm.value.trim()}); selectedRoleId.value = role.id; roleForm.value = '' }
|
||||||
|
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de création' }
|
||||||
|
}
|
||||||
|
async function saveRole() {
|
||||||
|
if (!selectedRoleId.value || !roleForm.value.trim()) return
|
||||||
|
try {
|
||||||
|
await roleStore.updateRole(selectedRoleId.value, {name: roleForm.value.trim()})
|
||||||
|
await permissionsApi.setServerRolePermission(props.serverId, selectedRoleId.value, rolePermissions.value)
|
||||||
|
} catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de sauvegarde' }
|
||||||
|
}
|
||||||
|
async function deleteRole() {
|
||||||
|
if (!selectedRoleId.value || selectedRole.value?.is_default) return
|
||||||
|
try { await roleStore.deleteRole(selectedRoleId.value); selectedRoleId.value = null }
|
||||||
|
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de suppression' }
|
||||||
|
}
|
||||||
|
async function addMember() {
|
||||||
|
if (!selectedRoleId.value || !memberToAdd.value) return
|
||||||
|
try { await roleStore.addMember(selectedRoleId.value, memberToAdd.value); memberToAdd.value = null }
|
||||||
|
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur d’ajout' }
|
||||||
|
}
|
||||||
|
async function savePermissions(value: ServerPermissionMask) {
|
||||||
|
if (!selectedRoleId.value) return
|
||||||
|
try { await permissionsApi.setServerRolePermission(props.serverId, selectedRoleId.value, value); rolePermissions.value = value }
|
||||||
|
catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de permissions' }
|
||||||
|
}
|
||||||
|
async function saveMemberPermissions(value: ServerPermissionMask) {
|
||||||
|
if (!selectedUserId.value) return
|
||||||
|
try {
|
||||||
|
const permission = await permissionsApi.setServerUserPermission(props.serverId, selectedUserId.value, value)
|
||||||
|
userPermissions.value[selectedUserId.value] = permission
|
||||||
|
memberPermissions.value = value
|
||||||
|
} catch (e) { error.value = e instanceof Error ? e.message : 'Erreur de permissions' }
|
||||||
|
}
|
||||||
|
async function resetMemberPermissions() {
|
||||||
|
if (!selectedUserId.value) return
|
||||||
|
try {
|
||||||
|
await permissionsApi.removeServerUserPermission(props.serverId, selectedUserId.value)
|
||||||
|
delete userPermissions.value[selectedUserId.value]
|
||||||
|
memberPermissions.value = toServerPermissionMask(0)
|
||||||
|
} catch (e) {
|
||||||
|
const status = (e as Error & {status?: number}).status
|
||||||
|
if (status !== 404) error.value = e instanceof Error ? e.message : 'Erreur de réinitialisation'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-dialog :model-value="modelValue" max-width="980" @update:model-value="emit('update:modelValue', $event)">
|
||||||
|
<v-card min-height="620">
|
||||||
|
<v-card-title>Paramètres du serveur</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-alert v-if="error" type="error" density="compact" class="mb-4">{{ error }}</v-alert>
|
||||||
|
<v-row class="settings-layout" no-gutters>
|
||||||
|
<v-col cols="12" md="3" class="settings-sidebar">
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<v-list-item title="Général" prepend-icon="mdi-cog" :active="activeTab === 'general'" @click="activeTab = 'general'" />
|
||||||
|
<v-list-item title="Rôles" prepend-icon="mdi-shield-account" :active="activeTab === 'roles'" @click="activeTab = 'roles'" />
|
||||||
|
<v-list-item title="Membres" prepend-icon="mdi-account-cog" :active="activeTab === 'members'" @click="activeTab = 'members'" />
|
||||||
|
</v-list>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="9" class="pa-5">
|
||||||
|
<v-progress-linear v-if="loading" indeterminate class="mb-4" />
|
||||||
|
<template v-if="activeTab === 'general'">
|
||||||
|
<div class="text-h6 mb-4">Général</div>
|
||||||
|
<v-text-field v-model="name" label="Nom du serveur" :disabled="loading || saving" />
|
||||||
|
<v-btn color="primary" :loading="saving" :disabled="loading || !name.trim()" @click="saveServer">Enregistrer</v-btn>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="activeTab === 'roles'">
|
||||||
|
<div class="text-h6 mb-4">Rôles</div>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-list border density="compact" class="role-list">
|
||||||
|
<v-list-item v-for="role in roles" :key="role.id" :title="role.name" :active="role.id === selectedRoleId" @click="selectedRoleId = role.id">
|
||||||
|
<template #append><v-icon v-if="role.is_default" icon="mdi-star" size="small" /></template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<v-text-field v-model="roleForm" class="mt-3" label="Nouveau rôle" hide-details @keyup.enter="createRole" />
|
||||||
|
<v-btn class="mt-2" block color="primary" variant="tonal" @click="createRole">Ajouter un rôle</v-btn>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="8">
|
||||||
|
<template v-if="selectedRole">
|
||||||
|
<v-text-field v-model="roleForm" label="Nom du rôle" />
|
||||||
|
<div class="d-flex ga-2 mb-4"><v-btn color="primary" @click="saveRole">Enregistrer</v-btn><v-btn v-if="!selectedRole.is_default" color="error" variant="text" @click="deleteRole">Supprimer</v-btn></div>
|
||||||
|
<v-select v-model="memberToAdd" :items="availableUsers" item-title="username" item-value="id" label="Ajouter un membre" clearable @update:model-value="addMember" />
|
||||||
|
<v-list density="compact" border class="mb-4">
|
||||||
|
<v-list-item v-for="member in selectedMembers" :key="member.id" :title="member.username">
|
||||||
|
<template #append><v-btn icon="mdi-close" size="small" variant="text" @click="roleStore.removeMember(selectedRoleId!, member.id)" /></template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<ServerPermissionEditor v-model="rolePermissions" title="Permissions du rôle" @save="savePermissions" />
|
||||||
|
</template>
|
||||||
|
<v-sheet v-else class="empty-selection d-flex align-center justify-center text-medium-emphasis" border rounded>Sélectionnez un rôle.</v-sheet>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="text-h6 mb-4">Permissions des membres</div>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-list border density="compact" class="role-list">
|
||||||
|
<v-list-item
|
||||||
|
v-for="user in userStore.users"
|
||||||
|
:key="user.id"
|
||||||
|
:title="user.username"
|
||||||
|
:active="user.id === selectedUserId"
|
||||||
|
@click="selectedUserId = user.id"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<v-icon v-if="userPermissions[user.id]" icon="mdi-shield-check" size="small" />
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="8">
|
||||||
|
<template v-if="selectedUser">
|
||||||
|
<v-chip class="mb-4" color="primary" size="small">{{ selectedUser.username }}</v-chip>
|
||||||
|
<ServerPermissionEditor
|
||||||
|
v-model="memberPermissions"
|
||||||
|
title="Permissions directes du membre"
|
||||||
|
@save="saveMemberPermissions"
|
||||||
|
/>
|
||||||
|
<v-btn class="mt-3" color="error" variant="text" @click="resetMemberPermissions">
|
||||||
|
Réinitialiser les permissions directes
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
<v-sheet v-else class="empty-selection d-flex align-center justify-center text-medium-emphasis" border rounded>
|
||||||
|
Sélectionnez un membre.
|
||||||
|
</v-sheet>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions><v-spacer /><v-btn variant="text" @click="emit('update:modelValue', false)">Fermer</v-btn></v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-layout { min-height: 500px; }
|
||||||
|
.settings-sidebar { border-right: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); }
|
||||||
|
.role-list { max-height: 250px; overflow-y: auto; }
|
||||||
|
.empty-selection { min-height: 400px; }
|
||||||
|
@media (max-width: 959px) { .settings-sidebar { border-right: 0; border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity)); } }
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import {useApi} from '@/composables/useApi'
|
import {useApi} from '@/composables/useApi'
|
||||||
import {
|
import {
|
||||||
type ChannelRolePermission,
|
type ChannelRolePermission,
|
||||||
|
type ChannelPermissions,
|
||||||
|
type ChannelPermissionsDto,
|
||||||
|
channelPermissionsFromDto,
|
||||||
type ChannelRolePermissionDto,
|
type ChannelRolePermissionDto,
|
||||||
channelRolePermissionFromDto,
|
channelRolePermissionFromDto,
|
||||||
type ChannelUserPermission,
|
type ChannelUserPermission,
|
||||||
@@ -22,10 +25,11 @@ export function usePermissions() {
|
|||||||
async function parseResponse<T>(response: Response): Promise<T> {
|
async function parseResponse<T>(response: Response): Promise<T> {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => null)
|
const error = await response.json().catch(() => null)
|
||||||
|
const exception = new Error(
|
||||||
throw new Error(
|
|
||||||
error?.message || 'Erreur lors de la gestion des permissions',
|
error?.message || 'Erreur lors de la gestion des permissions',
|
||||||
)
|
) as Error & { status?: number }
|
||||||
|
exception.status = response.status
|
||||||
|
throw exception
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
@@ -52,6 +56,14 @@ export function usePermissions() {
|
|||||||
return serverUserPermissionFromDto(dto)
|
return serverUserPermissionFromDto(dto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listServerUserPermissions(
|
||||||
|
serverId: string,
|
||||||
|
): Promise<ServerUserPermission[]> {
|
||||||
|
const response = await api.get(`/servers/${serverId}/permissions/users`)
|
||||||
|
const dtos = await parseResponse<ServerUserPermissionDto[]>(response)
|
||||||
|
return dtos.map(serverUserPermissionFromDto)
|
||||||
|
}
|
||||||
|
|
||||||
async function setServerUserPermission(
|
async function setServerUserPermission(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -129,6 +141,12 @@ export function usePermissions() {
|
|||||||
// Permissions canal - utilisateur
|
// Permissions canal - utilisateur
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getChannelPermissions(channelId: string): Promise<ChannelPermissions> {
|
||||||
|
const response = await api.get(`/channels/${channelId}/permissions`)
|
||||||
|
const dto = await parseResponse<ChannelPermissionsDto>(response)
|
||||||
|
return channelPermissionsFromDto(dto)
|
||||||
|
}
|
||||||
|
|
||||||
async function getChannelUserPermission(
|
async function getChannelUserPermission(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -216,7 +234,9 @@ export function usePermissions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
getChannelPermissions,
|
||||||
getServerUserPermission,
|
getServerUserPermission,
|
||||||
|
listServerUserPermissions,
|
||||||
setServerUserPermission,
|
setServerUserPermission,
|
||||||
removeServerUserPermission,
|
removeServerUserPermission,
|
||||||
|
|
||||||
@@ -232,4 +252,4 @@ export function usePermissions() {
|
|||||||
setChannelRolePermission,
|
setChannelRolePermission,
|
||||||
removeChannelRolePermission,
|
removeChannelRolePermission,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {storeToRefs} from 'pinia'
|
import {storeToRefs} from 'pinia'
|
||||||
import {useServerStore} from '@/stores/server'
|
import {useServerStore, type Server} from '@/stores/server'
|
||||||
import {computed, ref, watch} from 'vue'
|
import {computed, ref, watch} from 'vue'
|
||||||
import {useRoute, useRouter} from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import ContextMenu from "@/components/ContextMenu.vue";
|
import ContextMenu from "@/components/ContextMenu.vue";
|
||||||
import UserListDrawer from '@/components/UserListDrawer.vue'
|
import UserListDrawer from '@/components/UserListDrawer.vue'
|
||||||
|
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
|
||||||
|
import {useContextMenu} from '@/composables/useContextMenu'
|
||||||
|
|
||||||
const serverStore = useServerStore()
|
const serverStore = useServerStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const {openContextMenu} = useContextMenu()
|
||||||
|
|
||||||
const {servers} = storeToRefs(serverStore)
|
const {servers} = storeToRefs(serverStore)
|
||||||
|
|
||||||
const showUsersDrawer = ref(false)
|
const showUsersDrawer = ref(false)
|
||||||
|
const showServerSettings = ref(false)
|
||||||
|
const selectedServerId = ref<string | null>(null)
|
||||||
|
const selectedServerName = ref('')
|
||||||
const isServerContext = computed(() => Boolean(route.params.serverId))
|
const isServerContext = computed(() => Boolean(route.params.serverId))
|
||||||
|
|
||||||
watch(isServerContext, (isActive) => {
|
watch(isServerContext, (isActive) => {
|
||||||
@@ -82,6 +88,18 @@ const getServerColor = (str: string): string => {
|
|||||||
return `hsl(${hue}, 60%, 45%)`
|
return `hsl(${hue}, 60%, 45%)`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onServerContextMenu(event: MouseEvent, server: Server) {
|
||||||
|
openContextMenu(event, [{
|
||||||
|
label: 'Gérer le serveur',
|
||||||
|
icon: 'mdi-cog',
|
||||||
|
action: () => {
|
||||||
|
selectedServerId.value = server.id
|
||||||
|
selectedServerName.value = server.name
|
||||||
|
showServerSettings.value = true
|
||||||
|
},
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -125,14 +143,25 @@ const getServerColor = (str: string): string => {
|
|||||||
v-for="server in servers"
|
v-for="server in servers"
|
||||||
:key="server.id"
|
:key="server.id"
|
||||||
:to="`/server/${server.id}`"
|
:to="`/server/${server.id}`"
|
||||||
|
@contextmenu="onServerContextMenu($event, server)"
|
||||||
>
|
>
|
||||||
<v-avatar
|
<v-badge
|
||||||
:style="{ backgroundColor: getServerColor(server.name) }"
|
class="server-badge d-flex mx-auto mb-9"
|
||||||
class="d-flex align-center justify-center mx-auto mb-9 font-weight-bold text-caption text-white"
|
:content="server.unread_count"
|
||||||
size="28"
|
:model-value="(server.unread_count ?? 0) > 0"
|
||||||
|
color="primary"
|
||||||
|
location="bottom right"
|
||||||
|
offset-x="2"
|
||||||
|
offset-y="2"
|
||||||
>
|
>
|
||||||
{{ getServerInitials(server.name) }}
|
<v-avatar
|
||||||
</v-avatar>
|
:style="{ backgroundColor: getServerColor(server.name) }"
|
||||||
|
class="d-flex align-center justify-center font-weight-bold text-caption text-white"
|
||||||
|
size="36"
|
||||||
|
>
|
||||||
|
{{ getServerInitials(server.name) }}
|
||||||
|
</v-avatar>
|
||||||
|
</v-badge>
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -150,6 +179,13 @@ const getServerColor = (str: string): string => {
|
|||||||
v-model="showUsersDrawer"
|
v-model="showUsersDrawer"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ServerSettingsDialog
|
||||||
|
v-if="selectedServerId"
|
||||||
|
v-model="showServerSettings"
|
||||||
|
:server-id="selectedServerId"
|
||||||
|
:server-name="selectedServerName"
|
||||||
|
/>
|
||||||
|
|
||||||
<router-view/>
|
<router-view/>
|
||||||
<!-- Menu contextuel global -->
|
<!-- Menu contextuel global -->
|
||||||
<ContextMenu/>
|
<ContextMenu/>
|
||||||
@@ -207,4 +243,17 @@ const getServerColor = (str: string): string => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-badge {
|
||||||
|
height: 36px;
|
||||||
|
width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-badge :deep(.v-badge__badge) {
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import 'highlight.js/styles/github-dark.css'
|
import 'highlight.js/styles/github-dark.css'
|
||||||
import {computed, nextTick, onMounted, ref, watch} from 'vue';
|
import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue';
|
||||||
import {useRoute} from 'vue-router';
|
|
||||||
import {storeToRefs} from 'pinia';
|
import {storeToRefs} from 'pinia';
|
||||||
import {useMessageStore} from '@/stores/message';
|
import {useMessageStore} from '@/stores/message';
|
||||||
|
import {useServerStore} from '@/stores/server';
|
||||||
import {useUserStore} from "@/stores/user.ts";
|
import {useUserStore} from "@/stores/user.ts";
|
||||||
import {useMarkdown} from '@/composables/useMarkdown'
|
import {useMarkdown} from '@/composables/useMarkdown'
|
||||||
|
import {onReloadAll} from '@/plugins/events.ts'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
serverId: string
|
serverId: string
|
||||||
@@ -13,25 +14,154 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const channelId = computed(() => props.channelId);
|
const channelId = computed(() => props.channelId);
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const messageStore = useMessageStore();
|
const messageStore = useMessageStore();
|
||||||
|
const serverStore = useServerStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const {renderMarkdown} = useMarkdown()
|
const {renderMarkdown} = useMarkdown()
|
||||||
|
|
||||||
// Référence vers l'élément scrollable
|
|
||||||
const messageContainer = ref<HTMLElement | null>(null);
|
const messageContainer = ref<HTMLElement | null>(null);
|
||||||
|
const {messages, loading, loadingBefore, loadingAfter, hasMoreAfter, isAtBottom, newestId} = storeToRefs(messageStore);
|
||||||
// "messages" ici est une référence réactive liée au store
|
|
||||||
const {messages, loading} = storeToRefs(messageStore);
|
|
||||||
|
|
||||||
const newMessage = ref('');
|
const newMessage = ref('');
|
||||||
|
|
||||||
|
const SCROLL_LOAD_THRESHOLD = 120;
|
||||||
|
const SCROLL_BOTTOM_TOLERANCE = 4;
|
||||||
|
const paginationLock = ref<'before' | 'after' | null>(null);
|
||||||
|
const paginationLockScrollTop = ref(0);
|
||||||
|
const lastScrollTop = ref(0);
|
||||||
|
const markedMessageByChannel = new Map<string, string>();
|
||||||
|
|
||||||
|
const markCurrentChannelRead = async (targetChannelId: string) => {
|
||||||
|
if (messageStore.activeChannelId !== targetChannelId || !newestId.value) return;
|
||||||
|
|
||||||
|
const messageId = newestId.value;
|
||||||
|
if (markedMessageByChannel.get(targetChannelId) === messageId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const readState = await messageStore.markChannelRead(targetChannelId, messageId);
|
||||||
|
if (messageStore.activeChannelId !== targetChannelId) return;
|
||||||
|
|
||||||
|
markedMessageByChannel.set(targetChannelId, messageId);
|
||||||
|
serverStore.applyChannelReadState(props.serverId, targetChannelId, readState.unread_count);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la mise à jour de la lecture:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showRecentMessagesButton = computed(() =>
|
||||||
|
!loading.value && (hasMoreAfter.value || !isAtBottom.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ScrollAnchor {
|
||||||
|
id: string;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
const scrollToBottom = async () => {
|
const scrollToBottom = async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
if (messageContainer.value) {
|
if (messageContainer.value) {
|
||||||
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
|
messageContainer.value.scrollTop = messageContainer.value.scrollHeight;
|
||||||
|
messageStore.setAtBottom(true);
|
||||||
|
paginationLock.value = null;
|
||||||
|
lastScrollTop.value = messageContainer.value.scrollTop;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMessageElements = () => Array.from(
|
||||||
|
messageContainer.value?.querySelectorAll<HTMLElement>('[data-message-id]') ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
const captureAnchor = (edge: 'top' | 'bottom'): ScrollAnchor | null => {
|
||||||
|
const container = messageContainer.value;
|
||||||
|
if (!container) return null;
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const visible = getMessageElements().filter(element => {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
|
||||||
|
});
|
||||||
|
const element = edge === 'top' ? visible[0] : visible[visible.length - 1];
|
||||||
|
if (!element?.dataset.messageId) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: element.dataset.messageId,
|
||||||
|
top: element.getBoundingClientRect().top,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreAnchor = async (anchor: ScrollAnchor | null) => {
|
||||||
|
if (!anchor || !messageContainer.value) return;
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const element = getMessageElements().find(item => item.dataset.messageId === anchor.id);
|
||||||
|
if (element) {
|
||||||
|
messageContainer.value.scrollTop += element.getBoundingClientRect().top - anchor.top;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateScrollState = () => {
|
||||||
|
const container = messageContainer.value;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
messageStore.setAtBottom(distanceFromBottom <= SCROLL_BOTTOM_TOLERANCE);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPaginationLock = (direction: 'before' | 'after') => {
|
||||||
|
if (!messageContainer.value) return;
|
||||||
|
paginationLock.value = direction;
|
||||||
|
paginationLockScrollTop.value = messageContainer.value.scrollTop;
|
||||||
|
lastScrollTop.value = messageContainer.value.scrollTop;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadBefore = async () => {
|
||||||
|
const anchor = captureAnchor('top');
|
||||||
|
const change = await messageStore.fetchBefore(channelId.value);
|
||||||
|
if (change) {
|
||||||
|
await restoreAnchor(anchor);
|
||||||
|
setPaginationLock('before');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAfter = async () => {
|
||||||
|
const anchor = captureAnchor('bottom');
|
||||||
|
const change = await messageStore.fetchAfter(channelId.value);
|
||||||
|
if (change) {
|
||||||
|
await restoreAnchor(anchor);
|
||||||
|
setPaginationLock('after');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = async () => {
|
||||||
|
const container = messageContainer.value;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const currentScrollTop = container.scrollTop;
|
||||||
|
const scrollDelta = currentScrollTop - lastScrollTop.value;
|
||||||
|
lastScrollTop.value = currentScrollTop;
|
||||||
|
|
||||||
|
if (paginationLock.value === 'after') {
|
||||||
|
if (scrollDelta < -1 || currentScrollTop > paginationLockScrollTop.value + 2) {
|
||||||
|
paginationLock.value = null;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (paginationLock.value === 'before') {
|
||||||
|
if (scrollDelta > 1 || currentScrollTop < paginationLockScrollTop.value - 2) {
|
||||||
|
paginationLock.value = null;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateScrollState();
|
||||||
|
|
||||||
|
if (container.scrollTop <= SCROLL_LOAD_THRESHOLD && !loadingBefore.value) {
|
||||||
|
await loadBefore();
|
||||||
|
} else {
|
||||||
|
const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
if (distanceFromBottom <= SCROLL_LOAD_THRESHOLD && !loadingAfter.value) {
|
||||||
|
await loadAfter();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,47 +169,78 @@ const sendMessage = async () => {
|
|||||||
if (!newMessage.value.trim()) return;
|
if (!newMessage.value.trim()) return;
|
||||||
|
|
||||||
const content = newMessage.value;
|
const content = newMessage.value;
|
||||||
|
const wasAtBottom = messageStore.isAtBottom;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await messageStore.sendMessage(channelId.value, content);
|
await messageStore.sendMessage(channelId.value, content);
|
||||||
newMessage.value = ''; // On vide le champ après succès
|
newMessage.value = '';
|
||||||
await scrollToBottom();
|
if (wasAtBottom) await scrollToBottom();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Gérer l'erreur (ex: notification toast)
|
console.error('Erreur lors de l\'envoi du message:', e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const returnToRecentMessages = async () => {
|
||||||
|
paginationLock.value = null;
|
||||||
|
await messageStore.fetchMessages(channelId.value);
|
||||||
|
await scrollToBottom();
|
||||||
|
await markCurrentChannelRead(channelId.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
let stopReloadAll: (() => void) | null = null;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (channelId.value) {
|
stopReloadAll = onReloadAll(() => messageStore.fetchMessages(channelId.value));
|
||||||
messageStore.fetchMessages(channelId.value);
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopReloadAll?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only explicit initial loads and realtime messages received while at the
|
||||||
|
// bottom request an automatic scroll. Pagination restores its own anchor.
|
||||||
|
watch(messages, async () => {
|
||||||
|
if (messageStore.consumeScrollToBottomRequest()) {
|
||||||
|
await scrollToBottom();
|
||||||
|
}
|
||||||
|
}, {deep: true, flush: 'post'});
|
||||||
|
|
||||||
|
watch(isAtBottom, async (atBottom) => {
|
||||||
|
if (atBottom) {
|
||||||
|
await markCurrentChannelRead(channelId.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(channelId, (newChannelId) => {
|
watch(channelId, async (newChannelId) => {
|
||||||
if (newChannelId) {
|
if (newChannelId) {
|
||||||
messageStore.fetchMessages(newChannelId);
|
await messageStore.fetchMessages(newChannelId);
|
||||||
|
await scrollToBottom();
|
||||||
|
await markCurrentChannelRead(newChannelId);
|
||||||
}
|
}
|
||||||
}, {immediate: true})
|
}, {immediate: true})
|
||||||
|
|
||||||
// Scroll automatique quand la liste des messages change (nouveaux messages reçus)
|
|
||||||
watch(messages, () => {
|
|
||||||
scrollToBottom();
|
|
||||||
}, {deep: true});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- Conteneur principal prenant toute la hauteur -->
|
<v-container class="pa-0 fill-height d-flex flex-column channel-layout" fluid>
|
||||||
<v-container class="pa-0 fill-height d-flex flex-column" fluid>
|
|
||||||
|
|
||||||
<!-- Zone des messages (scrollable) -->
|
|
||||||
<div
|
<div
|
||||||
ref="messageContainer"
|
ref="messageContainer"
|
||||||
class="flex-grow-1 overflow-y-auto w-100 message-container"
|
class="flex-grow-1 overflow-y-auto w-100 message-container"
|
||||||
|
@scroll.passive="handleScroll"
|
||||||
>
|
>
|
||||||
|
<v-progress-linear v-if="loadingBefore" color="primary" indeterminate />
|
||||||
|
|
||||||
|
<v-progress-circular
|
||||||
|
v-if="loading && !messages.length"
|
||||||
|
class="d-block mx-auto mt-4"
|
||||||
|
color="primary"
|
||||||
|
indeterminate
|
||||||
|
/>
|
||||||
|
|
||||||
<v-list bg-color="transparent" lines="three">
|
<v-list bg-color="transparent" lines="three">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="msg in messages"
|
v-for="msg in messages"
|
||||||
:key="msg.id"
|
:key="msg.id"
|
||||||
|
:data-message-id="msg.id"
|
||||||
class="px-4 py-1"
|
class="px-4 py-1"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
@@ -98,9 +259,26 @@ watch(messages, () => {
|
|||||||
</div>
|
</div>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
|
|
||||||
|
<v-progress-linear v-if="loadingAfter" color="primary" indeterminate />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showRecentMessagesButton" class="recent-messages-button">
|
||||||
|
<v-tooltip location="top" text="Revenir aux messages récents">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="tooltipProps"
|
||||||
|
aria-label="Revenir aux messages récents"
|
||||||
|
color="primary"
|
||||||
|
elevation="4"
|
||||||
|
icon="mdi-arrow-down-bold"
|
||||||
|
:loading="loading"
|
||||||
|
@click="returnToRecentMessages"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zone de saisie fixe en bas -->
|
|
||||||
<v-sheet class="pa-4 flex-shrink-0" width="100%">
|
<v-sheet class="pa-4 flex-shrink-0" width="100%">
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-model="newMessage"
|
v-model="newMessage"
|
||||||
@@ -126,16 +304,25 @@ watch(messages, () => {
|
|||||||
</template>
|
</template>
|
||||||
</v-textarea>
|
</v-textarea>
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
|
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.message-container {
|
.message-container {
|
||||||
/* Assure que la zone gère son scroll indépendamment */
|
|
||||||
height: 0;
|
height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.channel-layout {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-messages-button {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 92px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
.markdown-content :deep(p) {
|
.markdown-content :deep(p) {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -155,4 +342,4 @@ watch(messages, () => {
|
|||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,13 +2,17 @@
|
|||||||
import {storeToRefs} from 'pinia'
|
import {storeToRefs} from 'pinia'
|
||||||
import {useChannelStore} from '@/stores/channel'
|
import {useChannelStore} from '@/stores/channel'
|
||||||
import {useCategoryStore} from '@/stores/category'
|
import {useCategoryStore} from '@/stores/category'
|
||||||
import {ref, watch} from 'vue'
|
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'
|
||||||
import {useRoute} from 'vue-router'
|
import {useRoute} from 'vue-router'
|
||||||
import CreateChannelDialog from '@/components/channel/CreateChannelDialog.vue'
|
import CreateChannelDialog from '@/components/channel/CreateChannelDialog.vue'
|
||||||
import CreateCategoryDialog from '@/components/category/CreateCategoryDialog.vue'
|
import CreateCategoryDialog from '@/components/category/CreateCategoryDialog.vue'
|
||||||
import {type MenuItem, useContextMenu} from '@/composables/useContextMenu'
|
import {type MenuItem, useContextMenu} from '@/composables/useContextMenu'
|
||||||
import {useUserStore} from "@/stores/user.ts";
|
import {useUserStore} from "@/stores/user.ts";
|
||||||
import {useServerStore} from "@/stores/server.ts";
|
import {useServerStore} from "@/stores/server.ts";
|
||||||
|
import ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue'
|
||||||
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
|
||||||
|
import {onReloadAll} from '@/plugins/events.ts'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
serverId: string
|
serverId: string
|
||||||
@@ -22,6 +26,11 @@ const userStore = useUserStore()
|
|||||||
const serverStore = useServerStore()
|
const serverStore = useServerStore()
|
||||||
const {currentTree} = storeToRefs(serverStore)
|
const {currentTree} = storeToRefs(serverStore)
|
||||||
const {openContextMenu} = useContextMenu()
|
const {openContextMenu} = useContextMenu()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const showPermissionsDialog = ref(false)
|
||||||
|
const selectedChannel = ref<any | null>(null)
|
||||||
|
const showServerSettings = ref(false)
|
||||||
|
const serverName = computed(() => serverStore.servers.find(server => server.id === props.serverId)?.name || 'Serveur')
|
||||||
|
|
||||||
|
|
||||||
const loadServerData = async (targetServerId: string) => {
|
const loadServerData = async (targetServerId: string) => {
|
||||||
@@ -40,6 +49,16 @@ const loadServerData = async (targetServerId: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let stopReloadAll: (() => void) | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
stopReloadAll = onReloadAll(() => loadServerData(props.serverId))
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopReloadAll?.()
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.serverId,
|
() => props.serverId,
|
||||||
async (newServerId) => {
|
async (newServerId) => {
|
||||||
@@ -111,6 +130,14 @@ async function deleteChannel(channelId: string) {
|
|||||||
|
|
||||||
function onChannelContextMenu(event: MouseEvent, channel: any) {
|
function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
|
...(authStore.isAdmin ? [{
|
||||||
|
label: 'Gérer les permissions',
|
||||||
|
icon: 'mdi-shield-key',
|
||||||
|
action: () => {
|
||||||
|
selectedChannel.value = channel
|
||||||
|
showPermissionsDialog.value = true
|
||||||
|
},
|
||||||
|
}] : []),
|
||||||
{
|
{
|
||||||
label: 'Marquer comme lu',
|
label: 'Marquer comme lu',
|
||||||
icon: 'mdi-check',
|
icon: 'mdi-check',
|
||||||
@@ -136,11 +163,9 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-navigation-drawer width="244" @contextmenu="onSidebarContextMenu">
|
<v-navigation-drawer width="244" @contextmenu="onSidebarContextMenu">
|
||||||
<v-sheet
|
<v-sheet color="grey-lighten-5" height="128" width="100%" class="pa-3">
|
||||||
color="grey-lighten-5"
|
<v-btn block variant="text" prepend-icon="mdi-cog" @click="showServerSettings = true">Gérer le serveur</v-btn>
|
||||||
height="128"
|
</v-sheet>
|
||||||
width="100%"
|
|
||||||
></v-sheet>
|
|
||||||
|
|
||||||
<v-list
|
<v-list
|
||||||
v-model:opened="openedCategories"
|
v-model:opened="openedCategories"
|
||||||
@@ -163,9 +188,22 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
|||||||
:key="channel.id"
|
:key="channel.id"
|
||||||
:title="channel.name"
|
:title="channel.name"
|
||||||
:to="`/server/${serverId}/channel/${channel.id}`"
|
:to="`/server/${serverId}/channel/${channel.id}`"
|
||||||
|
:class="{ 'font-weight-bold': (channel.unread_count ?? 0) > 0 }"
|
||||||
link
|
link
|
||||||
@contextmenu="onChannelContextMenu($event, channel)"
|
@contextmenu="onChannelContextMenu($event, channel)"
|
||||||
/>
|
>
|
||||||
|
<template #append>
|
||||||
|
<v-chip
|
||||||
|
v-if="(channel.unread_count ?? 0) > 0"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
size="small"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{{ channel.unread_count }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
</v-list-group>
|
</v-list-group>
|
||||||
|
|
||||||
<!-- Canal orphelin (racine) -->
|
<!-- Canal orphelin (racine) -->
|
||||||
@@ -174,9 +212,22 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
|||||||
:key="item.Channel.id"
|
:key="item.Channel.id"
|
||||||
:title="item.Channel.name"
|
:title="item.Channel.name"
|
||||||
:to="`/server/${serverId}/channel/${item.Channel.id}`"
|
:to="`/server/${serverId}/channel/${item.Channel.id}`"
|
||||||
|
:class="{ 'font-weight-bold': (item.Channel.unread_count ?? 0) > 0 }"
|
||||||
link
|
link
|
||||||
@contextmenu="onChannelContextMenu($event, item.Channel)"
|
@contextmenu="onChannelContextMenu($event, item.Channel)"
|
||||||
/>
|
>
|
||||||
|
<template #append>
|
||||||
|
<v-chip
|
||||||
|
v-if="(item.Channel.unread_count ?? 0) > 0"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
size="small"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{{ item.Channel.unread_count }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
</v-list>
|
</v-list>
|
||||||
</v-navigation-drawer>
|
</v-navigation-drawer>
|
||||||
@@ -194,6 +245,18 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
|||||||
@created="refreshServerTree"
|
@created="refreshServerTree"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ChannelPermissionsDialog
|
||||||
|
v-model="showPermissionsDialog"
|
||||||
|
:channel="selectedChannel"
|
||||||
|
:server-id="serverId"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ServerSettingsDialog
|
||||||
|
v-model="showServerSettings"
|
||||||
|
:server-id="serverId"
|
||||||
|
:server-name="serverName"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-main>
|
<v-main>
|
||||||
<router-view/>
|
<router-view/>
|
||||||
</v-main>
|
</v-main>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
export const bus = new EventTarget();
|
export const bus = new EventTarget();
|
||||||
|
|
||||||
|
type ReloadAllHandler = () => void | Promise<void>;
|
||||||
|
|
||||||
|
const reloadAllHandlers = new Set<ReloadAllHandler>();
|
||||||
|
|
||||||
export function emitGatewayEvent(namespace: string, action: string, content: any) {
|
export function emitGatewayEvent(namespace: string, action: string, content: any) {
|
||||||
// On construit le nom de l'événement de manière cohérente : gateway:message
|
// On construit le nom de l'événement de manière cohérente : gateway:message
|
||||||
const eventName = `gateway:${namespace.toLowerCase()}`;
|
const eventName = `gateway:${namespace.toLowerCase()}`;
|
||||||
@@ -16,4 +20,21 @@ export function onGatewayEvent(namespace: string, callback: (payload: { action:
|
|||||||
|
|
||||||
// Retourne une fonction pour se désabonner facilement si besoin
|
// Retourne une fonction pour se désabonner facilement si besoin
|
||||||
return () => bus.removeEventListener(eventName, wrapper);
|
return () => bus.removeEventListener(eventName, wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function onReloadAll(handler: ReloadAllHandler) {
|
||||||
|
reloadAllHandlers.add(handler);
|
||||||
|
return () => reloadAllHandlers.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emitReloadAll() {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
Array.from(reloadAllHandlers, handler => Promise.resolve().then(handler)),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
console.error('Reload after WebSocket reconnection failed:', result.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface Channel {
|
|||||||
category_id?: string | null
|
category_id?: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
unread_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -65,4 +66,4 @@ export const useChannelStore = defineStore('channel', {
|
|||||||
this.error = null;
|
this.error = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {defineStore} from 'pinia';
|
import {defineStore} from 'pinia';
|
||||||
import {useAppStore} from "@/stores/app.ts";
|
import {useAppStore} from "@/stores/app.ts";
|
||||||
import {emitGatewayEvent} from "@/plugins/events.ts";
|
import {emitGatewayEvent, emitReloadAll} from "@/plugins/events.ts";
|
||||||
|
|
||||||
type GatewayStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
type GatewayStatus = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ export const useGatewayStore = defineStore('gateway', {
|
|||||||
status: 'disconnected' as GatewayStatus,
|
status: 'disconnected' as GatewayStatus,
|
||||||
reconnectAttempts: 0,
|
reconnectAttempts: 0,
|
||||||
shouldReconnect: false,
|
shouldReconnect: false,
|
||||||
|
reloadOnConnect: false,
|
||||||
reconnectTimer: null as number | null,
|
reconnectTimer: null as number | null,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -34,22 +35,31 @@ export const useGatewayStore = defineStore('gateway', {
|
|||||||
const socket = new WebSocket(wsUrl)
|
const socket = new WebSocket(wsUrl)
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
|
if (this.socket !== socket) return
|
||||||
|
|
||||||
|
const shouldReload = this.reloadOnConnect
|
||||||
this.status = 'connected'
|
this.status = 'connected'
|
||||||
this.reconnectAttempts = 0
|
this.reconnectAttempts = 0
|
||||||
|
this.reloadOnConnect = false
|
||||||
|
|
||||||
|
if (shouldReload) {
|
||||||
|
void emitReloadAll()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
|
if (this.socket !== socket) return
|
||||||
|
|
||||||
this.status = 'disconnected'
|
this.status = 'disconnected'
|
||||||
if (this.socket === socket) {
|
this.socket = null
|
||||||
this.socket = null
|
|
||||||
}
|
|
||||||
if (this.shouldReconnect) {
|
if (this.shouldReconnect) {
|
||||||
|
this.reloadOnConnect = true
|
||||||
this.scheduleReconnect()
|
this.scheduleReconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.onerror = () => {
|
socket.onerror = () => {
|
||||||
|
if (this.socket !== socket) return
|
||||||
this.status = 'error'
|
this.status = 'error'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +80,7 @@ export const useGatewayStore = defineStore('gateway', {
|
|||||||
this.socket = null
|
this.socket = null
|
||||||
this.status = 'disconnected'
|
this.status = 'disconnected'
|
||||||
this.reconnectAttempts = 0
|
this.reconnectAttempts = 0
|
||||||
|
this.reloadOnConnect = false
|
||||||
},
|
},
|
||||||
|
|
||||||
async send(payload: object) {
|
async send(payload: object) {
|
||||||
@@ -100,4 +111,4 @@ export const useGatewayStore = defineStore('gateway', {
|
|||||||
}, delay)
|
}, delay)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+307
-41
@@ -1,9 +1,18 @@
|
|||||||
import {defineStore} from "pinia";
|
import {defineStore} from "pinia";
|
||||||
import {useApi} from "@/composables/useApi.ts";
|
import {useApi} from "@/composables/useApi.ts";
|
||||||
import {onGatewayEvent} from "@/plugins/events.ts";
|
import {onGatewayEvent} from "@/plugins/events.ts";
|
||||||
|
import {useServerStore} from "@/stores/server.ts";
|
||||||
|
import {useAuthStore} from "@/stores/auth.ts";
|
||||||
|
import {useNotificationStore} from "@/stores/notification.ts";
|
||||||
|
|
||||||
interface Message {
|
// Change this value to adjust the maximum number of messages kept in the DOM.
|
||||||
|
// Directional loads automatically use half of this window.
|
||||||
|
export const MESSAGE_WINDOW_SIZE = 50;
|
||||||
|
export const MESSAGE_SHIFT_SIZE = Math.max(1, Math.floor(MESSAGE_WINDOW_SIZE / 2));
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
|
server_id: string | null;
|
||||||
channel_id: string;
|
channel_id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -12,78 +21,335 @@ interface Message {
|
|||||||
reply_to_id: string | null;
|
reply_to_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ReadStateResponse {
|
||||||
|
channel_id: string;
|
||||||
|
last_read_message_id: string | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
unread_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessagePage {
|
||||||
|
messages: Message[];
|
||||||
|
oldest_id: string | null;
|
||||||
|
newest_id: string | null;
|
||||||
|
has_more_before: boolean;
|
||||||
|
has_more_after: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowChange {
|
||||||
|
addedIds: string[];
|
||||||
|
removedIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareMessages(left: Message, right: Message): number {
|
||||||
|
if (left.id < right.id) return -1;
|
||||||
|
if (left.id > right.id) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMessages(messages: Message[]): Message[] {
|
||||||
|
const byId = new Map<string, Message>();
|
||||||
|
for (const message of messages) {
|
||||||
|
byId.set(message.id, message);
|
||||||
|
}
|
||||||
|
return Array.from(byId.values()).sort(compareMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestPage(
|
||||||
|
channelId: string,
|
||||||
|
params: { limit: number; before_id?: string; after_id?: string },
|
||||||
|
): Promise<MessagePage> {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
channel_id: channelId,
|
||||||
|
limit: String(params.limit),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (params.before_id) query.set("before_id", params.before_id);
|
||||||
|
if (params.after_id) query.set("after_id", params.after_id);
|
||||||
|
|
||||||
|
const response = await useApi().get(`/messages?${query.toString()}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Message loading failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<MessagePage>;
|
||||||
|
}
|
||||||
|
|
||||||
export const useMessageStore = defineStore("message", {
|
export const useMessageStore = defineStore("message", {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
messages: [] as Message[],
|
messages: [] as Message[],
|
||||||
|
activeChannelId: null as string | null,
|
||||||
|
oldestId: null as string | null,
|
||||||
|
newestId: null as string | null,
|
||||||
|
hasMoreBefore: false,
|
||||||
|
hasMoreAfter: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
loadingBefore: false,
|
||||||
|
loadingAfter: false,
|
||||||
|
isAtBottom: true,
|
||||||
|
scrollToBottomRequested: false,
|
||||||
|
requestVersion: 0,
|
||||||
|
seenRealtimeMessageIds: new Set<string>(),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
async fetchMessages(channel_id: string) {
|
updateBoundaries(page: MessagePage) {
|
||||||
|
this.oldestId = page.oldest_id ?? this.messages[0]?.id ?? null;
|
||||||
|
this.newestId = page.newest_id ?? this.messages[this.messages.length - 1]?.id ?? null;
|
||||||
|
this.hasMoreBefore = page.has_more_before;
|
||||||
|
this.hasMoreAfter = page.has_more_after;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateLocalBoundaries() {
|
||||||
|
this.oldestId = this.messages[0]?.id ?? null;
|
||||||
|
this.newestId = this.messages[this.messages.length - 1]?.id ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchMessages(channelId: string) {
|
||||||
|
const requestVersion = ++this.requestVersion;
|
||||||
|
this.activeChannelId = channelId;
|
||||||
|
this.messages = [];
|
||||||
|
this.oldestId = null;
|
||||||
|
this.newestId = null;
|
||||||
|
this.hasMoreBefore = false;
|
||||||
|
this.hasMoreAfter = false;
|
||||||
|
this.isAtBottom = true;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
// Query params
|
|
||||||
let params = new URLSearchParams();
|
|
||||||
params.append("channel_id", channel_id);
|
|
||||||
const queryString = params.toString();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const api = useApi();
|
const page = await requestPage(channelId, {limit: MESSAGE_WINDOW_SIZE});
|
||||||
// Utilisation du paramètre pour cibler le channel
|
if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return;
|
||||||
const response = await api.get(`/messages${queryString ? `?${queryString}` : ""}`);
|
|
||||||
this.messages = await response.json();
|
this.messages = mergeMessages(page.messages).slice(-MESSAGE_WINDOW_SIZE);
|
||||||
|
this.updateBoundaries(page);
|
||||||
|
this.scrollToBottomRequested = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur lors du chargement des messages:", error);
|
if (requestVersion === this.requestVersion) {
|
||||||
|
console.error("Erreur lors du chargement des messages:", error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
if (requestVersion === this.requestVersion) {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async sendMessage(channelId: string, content: string) {
|
|
||||||
const api = useApi();
|
|
||||||
console.log("channelId", channelId);
|
|
||||||
try {
|
|
||||||
// Envoi au serveur pour persistance
|
|
||||||
const response = await api.post('/messages', {
|
|
||||||
channel_id: channelId,
|
|
||||||
content: content,
|
|
||||||
reply_to_id: null
|
|
||||||
});
|
|
||||||
const newMessage = await response.json();
|
|
||||||
|
|
||||||
// Ajout local immédiat (optimistic update)
|
async fetchBefore(channelId: string): Promise<WindowChange | null> {
|
||||||
// this.messages.push(newMessage);
|
if (
|
||||||
|
this.activeChannelId !== channelId ||
|
||||||
|
!this.oldestId ||
|
||||||
|
!this.hasMoreBefore ||
|
||||||
|
this.loadingBefore ||
|
||||||
|
this.loadingAfter
|
||||||
|
) return null;
|
||||||
|
|
||||||
|
const requestVersion = this.requestVersion;
|
||||||
|
const previousIds = new Set(this.messages.map(message => message.id));
|
||||||
|
this.loadingBefore = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await requestPage(channelId, {
|
||||||
|
limit: MESSAGE_SHIFT_SIZE,
|
||||||
|
before_id: this.oldestId,
|
||||||
|
});
|
||||||
|
if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return null;
|
||||||
|
|
||||||
|
const incoming = mergeMessages(page.messages);
|
||||||
|
const merged = mergeMessages([...incoming, ...this.messages]);
|
||||||
|
this.messages = merged.slice(0, MESSAGE_WINDOW_SIZE);
|
||||||
|
this.updateBoundaries(page);
|
||||||
|
this.oldestId = this.messages[0]?.id ?? null;
|
||||||
|
this.newestId = this.messages[this.messages.length - 1]?.id ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
addedIds: incoming.filter(message => !previousIds.has(message.id)).map(message => message.id),
|
||||||
|
removedIds: merged.slice(0, -MESSAGE_WINDOW_SIZE).map(message => message.id),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors du chargement des messages précédents:", error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (requestVersion === this.requestVersion) {
|
||||||
|
this.loadingBefore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchAfter(channelId: string): Promise<WindowChange | null> {
|
||||||
|
if (
|
||||||
|
this.activeChannelId !== channelId ||
|
||||||
|
!this.newestId ||
|
||||||
|
!this.hasMoreAfter ||
|
||||||
|
this.loadingBefore ||
|
||||||
|
this.loadingAfter
|
||||||
|
) return null;
|
||||||
|
|
||||||
|
const requestVersion = this.requestVersion;
|
||||||
|
const previousIds = new Set(this.messages.map(message => message.id));
|
||||||
|
this.loadingAfter = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await requestPage(channelId, {
|
||||||
|
limit: MESSAGE_SHIFT_SIZE,
|
||||||
|
after_id: this.newestId,
|
||||||
|
});
|
||||||
|
if (requestVersion !== this.requestVersion || this.activeChannelId !== channelId) return null;
|
||||||
|
|
||||||
|
const incoming = mergeMessages(page.messages);
|
||||||
|
const merged = mergeMessages([...this.messages, ...incoming]);
|
||||||
|
this.messages = merged.slice(-MESSAGE_WINDOW_SIZE);
|
||||||
|
this.updateBoundaries(page);
|
||||||
|
this.oldestId = this.messages[0]?.id ?? null;
|
||||||
|
this.newestId = this.messages[this.messages.length - 1]?.id ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
addedIds: incoming.filter(message => !previousIds.has(message.id)).map(message => message.id),
|
||||||
|
removedIds: merged.slice(0, Math.max(0, merged.length - MESSAGE_WINDOW_SIZE)).map(message => message.id),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lors du chargement des messages suivants:", error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (requestVersion === this.requestVersion) {
|
||||||
|
this.loadingAfter = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendMessage(channelId: string, content: string) {
|
||||||
|
try {
|
||||||
|
const response = await useApi().post("/messages", {
|
||||||
|
channel_id: channelId,
|
||||||
|
content,
|
||||||
|
reply_to_id: null,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Message sending failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMessage = await response.json() as Message;
|
||||||
|
this.addRealtimeMessage(newMessage);
|
||||||
|
return newMessage;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur lors de l'envoi du message:", error);
|
console.error("Erreur lors de l'envoi du message:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async markChannelRead(channelId: string, messageId: string): Promise<ReadStateResponse> {
|
||||||
|
const response = await useApi().put(`/channels/${channelId}/read-state`, {
|
||||||
|
last_read_message_id: messageId,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Read state update failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json() as ReadStateResponse;
|
||||||
|
},
|
||||||
|
|
||||||
|
addRealtimeMessage(message: Message, fromGateway = false) {
|
||||||
|
const serverStore = useServerStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const notificationStore = useNotificationStore();
|
||||||
|
|
||||||
|
if (fromGateway) {
|
||||||
|
if (this.seenRealtimeMessageIds.has(message.id)) return;
|
||||||
|
this.seenRealtimeMessageIds.add(message.id);
|
||||||
|
if (this.seenRealtimeMessageIds.size > 1000) {
|
||||||
|
const oldest = this.seenRealtimeMessageIds.values().next().value;
|
||||||
|
if (oldest) this.seenRealtimeMessageIds.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOwnMessage = authStore.currentUser?.id === message.user_id;
|
||||||
|
const isActiveChannel = message.channel_id === this.activeChannelId;
|
||||||
|
if (!isActiveChannel) {
|
||||||
|
if (fromGateway && !isOwnMessage) {
|
||||||
|
serverStore.applyIncomingMessage(message.server_id, message.channel_id);
|
||||||
|
notificationStore.show("Nouveau message", "Un nouveau message est arrivé dans un autre canal.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = this.messages.findIndex(current => current.id === message.id);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
this.messages[existingIndex] = message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.isAtBottom && this.newestId && message.id > this.newestId) {
|
||||||
|
this.hasMoreAfter = true;
|
||||||
|
if (fromGateway && !isOwnMessage) {
|
||||||
|
serverStore.applyIncomingMessage(message.server_id, message.channel_id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.messages = mergeMessages([...this.messages, message]).slice(-MESSAGE_WINDOW_SIZE);
|
||||||
|
this.updateLocalBoundaries();
|
||||||
|
this.hasMoreAfter = false;
|
||||||
|
if (this.isAtBottom) {
|
||||||
|
this.scrollToBottomRequested = true;
|
||||||
|
} else if (fromGateway && !isOwnMessage) {
|
||||||
|
serverStore.applyIncomingMessage(message.server_id, message.channel_id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMessage(message: Message) {
|
||||||
|
if (message.channel_id !== this.activeChannelId) return;
|
||||||
|
const index = this.messages.findIndex(current => current.id === message.id);
|
||||||
|
if (index !== -1) this.messages[index] = message;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeMessage(id: string) {
|
||||||
|
const index = this.messages.findIndex(message => message.id === id);
|
||||||
|
if (index === -1) return;
|
||||||
|
this.messages.splice(index, 1);
|
||||||
|
this.updateLocalBoundaries();
|
||||||
|
},
|
||||||
|
|
||||||
|
setAtBottom(value: boolean) {
|
||||||
|
this.isAtBottom = value;
|
||||||
|
},
|
||||||
|
|
||||||
|
consumeScrollToBottomRequest() {
|
||||||
|
const requested = this.scrollToBottomRequested;
|
||||||
|
this.scrollToBottomRequested = false;
|
||||||
|
return requested;
|
||||||
|
},
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
|
this.requestVersion += 1;
|
||||||
this.messages = [];
|
this.messages = [];
|
||||||
}
|
this.activeChannelId = null;
|
||||||
}
|
this.oldestId = null;
|
||||||
|
this.newestId = null;
|
||||||
|
this.hasMoreBefore = false;
|
||||||
|
this.hasMoreAfter = false;
|
||||||
|
this.loading = false;
|
||||||
|
this.loadingBefore = false;
|
||||||
|
this.loadingAfter = false;
|
||||||
|
this.isAtBottom = true;
|
||||||
|
this.scrollToBottomRequested = false;
|
||||||
|
this.seenRealtimeMessageIds.clear();
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
onGatewayEvent("Message", (payload) => {
|
onGatewayEvent("Message", (payload) => {
|
||||||
const store = useMessageStore();
|
const store = useMessageStore();
|
||||||
|
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case "add":
|
case "add":
|
||||||
const exists = store.messages.some(m => m.id === payload.content.id);
|
store.addRealtimeMessage(payload.content as Message, true);
|
||||||
if (!exists) {
|
|
||||||
store.messages.push(payload.content);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case "update":
|
case "update":
|
||||||
const updateIndex = store.messages.findIndex(m => m.id === payload.content.id);
|
store.updateMessage(payload.content as Message);
|
||||||
if (updateIndex !== -1) {
|
|
||||||
store.messages[updateIndex] = payload.content;
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case "remove":
|
case "remove":
|
||||||
const removeIndex = store.messages.findIndex(m => m.id === payload.content);
|
store.removeMessage(String(payload.content));
|
||||||
if (removeIndex !== -1) {
|
|
||||||
store.messages.splice(removeIndex, 1);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn("Action non gérée :", payload.action);
|
console.warn("Action non gérée :", payload.action);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {defineStore} from "pinia";
|
||||||
|
|
||||||
|
export const useNotificationStore = defineStore("notification", {
|
||||||
|
state: () => ({
|
||||||
|
visible: false,
|
||||||
|
title: "",
|
||||||
|
message: "",
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
show(title: string, message: string) {
|
||||||
|
this.title = title;
|
||||||
|
this.message = message;
|
||||||
|
this.visible = true;
|
||||||
|
},
|
||||||
|
hide() {
|
||||||
|
this.visible = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import {defineStore} from 'pinia'
|
||||||
|
import {useApi} from '@/composables/useApi'
|
||||||
|
import type {Role} from '@/types/role'
|
||||||
|
import type {User} from '@/types/user'
|
||||||
|
|
||||||
|
export const useRoleStore = defineStore('role', {
|
||||||
|
state: () => ({
|
||||||
|
roles: [] as Role[],
|
||||||
|
members: {} as Record<string, User[]>,
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async fetchRoles(serverId: string) {
|
||||||
|
const response = await useApi().get(`/roles?server_id=${serverId}`)
|
||||||
|
if (!response.ok) throw new Error('Impossible de charger les rôles')
|
||||||
|
this.roles = await response.json()
|
||||||
|
return this.roles
|
||||||
|
},
|
||||||
|
async createRole(payload: {server_id: string; name: string; is_default?: boolean}) {
|
||||||
|
const response = await useApi().post('/roles', payload)
|
||||||
|
if (!response.ok) throw new Error('Impossible de créer le rôle')
|
||||||
|
const role = await response.json()
|
||||||
|
this.roles.push(role)
|
||||||
|
return role
|
||||||
|
},
|
||||||
|
async updateRole(id: string, payload: {name: string; is_default?: boolean}) {
|
||||||
|
const response = await useApi().put(`/roles/${id}`, payload)
|
||||||
|
if (!response.ok) throw new Error('Impossible de modifier le rôle')
|
||||||
|
const role = await response.json()
|
||||||
|
const index = this.roles.findIndex(item => item.id === id)
|
||||||
|
if (index >= 0) this.roles[index] = role
|
||||||
|
return role
|
||||||
|
},
|
||||||
|
async deleteRole(id: string) {
|
||||||
|
const response = await useApi().delete(`/roles/${id}`)
|
||||||
|
if (!response.ok) throw new Error('Impossible de supprimer le rôle')
|
||||||
|
this.roles = this.roles.filter(role => role.id !== id)
|
||||||
|
delete this.members[id]
|
||||||
|
},
|
||||||
|
async fetchMembers(roleId: string) {
|
||||||
|
const response = await useApi().get(`/roles/${roleId}/members`)
|
||||||
|
if (!response.ok) throw new Error('Impossible de charger les membres')
|
||||||
|
this.members[roleId] = await response.json()
|
||||||
|
return this.members[roleId]
|
||||||
|
},
|
||||||
|
async addMember(roleId: string, userId: string) {
|
||||||
|
const response = await useApi().put(`/roles/${roleId}/members/${userId}`)
|
||||||
|
if (!response.ok) throw new Error('Impossible d’ajouter le membre')
|
||||||
|
await this.fetchMembers(roleId)
|
||||||
|
},
|
||||||
|
async removeMember(roleId: string, userId: string) {
|
||||||
|
const response = await useApi().delete(`/roles/${roleId}/members/${userId}`)
|
||||||
|
if (!response.ok) throw new Error('Impossible de retirer le membre')
|
||||||
|
await this.fetchMembers(roleId)
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
this.roles = []
|
||||||
|
this.members = {}
|
||||||
|
this.loading = false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -3,12 +3,13 @@ import {useApi} from "@/composables/useApi.ts";
|
|||||||
import {useChannelStore} from "@/stores/channel.ts";
|
import {useChannelStore} from "@/stores/channel.ts";
|
||||||
import {useCategoryStore} from "@/stores/category.ts";
|
import {useCategoryStore} from "@/stores/category.ts";
|
||||||
|
|
||||||
interface Server {
|
export interface Server {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
unread_count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useServerStore = defineStore("server", {
|
export const useServerStore = defineStore("server", {
|
||||||
@@ -24,6 +25,22 @@ export const useServerStore = defineStore("server", {
|
|||||||
const response = await api.get("/servers");
|
const response = await api.get("/servers");
|
||||||
this.servers = await response.json();
|
this.servers = await response.json();
|
||||||
},
|
},
|
||||||
|
async fetchServer(serverId: string) {
|
||||||
|
const response = await useApi().get(`/servers/${serverId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => null);
|
||||||
|
throw new Error(error?.message || 'Failed to load server');
|
||||||
|
}
|
||||||
|
|
||||||
|
const server: Server = await response.json();
|
||||||
|
const index = this.servers.findIndex(item => item.id === server.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
server.unread_count ??= this.servers[index].unread_count ?? 0;
|
||||||
|
this.servers[index] = server;
|
||||||
|
}
|
||||||
|
else this.servers.push(server);
|
||||||
|
return server;
|
||||||
|
},
|
||||||
async createServer(payload: { name: string; password?: string | null; is_default?: boolean }) {
|
async createServer(payload: { name: string; password?: string | null; is_default?: boolean }) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
@@ -44,6 +61,25 @@ export const useServerStore = defineStore("server", {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async updateServer(serverId: string, payload: { name: string; is_default?: boolean }) {
|
||||||
|
const api = useApi();
|
||||||
|
const response = await api.put(`/servers/${serverId}`, {
|
||||||
|
name: payload.name,
|
||||||
|
password: null,
|
||||||
|
is_default: payload.is_default ?? false,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => null);
|
||||||
|
throw new Error(error?.message || 'Failed to update server');
|
||||||
|
}
|
||||||
|
const updated = await response.json();
|
||||||
|
const index = this.servers.findIndex(server => server.id === serverId);
|
||||||
|
if (index >= 0) {
|
||||||
|
updated.unread_count ??= this.servers[index].unread_count ?? 0;
|
||||||
|
this.servers[index] = updated;
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
},
|
||||||
async fetchServerTree(serverId: string) {
|
async fetchServerTree(serverId: string) {
|
||||||
const api = useApi();
|
const api = useApi();
|
||||||
const channelStore = useChannelStore();
|
const channelStore = useChannelStore();
|
||||||
@@ -74,10 +110,46 @@ export const useServerStore = defineStore("server", {
|
|||||||
|
|
||||||
return tree.items;
|
return tree.items;
|
||||||
},
|
},
|
||||||
|
applyChannelReadState(serverId: string, channelId: string, unreadCount: number) {
|
||||||
|
let previousUnreadCount = 0;
|
||||||
|
|
||||||
|
for (const item of this.currentTree) {
|
||||||
|
const channels = "Category" in item ? item.Category[1] : "Channel" in item ? [item.Channel] : [];
|
||||||
|
const channel = channels.find((candidate: { id: string }) => candidate.id === channelId);
|
||||||
|
if (channel) {
|
||||||
|
previousUnreadCount = channel.unread_count ?? 0;
|
||||||
|
channel.unread_count = unreadCount;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = this.servers.find(candidate => candidate.id === serverId);
|
||||||
|
if (server) {
|
||||||
|
server.unread_count = Math.max(
|
||||||
|
0,
|
||||||
|
(server.unread_count ?? 0) - previousUnreadCount + unreadCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
applyIncomingMessage(serverId: string | null, channelId: string) {
|
||||||
|
if (!serverId) return;
|
||||||
|
|
||||||
|
for (const item of this.currentTree) {
|
||||||
|
const channels = "Category" in item ? item.Category[1] : "Channel" in item ? [item.Channel] : [];
|
||||||
|
const channel = channels.find((candidate: { id: string }) => candidate.id === channelId);
|
||||||
|
if (channel) {
|
||||||
|
channel.unread_count = (channel.unread_count ?? 0) + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = this.servers.find(candidate => candidate.id === serverId);
|
||||||
|
if (server) server.unread_count = (server.unread_count ?? 0) + 1;
|
||||||
|
},
|
||||||
reset() {
|
reset() {
|
||||||
this.servers = [];
|
this.servers = [];
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import {useServerStore} from '@/stores/server.ts'
|
|||||||
import {useCategoryStore} from '@/stores/category.ts'
|
import {useCategoryStore} from '@/stores/category.ts'
|
||||||
import {useChannelStore} from '@/stores/channel.ts'
|
import {useChannelStore} from '@/stores/channel.ts'
|
||||||
import {useMessageStore} from '@/stores/message.ts'
|
import {useMessageStore} from '@/stores/message.ts'
|
||||||
|
import {onReloadAll} from '@/plugins/events.ts'
|
||||||
|
|
||||||
let bootstrapPromise: Promise<void> | null = null
|
let bootstrapPromise: Promise<void> | null = null
|
||||||
|
let reloadServersPromise: Promise<void> | null = null
|
||||||
|
|
||||||
export const useSessionStore = defineStore('session', {
|
export const useSessionStore = defineStore('session', {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
@@ -77,6 +79,19 @@ export const useSessionStore = defineStore('session', {
|
|||||||
])
|
])
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async reloadServers() {
|
||||||
|
if (reloadServersPromise) {
|
||||||
|
return reloadServersPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverStore = useServerStore()
|
||||||
|
reloadServersPromise = serverStore.fetchServers().finally(() => {
|
||||||
|
reloadServersPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return reloadServersPromise
|
||||||
|
},
|
||||||
|
|
||||||
async login(username: string, password: string) {
|
async login(username: string, password: string) {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
@@ -93,4 +108,6 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.isReady = true
|
this.isReady = true
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onReloadAll(() => useSessionStore().reloadServers())
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ export interface ChannelRolePermission {
|
|||||||
permissions: ChannelPermissionMask
|
permissions: ChannelPermissionMask
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChannelPermissions {
|
||||||
|
users: ChannelUserPermission[]
|
||||||
|
roles: ChannelRolePermission[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerUserPermissionDto {
|
export interface ServerUserPermissionDto {
|
||||||
id: string
|
id: string
|
||||||
server_id: string
|
server_id: string
|
||||||
@@ -80,6 +85,11 @@ export interface ChannelRolePermissionDto {
|
|||||||
permissions: number | string
|
permissions: number | string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChannelPermissionsDto {
|
||||||
|
users: ChannelUserPermissionDto[]
|
||||||
|
roles: ChannelRolePermissionDto[]
|
||||||
|
}
|
||||||
|
|
||||||
function asServerPermissionMask(value: bigint): ServerPermissionMask {
|
function asServerPermissionMask(value: bigint): ServerPermissionMask {
|
||||||
return value as ServerPermissionMask
|
return value as ServerPermissionMask
|
||||||
}
|
}
|
||||||
@@ -442,4 +452,13 @@ export function channelRolePermissionFromDto(
|
|||||||
role_id: dto.role_id,
|
role_id: dto.role_id,
|
||||||
permissions: toChannelPermissionMask(dto.permissions),
|
permissions: toChannelPermissionMask(dto.permissions),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function channelPermissionsFromDto(
|
||||||
|
dto: ChannelPermissionsDto,
|
||||||
|
): ChannelPermissions {
|
||||||
|
return {
|
||||||
|
users: dto.users.map(channelUserPermissionFromDto),
|
||||||
|
roles: dto.roles.map(channelRolePermissionFromDto),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface Role {
|
||||||
|
id: string
|
||||||
|
server_id: string
|
||||||
|
name: string
|
||||||
|
is_default: boolean
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
+12
-12
@@ -1,12 +1,12 @@
|
|||||||
pub use sea_orm_migration::prelude::*;
|
pub use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
mod m20220101_000001_create_table;
|
mod m20220101_000001_create_table;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl MigratorTrait for Migrator {
|
impl MigratorTrait for Migrator {
|
||||||
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
|
||||||
vec![Box::new(m20220101_000001_create_table::Migration)]
|
vec![Box::new(m20220101_000001_create_table::Migration)]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,13 +138,18 @@ impl MigrationTrait for Migration {
|
|||||||
.to(Alias::new("user"), Alias::new("id"))
|
.to(Alias::new("user"), Alias::new("id"))
|
||||||
.on_delete(ForeignKeyAction::Cascade),
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.index(
|
.to_owned(),
|
||||||
Index::create()
|
)
|
||||||
.name("uq_server_user")
|
.await?;
|
||||||
.col(Alias::new("server_id"))
|
|
||||||
.col(Alias::new("user_id"))
|
manager
|
||||||
.unique(),
|
.create_index(
|
||||||
)
|
Index::create()
|
||||||
|
.name("uq_server_user")
|
||||||
|
.table(Alias::new("server_user"))
|
||||||
|
.col(Alias::new("server_id"))
|
||||||
|
.col(Alias::new("user_id"))
|
||||||
|
.unique()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -185,13 +190,18 @@ impl MigrationTrait for Migration {
|
|||||||
.to(Alias::new("server"), Alias::new("id"))
|
.to(Alias::new("server"), Alias::new("id"))
|
||||||
.on_delete(ForeignKeyAction::Cascade),
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.index(
|
.to_owned(),
|
||||||
Index::create()
|
)
|
||||||
.name("uq_role_server_name")
|
.await?;
|
||||||
.col(Alias::new("server_id"))
|
|
||||||
.col(Alias::new("name"))
|
manager
|
||||||
.unique(),
|
.create_index(
|
||||||
)
|
Index::create()
|
||||||
|
.name("uq_role_server_name")
|
||||||
|
.table(Alias::new("role"))
|
||||||
|
.col(Alias::new("server_id"))
|
||||||
|
.col(Alias::new("name"))
|
||||||
|
.unique()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -376,14 +386,6 @@ impl MigrationTrait for Migration {
|
|||||||
.to(Alias::new("category"), Alias::new("id"))
|
.to(Alias::new("category"), Alias::new("id"))
|
||||||
.on_delete(ForeignKeyAction::Cascade),
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.index(
|
|
||||||
Index::create()
|
|
||||||
.name("uq_server_item_order_resource")
|
|
||||||
.col(Alias::new("server_id"))
|
|
||||||
.col(Alias::new("resource_type"))
|
|
||||||
.col(Alias::new("resource_id"))
|
|
||||||
.unique(),
|
|
||||||
)
|
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -400,6 +402,19 @@ impl MigrationTrait for Migration {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("uq_server_item_order_resource")
|
||||||
|
.table(Alias::new("server_item_order"))
|
||||||
|
.col(Alias::new("server_id"))
|
||||||
|
.col(Alias::new("resource_type"))
|
||||||
|
.col(Alias::new("resource_id"))
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Membres des canaux
|
// Membres des canaux
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -443,13 +458,82 @@ impl MigrationTrait for Migration {
|
|||||||
.to(Alias::new("user"), Alias::new("id"))
|
.to(Alias::new("user"), Alias::new("id"))
|
||||||
.on_delete(ForeignKeyAction::Cascade),
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.index(
|
.to_owned(),
|
||||||
Index::create()
|
)
|
||||||
.name("uq_channel_user")
|
.await?;
|
||||||
.col(Alias::new("channel_id"))
|
|
||||||
.col(Alias::new("user_id"))
|
manager
|
||||||
.unique(),
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("uq_channel_user")
|
||||||
|
.table(Alias::new("channel_user"))
|
||||||
|
.col(Alias::new("channel_id"))
|
||||||
|
.col(Alias::new("user_id"))
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
// Position de lecture des utilisateurs
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Alias::new("channel_user_read_state"))
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Alias::new("id"))
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
)
|
)
|
||||||
|
.col(ColumnDef::new(Alias::new("channel_id")).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(Alias::new("user_id")).uuid().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Alias::new("last_read_message_id"))
|
||||||
|
.uuid()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Alias::new("updated_at"))
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_channel_user_read_state_channel")
|
||||||
|
.from(
|
||||||
|
Alias::new("channel_user_read_state"),
|
||||||
|
Alias::new("channel_id"),
|
||||||
|
)
|
||||||
|
.to(Alias::new("channel"), Alias::new("id"))
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_channel_user_read_state_user")
|
||||||
|
.from(
|
||||||
|
Alias::new("channel_user_read_state"),
|
||||||
|
Alias::new("user_id"),
|
||||||
|
)
|
||||||
|
.to(Alias::new("user"), Alias::new("id"))
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("uq_channel_user_read_state")
|
||||||
|
.table(Alias::new("channel_user_read_state"))
|
||||||
|
.col(Alias::new("channel_id"))
|
||||||
|
.col(Alias::new("user_id"))
|
||||||
|
.unique()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -509,6 +593,17 @@ impl MigrationTrait for Migration {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_message_channel_id_id")
|
||||||
|
.table(Alias::new("message"))
|
||||||
|
.col(Alias::new("channel_id"))
|
||||||
|
.col(Alias::new("id"))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
manager
|
manager
|
||||||
.create_table(
|
.create_table(
|
||||||
Table::create()
|
Table::create()
|
||||||
@@ -568,13 +663,6 @@ impl MigrationTrait for Migration {
|
|||||||
.not_null()
|
.not_null()
|
||||||
.default(0),
|
.default(0),
|
||||||
)
|
)
|
||||||
.index(
|
|
||||||
Index::create()
|
|
||||||
.name("uq_server_user_permission")
|
|
||||||
.col(Alias::new("server_id"))
|
|
||||||
.col(Alias::new("user_id"))
|
|
||||||
.unique(),
|
|
||||||
)
|
|
||||||
.foreign_key(
|
.foreign_key(
|
||||||
ForeignKey::create()
|
ForeignKey::create()
|
||||||
.name("fk_server_user_permission_server")
|
.name("fk_server_user_permission_server")
|
||||||
@@ -596,6 +684,18 @@ impl MigrationTrait for Migration {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("uq_server_user_permission")
|
||||||
|
.table(Alias::new("server_user_permission"))
|
||||||
|
.col(Alias::new("server_id"))
|
||||||
|
.col(Alias::new("user_id"))
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
manager
|
manager
|
||||||
.create_table(
|
.create_table(
|
||||||
Table::create()
|
Table::create()
|
||||||
@@ -632,13 +732,18 @@ impl MigrationTrait for Migration {
|
|||||||
.to(Alias::new("role"), Alias::new("id"))
|
.to(Alias::new("role"), Alias::new("id"))
|
||||||
.on_delete(ForeignKeyAction::Cascade),
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.index(
|
.to_owned(),
|
||||||
Index::create()
|
)
|
||||||
.name("uq_server_role_permission")
|
.await?;
|
||||||
.col(Alias::new("server_id"))
|
|
||||||
.col(Alias::new("role_id"))
|
manager
|
||||||
.unique(),
|
.create_index(
|
||||||
)
|
Index::create()
|
||||||
|
.name("uq_server_role_permission")
|
||||||
|
.table(Alias::new("server_role_permission"))
|
||||||
|
.col(Alias::new("server_id"))
|
||||||
|
.col(Alias::new("role_id"))
|
||||||
|
.unique()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -679,13 +784,18 @@ impl MigrationTrait for Migration {
|
|||||||
.to(Alias::new("role"), Alias::new("id"))
|
.to(Alias::new("role"), Alias::new("id"))
|
||||||
.on_delete(ForeignKeyAction::Cascade),
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.index(
|
.to_owned(),
|
||||||
Index::create()
|
)
|
||||||
.name("uq_channel_role_permission")
|
.await?;
|
||||||
.col(Alias::new("channel_id"))
|
|
||||||
.col(Alias::new("role_id"))
|
manager
|
||||||
.unique(),
|
.create_index(
|
||||||
)
|
Index::create()
|
||||||
|
.name("uq_channel_role_permission")
|
||||||
|
.table(Alias::new("channel_role_permission"))
|
||||||
|
.col(Alias::new("channel_id"))
|
||||||
|
.col(Alias::new("role_id"))
|
||||||
|
.unique()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -726,13 +836,18 @@ impl MigrationTrait for Migration {
|
|||||||
.to(Alias::new("user"), Alias::new("id"))
|
.to(Alias::new("user"), Alias::new("id"))
|
||||||
.on_delete(ForeignKeyAction::Cascade),
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
)
|
)
|
||||||
.index(
|
.to_owned(),
|
||||||
Index::create()
|
)
|
||||||
.name("uq_channel_user_permission")
|
.await?;
|
||||||
.col(Alias::new("channel_id"))
|
|
||||||
.col(Alias::new("user_id"))
|
manager
|
||||||
.unique(),
|
.create_index(
|
||||||
)
|
Index::create()
|
||||||
|
.name("uq_channel_user_permission")
|
||||||
|
.table(Alias::new("channel_user_permission"))
|
||||||
|
.col(Alias::new("channel_id"))
|
||||||
|
.col(Alias::new("user_id"))
|
||||||
|
.unique()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -793,6 +908,7 @@ impl MigrationTrait for Migration {
|
|||||||
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
let tables = [
|
let tables = [
|
||||||
"computed_permission",
|
"computed_permission",
|
||||||
|
"channel_user_read_state",
|
||||||
"channel_user_permission",
|
"channel_user_permission",
|
||||||
"channel_role_permission",
|
"channel_role_permission",
|
||||||
"server_user_permission",
|
"server_user_permission",
|
||||||
@@ -821,15 +937,6 @@ impl MigrationTrait for Migration {
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
manager
|
|
||||||
.drop_index(
|
|
||||||
Index::drop()
|
|
||||||
.name("idx_server_item_order_scope")
|
|
||||||
.table(Alias::new("server_item_order"))
|
|
||||||
.to_owned(),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -35,7 +35,6 @@ impl App {
|
|||||||
let repositories = Arc::new(Repositories::new(db.clone()));
|
let repositories = Arc::new(Repositories::new(db.clone()));
|
||||||
|
|
||||||
// Initialize gateway manager
|
// Initialize gateway manager
|
||||||
let gateway = Arc::new(GatewayManager::default());
|
|
||||||
|
|
||||||
// Init one server if no one exist
|
// Init one server if no one exist
|
||||||
let default_server = match repositories.server.get_default().await? {
|
let default_server = match repositories.server.get_default().await? {
|
||||||
@@ -68,6 +67,11 @@ impl App {
|
|||||||
let metrics = AppMetrics::new();
|
let metrics = AppMetrics::new();
|
||||||
|
|
||||||
let services = Arc::new(Services::new(repositories.clone(), event_bus.clone()));
|
let services = Arc::new(Services::new(repositories.clone(), event_bus.clone()));
|
||||||
|
services.permission_sync.start_listen_event().await;
|
||||||
|
services.realtime_registry.initialize(&repositories).await?;
|
||||||
|
services.realtime_registry.start_listening(repositories.clone(), event_bus.clone());
|
||||||
|
let gateway = Arc::new(GatewayManager::new(services.clone()));
|
||||||
|
gateway.start(event_bus.clone());
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -42,12 +42,28 @@ pub struct ChannelResponse {
|
|||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
|
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub unread_count: Option<u64>,
|
||||||
|
|
||||||
/// None : contexte sans permissions (champ ignoré dans le JSON).
|
/// None : contexte sans permissions (champ ignoré dans le JSON).
|
||||||
/// Some(value) : valeur de computed_permission (0 si absente).
|
/// Some(value) : valeur de computed_permission (0 si absente).
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub permission: Option<u64>,
|
pub permission: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct ReadStateResponse {
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub last_read_message_id: Option<Uuid>,
|
||||||
|
pub updated_at: Option<DateTime<Utc>>,
|
||||||
|
pub unread_count: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct SetReadStateRequest {
|
||||||
|
pub last_read_message_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct SetChannelPermissionRequest {
|
pub struct SetChannelPermissionRequest {
|
||||||
/// Bitmask des permissions à appliquer.
|
/// Bitmask des permissions à appliquer.
|
||||||
@@ -70,3 +86,9 @@ pub struct ChannelRolePermissionResponse {
|
|||||||
pub role_id: Uuid,
|
pub role_id: Uuid,
|
||||||
pub permissions: u64,
|
pub permissions: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct ChannelPermissionsResponse {
|
||||||
|
pub users: Vec<ChannelUserPermissionResponse>,
|
||||||
|
pub roles: Vec<ChannelRolePermissionResponse>,
|
||||||
|
}
|
||||||
|
|||||||
+13
-12
@@ -6,6 +6,7 @@ use uuid::Uuid;
|
|||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct MessageResponse {
|
pub struct MessageResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
pub server_id: Option<Uuid>,
|
||||||
pub channel_id: Uuid,
|
pub channel_id: Uuid,
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
@@ -14,6 +15,15 @@ pub struct MessageResponse {
|
|||||||
pub reply_to_id: Option<Uuid>,
|
pub reply_to_id: Option<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct MessagePageResponse {
|
||||||
|
pub messages: Vec<MessageResponse>,
|
||||||
|
pub oldest_id: Option<Uuid>,
|
||||||
|
pub newest_id: Option<Uuid>,
|
||||||
|
pub has_more_before: bool,
|
||||||
|
pub has_more_after: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct CreateMessageRequest {
|
pub struct CreateMessageRequest {
|
||||||
pub channel_id: Uuid,
|
pub channel_id: Uuid,
|
||||||
@@ -26,19 +36,10 @@ pub struct UpdateMessageRequest {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Deserialize, utoipa::IntoParams)]
|
#[derive(Debug, serde::Deserialize, utoipa::IntoParams)]
|
||||||
pub struct MessageQueryParams {
|
pub struct MessageQueryParams {
|
||||||
pub channel_id: Option<uuid::Uuid>,
|
pub channel_id: Uuid,
|
||||||
pub before_id: Option<Uuid>,
|
pub before_id: Option<Uuid>,
|
||||||
|
pub after_id: Option<Uuid>,
|
||||||
pub limit: Option<u64>,
|
pub limit: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MessageQueryParams {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
channel_id: None,
|
|
||||||
before_id: None,
|
|
||||||
limit: Some(50),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ use serde::{Deserialize, Serialize};
|
|||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema, utoipa::IntoParams)]
|
||||||
|
pub struct RoleQueryParams {
|
||||||
|
pub server_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct CreateRoleRequest {
|
pub struct CreateRoleRequest {
|
||||||
pub server_id: Uuid,
|
pub server_id: Uuid,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ pub struct ServerResponse {
|
|||||||
pub is_default: bool,
|
pub is_default: bool,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub unread_count: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
|
|||||||
+1
-3
@@ -1,7 +1,5 @@
|
|||||||
use migration::{Migrator, MigratorTrait};
|
|
||||||
use oxspeak_server_lib::config::AppConfig;
|
use oxspeak_server_lib::config::AppConfig;
|
||||||
use oxspeak_server_lib::core::App;
|
use oxspeak_server_lib::core::App;
|
||||||
use oxspeak_server_lib::database::Database;
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -10,7 +8,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.with_env_filter(
|
.with_env_filter(
|
||||||
std::env::var("RUST_LOG")
|
std::env::var("RUST_LOG")
|
||||||
// .unwrap_or_else(|_| "info,sqlx=debug,sea_orm=debug,sea_orm_migration=info".into()),
|
// .unwrap_or_else(|_| "info,sqlx=debug,sea_orm=debug,sea_orm_migration=info".into()),
|
||||||
.unwrap_or_else(|_| "info,sqlx=info,sea_orm=info,sea_orm_migration=info".into()),
|
.unwrap_or_else(|_| "info,sqlx=debug,sea_orm=debug,sea_orm_migration=debug".into()),
|
||||||
)
|
)
|
||||||
.with_target(true)
|
.with_target(true)
|
||||||
.with_level(true)
|
.with_level(true)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::prelude::async_trait::async_trait;
|
||||||
|
|
||||||
|
#[sea_orm::model]
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
|
||||||
|
#[sea_orm(table_name = "channel_user_read_state")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub channel_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub last_read_message_id: Option<Uuid>,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to,
|
||||||
|
from = "channel_id",
|
||||||
|
to = "id",
|
||||||
|
on_update = "NoAction",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
pub channel: HasOne<super::channel::Entity>,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to,
|
||||||
|
from = "user_id",
|
||||||
|
to = "id",
|
||||||
|
on_update = "NoAction",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
pub user: HasOne<super::user::Entity>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
+21
-20
@@ -1,20 +1,21 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||||
|
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
pub mod attachment;
|
pub mod attachment;
|
||||||
pub mod category;
|
pub mod category;
|
||||||
pub mod channel;
|
pub mod channel;
|
||||||
pub mod channel_role_permission;
|
pub mod channel_role_permission;
|
||||||
pub mod channel_user;
|
pub mod channel_user;
|
||||||
pub mod channel_user_permission;
|
pub mod channel_user_read_state;
|
||||||
pub mod computed_permission;
|
pub mod channel_user_permission;
|
||||||
pub mod message;
|
pub mod computed_permission;
|
||||||
pub mod role;
|
pub mod message;
|
||||||
pub mod role_user;
|
pub mod role;
|
||||||
pub mod server;
|
pub mod role_user;
|
||||||
pub mod server_item_order;
|
pub mod server;
|
||||||
pub mod server_role_permission;
|
pub mod server_item_order;
|
||||||
pub mod server_user;
|
pub mod server_role_permission;
|
||||||
pub mod server_user_permission;
|
pub mod server_user;
|
||||||
pub mod user;
|
pub mod server_user_permission;
|
||||||
|
pub mod user;
|
||||||
|
|||||||
+17
-16
@@ -1,16 +1,17 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||||
|
|
||||||
pub use super::attachment::Entity as Attachment;
|
pub use super::attachment::Entity as Attachment;
|
||||||
pub use super::category::Entity as Category;
|
pub use super::category::Entity as Category;
|
||||||
pub use super::channel::Entity as Channel;
|
pub use super::channel::Entity as Channel;
|
||||||
pub use super::channel_user::Entity as ChannelUser;
|
pub use super::channel_user::Entity as ChannelUser;
|
||||||
pub use super::computed_permission::Entity as ComputedPermission;
|
pub use super::channel_user_read_state::Entity as ChannelUserReadState;
|
||||||
pub use super::message::Entity as Message;
|
pub use super::computed_permission::Entity as ComputedPermission;
|
||||||
pub use super::role::Entity as Group;
|
pub use super::message::Entity as Message;
|
||||||
pub use super::role_user::Entity as GroupMember;
|
pub use super::role::Entity as Group;
|
||||||
pub use super::server::Entity as Server;
|
pub use super::role_user::Entity as GroupMember;
|
||||||
pub use super::server_item_order::Entity as ServerItemOrder;
|
pub use super::server::Entity as Server;
|
||||||
pub use super::server_role_permission::Entity as ServerRolePermission;
|
pub use super::server_item_order::Entity as ServerItemOrder;
|
||||||
pub use super::server_user::Entity as ServerUser;
|
pub use super::server_role_permission::Entity as ServerRolePermission;
|
||||||
pub use super::server_user_permission::Entity as ServerUserPermission;
|
pub use super::server_user::Entity as ServerUser;
|
||||||
pub use super::user::Entity as User;
|
pub use super::server_user_permission::Entity as ServerUserPermission;
|
||||||
|
pub use super::user::Entity as User;
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ impl ChannelRepository {
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_user_permissions(
|
||||||
|
&self,
|
||||||
|
channel_id: Uuid,
|
||||||
|
) -> AnyResult<Vec<channel_user_permission::Model>> {
|
||||||
|
Ok(channel_user_permission::Entity::find()
|
||||||
|
.filter(channel_user_permission::Column::ChannelId.eq(channel_id))
|
||||||
|
.all(&self.context.db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_user_permission(
|
pub async fn set_user_permission(
|
||||||
&self,
|
&self,
|
||||||
channel_id: Uuid,
|
channel_id: Uuid,
|
||||||
@@ -109,6 +119,16 @@ impl ChannelRepository {
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn list_role_permissions(
|
||||||
|
&self,
|
||||||
|
channel_id: Uuid,
|
||||||
|
) -> AnyResult<Vec<channel_role_permission::Model>> {
|
||||||
|
Ok(channel_role_permission::Entity::find()
|
||||||
|
.filter(channel_role_permission::Column::ChannelId.eq(channel_id))
|
||||||
|
.all(&self.context.db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_role_permission(
|
pub async fn set_role_permission(
|
||||||
&self,
|
&self,
|
||||||
channel_id: Uuid,
|
channel_id: Uuid,
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
use super::types::MessageFilter;
|
use super::types::MessageFilter;
|
||||||
use crate::models::{channel, message};
|
use crate::models::message;
|
||||||
use crate::repositories::{AnyResult, RepositoryContext};
|
use crate::repositories::{AnyResult, RepositoryContext};
|
||||||
use event_bus::Scope;
|
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const DEFAULT_MESSAGE_LIMIT: u64 = 20;
|
||||||
|
pub const MAX_MESSAGE_LIMIT: u64 = 100;
|
||||||
|
|
||||||
|
pub struct MessagePage {
|
||||||
|
pub messages: Vec<message::Model>,
|
||||||
|
pub has_more_before: bool,
|
||||||
|
pub has_more_after: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct MessageRepository {
|
pub struct MessageRepository {
|
||||||
@@ -21,7 +30,11 @@ impl MessageRepository {
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn filter(&self, filter: MessageFilter) -> AnyResult<Vec<message::Model>> {
|
pub async fn filter(&self, filter: MessageFilter) -> AnyResult<MessagePage> {
|
||||||
|
let limit = filter
|
||||||
|
.limit
|
||||||
|
.unwrap_or(DEFAULT_MESSAGE_LIMIT)
|
||||||
|
.clamp(1, MAX_MESSAGE_LIMIT);
|
||||||
let mut query = message::Entity::find();
|
let mut query = message::Entity::find();
|
||||||
|
|
||||||
if let Some(channel_id) = filter.channel_id {
|
if let Some(channel_id) = filter.channel_id {
|
||||||
@@ -32,11 +45,80 @@ impl MessageRepository {
|
|||||||
query = query.filter(message::Column::Id.lt(before_id));
|
query = query.filter(message::Column::Id.lt(before_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(limit) = filter.limit {
|
if let Some(after_id) = filter.after_id {
|
||||||
query = query.order_by_desc(message::Column::Id).limit(limit);
|
query = query
|
||||||
|
.filter(message::Column::Id.gt(after_id))
|
||||||
|
.order_by_asc(message::Column::Id);
|
||||||
|
} else {
|
||||||
|
query = query.order_by_desc(message::Column::Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(query.all(&self.context.db).await?)
|
let mut messages = query.limit(limit + 1).all(&self.context.db).await?;
|
||||||
|
let has_more_in_direction = messages.len() > limit as usize;
|
||||||
|
messages.truncate(limit as usize);
|
||||||
|
|
||||||
|
// Queries that walk backwards are executed in descending order so the
|
||||||
|
// database can stop as soon as it has found the requested rows. The UI
|
||||||
|
// always receives chronological order.
|
||||||
|
if filter.after_id.is_none() {
|
||||||
|
messages.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (has_more_before, has_more_after) = if filter.after_id.is_some() {
|
||||||
|
let has_messages_before = self
|
||||||
|
.exists_on_or_before(filter.channel_id, filter.after_id.unwrap())
|
||||||
|
.await?;
|
||||||
|
(has_messages_before, has_more_in_direction)
|
||||||
|
} else if filter.before_id.is_some() {
|
||||||
|
let has_messages_after = self
|
||||||
|
.exists_on_or_after(filter.channel_id, filter.before_id.unwrap())
|
||||||
|
.await?;
|
||||||
|
(has_more_in_direction, has_messages_after)
|
||||||
|
} else {
|
||||||
|
(has_more_in_direction, false)
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(MessagePage {
|
||||||
|
messages,
|
||||||
|
has_more_before,
|
||||||
|
has_more_after,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exists_on_or_before(&self, channel_id: Option<Uuid>, id: Uuid) -> AnyResult<bool> {
|
||||||
|
let mut query = message::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(message::Column::Id)
|
||||||
|
.filter(message::Column::Id.lte(id));
|
||||||
|
|
||||||
|
if let Some(channel_id) = channel_id {
|
||||||
|
query = query.filter(message::Column::ChannelId.eq(channel_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(query
|
||||||
|
.limit(1)
|
||||||
|
.into_tuple::<Uuid>()
|
||||||
|
.one(&self.context.db)
|
||||||
|
.await?
|
||||||
|
.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn exists_on_or_after(&self, channel_id: Option<Uuid>, id: Uuid) -> AnyResult<bool> {
|
||||||
|
let mut query = message::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(message::Column::Id)
|
||||||
|
.filter(message::Column::Id.gte(id));
|
||||||
|
|
||||||
|
if let Some(channel_id) = channel_id {
|
||||||
|
query = query.filter(message::Column::ChannelId.eq(channel_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(query
|
||||||
|
.limit(1)
|
||||||
|
.into_tuple::<Uuid>()
|
||||||
|
.one(&self.context.db)
|
||||||
|
.await?
|
||||||
|
.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_by_channel(&self, channel_id: uuid::Uuid) -> AnyResult<Vec<message::Model>> {
|
pub async fn get_by_channel(&self, channel_id: uuid::Uuid) -> AnyResult<Vec<message::Model>> {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use crate::repositories::category::CategoryRepository;
|
|||||||
use crate::repositories::channel::ChannelRepository;
|
use crate::repositories::channel::ChannelRepository;
|
||||||
use crate::repositories::computed_permission::ComputedPermissionRepository;
|
use crate::repositories::computed_permission::ComputedPermissionRepository;
|
||||||
use crate::repositories::message::MessageRepository;
|
use crate::repositories::message::MessageRepository;
|
||||||
|
use crate::repositories::read_state::ReadStateRepository;
|
||||||
use crate::repositories::role::RoleRepository;
|
use crate::repositories::role::RoleRepository;
|
||||||
use crate::repositories::server::ServerRepository;
|
use crate::repositories::server::ServerRepository;
|
||||||
use crate::repositories::server_item_order::ServerItemOrderRepository;
|
use crate::repositories::server_item_order::ServerItemOrderRepository;
|
||||||
@@ -16,6 +17,7 @@ mod category;
|
|||||||
mod channel;
|
mod channel;
|
||||||
mod computed_permission;
|
mod computed_permission;
|
||||||
mod message;
|
mod message;
|
||||||
|
mod read_state;
|
||||||
mod role;
|
mod role;
|
||||||
mod server;
|
mod server;
|
||||||
mod server_item_order;
|
mod server_item_order;
|
||||||
@@ -35,6 +37,7 @@ pub struct Repositories {
|
|||||||
pub channel: ChannelRepository,
|
pub channel: ChannelRepository,
|
||||||
pub role: RoleRepository,
|
pub role: RoleRepository,
|
||||||
pub message: MessageRepository,
|
pub message: MessageRepository,
|
||||||
|
pub read_state: ReadStateRepository,
|
||||||
pub user: UserRepository,
|
pub user: UserRepository,
|
||||||
pub computed_permission: ComputedPermissionRepository,
|
pub computed_permission: ComputedPermissionRepository,
|
||||||
pub server_item_order: ServerItemOrderRepository,
|
pub server_item_order: ServerItemOrderRepository,
|
||||||
@@ -61,6 +64,9 @@ impl Repositories {
|
|||||||
message: MessageRepository {
|
message: MessageRepository {
|
||||||
context: context.clone(),
|
context: context.clone(),
|
||||||
},
|
},
|
||||||
|
read_state: ReadStateRepository {
|
||||||
|
context: context.clone(),
|
||||||
|
},
|
||||||
user: UserRepository {
|
user: UserRepository {
|
||||||
context: context.clone(),
|
context: context.clone(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::models::{role, role_user};
|
use crate::models::{role, role_user, user};
|
||||||
use crate::repositories::{AnyResult, RepositoryContext};
|
use crate::repositories::{AnyResult, RepositoryContext};
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -63,4 +63,49 @@ impl RoleRepository {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(res.rows_affected > 0)
|
Ok(res.rows_affected > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_members(&self, role_id: Uuid) -> AnyResult<Vec<user::Model>> {
|
||||||
|
let memberships = role_user::Entity::find()
|
||||||
|
.filter(role_user::Column::RoleId.eq(role_id))
|
||||||
|
.all(&self.context.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut members = Vec::with_capacity(memberships.len());
|
||||||
|
for membership in memberships {
|
||||||
|
if let Some(user) = user::Entity::find_by_id(membership.user_id)
|
||||||
|
.one(&self.context.db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
members.push(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(members)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_member(&self, role_id: Uuid, user_id: Uuid) -> AnyResult<bool> {
|
||||||
|
if role_user::Entity::find_by_id((role_id, user_id))
|
||||||
|
.one(&self.context.db)
|
||||||
|
.await?
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
role_user::ActiveModel {
|
||||||
|
role_id: Set(role_id),
|
||||||
|
user_id: Set(user_id),
|
||||||
|
}
|
||||||
|
.insert(&self.context.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_member(&self, role_id: Uuid, user_id: Uuid) -> AnyResult<bool> {
|
||||||
|
let result = role_user::Entity::delete_by_id((role_id, user_id))
|
||||||
|
.exec(&self.context.db)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected > 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ impl ServerRepository {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_user(&self, server_id: Uuid, user_id: Uuid) -> AnyResult<Option<server_user::Model>> {
|
||||||
|
Ok(server_user::Entity::find()
|
||||||
|
.filter(server_user::Column::ServerId.eq(server_id))
|
||||||
|
.filter(server_user::Column::UserId.eq(user_id))
|
||||||
|
.one(&self.context.db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete(&self, id: Uuid) -> AnyResult<bool> {
|
pub async fn delete(&self, id: Uuid) -> AnyResult<bool> {
|
||||||
let res = server::Entity::delete_by_id(id)
|
let res = server::Entity::delete_by_id(id)
|
||||||
.exec(&self.context.db)
|
.exec(&self.context.db)
|
||||||
@@ -108,6 +116,16 @@ impl ServerRepository {
|
|||||||
.await?)
|
.await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_permissions(
|
||||||
|
&self,
|
||||||
|
server_id: Uuid,
|
||||||
|
) -> AnyResult<Vec<server_user_permission::Model>> {
|
||||||
|
Ok(server_user_permission::Entity::find()
|
||||||
|
.filter(server_user_permission::Column::ServerId.eq(server_id))
|
||||||
|
.all(&self.context.db)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn set_user_permission(
|
pub async fn set_user_permission(
|
||||||
&self,
|
&self,
|
||||||
server_id: Uuid,
|
server_id: Uuid,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use crate::models::{category, channel, computed_permission, server_item_order};
|
use crate::models::{category, channel, channel_user_read_state, computed_permission, message, server_item_order};
|
||||||
use crate::permissions::ChannelPermission;
|
use crate::permissions::ChannelPermission;
|
||||||
use crate::repositories::types::{CategoryWithPermissions, ChannelWithPermissions, ServerTreeData};
|
use crate::repositories::types::{CategoryWithPermissions, ChannelWithPermissions, ServerTreeData};
|
||||||
use crate::repositories::{AnyResult, RepositoryContext};
|
use crate::repositories::{AnyResult, RepositoryContext};
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -70,6 +70,35 @@ impl ServerTreeRepository {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let channel_ids: Vec<Uuid> = channel_models.iter().map(|channel| channel.id).collect();
|
||||||
|
let read_states = channel_user_read_state::Entity::find()
|
||||||
|
.filter(channel_user_read_state::Column::UserId.eq(user_id))
|
||||||
|
.filter(channel_user_read_state::Column::ChannelId.is_in(channel_ids.clone()))
|
||||||
|
.all(&self.context.db)
|
||||||
|
.await?;
|
||||||
|
let cursors: HashMap<Uuid, Option<Uuid>> = read_states
|
||||||
|
.into_iter()
|
||||||
|
.map(|state| (state.channel_id, state.last_read_message_id))
|
||||||
|
.collect();
|
||||||
|
let messages = message::Entity::find()
|
||||||
|
.select_only()
|
||||||
|
.column(message::Column::ChannelId)
|
||||||
|
.column(message::Column::Id)
|
||||||
|
.filter(message::Column::ChannelId.is_in(channel_ids))
|
||||||
|
.into_tuple::<(Uuid, Uuid)>()
|
||||||
|
.all(&self.context.db)
|
||||||
|
.await?;
|
||||||
|
let mut unread_counts = HashMap::new();
|
||||||
|
for (channel_id, message_id) in messages {
|
||||||
|
let unread = match cursors.get(&channel_id) {
|
||||||
|
Some(Some(cursor)) => message_id > *cursor,
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
if unread {
|
||||||
|
*unread_counts.entry(channel_id).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let channels = channel_models
|
let channels = channel_models
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|channel| {
|
.map(|channel| {
|
||||||
@@ -87,6 +116,7 @@ impl ServerTreeRepository {
|
|||||||
orders,
|
orders,
|
||||||
categories,
|
categories,
|
||||||
channels,
|
channels,
|
||||||
|
unread_counts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub struct ServerTree {
|
|||||||
pub struct MessageFilter {
|
pub struct MessageFilter {
|
||||||
pub channel_id: Option<uuid::Uuid>,
|
pub channel_id: Option<uuid::Uuid>,
|
||||||
pub before_id: Option<uuid::Uuid>,
|
pub before_id: Option<uuid::Uuid>,
|
||||||
|
pub after_id: Option<uuid::Uuid>,
|
||||||
pub limit: Option<u64>,
|
pub limit: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,4 +76,5 @@ pub struct ServerTreeData {
|
|||||||
pub orders: Vec<server_item_order::Model>,
|
pub orders: Vec<server_item_order::Model>,
|
||||||
pub categories: Vec<CategoryWithPermissions>,
|
pub categories: Vec<CategoryWithPermissions>,
|
||||||
pub channels: Vec<ChannelWithPermissions>,
|
pub channels: Vec<ChannelWithPermissions>,
|
||||||
|
pub unread_counts: std::collections::HashMap<Uuid, u64>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
use crate::core::state::AppState;
|
use crate::core::state::AppState;
|
||||||
use crate::http::context::Superuser;
|
use crate::http::context::{CurrentUser, Superuser};
|
||||||
use crate::http::error::HTTPError;
|
use crate::http::error::HTTPError;
|
||||||
use crate::domain::dto::channel::{
|
use crate::domain::dto::channel::{
|
||||||
ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
|
ChannelQueryParams, ChannelResponse, ChannelPermissionsResponse, ChannelRolePermissionResponse,
|
||||||
ChannelUserPermissionResponse, CreateChannelRequest, SetChannelPermissionRequest,
|
ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse,
|
||||||
|
SetChannelPermissionRequest, SetReadStateRequest,
|
||||||
UpdateChannelRequest,
|
UpdateChannelRequest,
|
||||||
};
|
};
|
||||||
use crate::routes::channel::mapper;
|
use crate::routes::channel::mapper;
|
||||||
@@ -41,6 +42,91 @@ pub async fn get_all(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/channels/{channel_id}/read-state",
|
||||||
|
params(("channel_id" = Uuid, Path, description = "ID du canal")),
|
||||||
|
responses((status = 200, body = ReadStateResponse), (status = 404, description = "Canal non trouvé")),
|
||||||
|
tag = "Channels",
|
||||||
|
security(("bearerAuth" = []))
|
||||||
|
)]
|
||||||
|
pub async fn get_read_state(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(channel_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ReadStateResponse>, HTTPError> {
|
||||||
|
state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?;
|
||||||
|
let read_state = state.repositories.read_state.get(channel_id, user.id).await?;
|
||||||
|
let unread_count = state
|
||||||
|
.repositories
|
||||||
|
.read_state
|
||||||
|
.unread_counts(&[channel_id], user.id)
|
||||||
|
.await?
|
||||||
|
.get(&channel_id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(Json(ReadStateResponse {
|
||||||
|
channel_id,
|
||||||
|
last_read_message_id: read_state.as_ref().and_then(|value| value.last_read_message_id),
|
||||||
|
updated_at: read_state.map(|value| value.updated_at),
|
||||||
|
unread_count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/channels/{channel_id}/read-state",
|
||||||
|
request_body = SetReadStateRequest,
|
||||||
|
params(("channel_id" = Uuid, Path, description = "ID du canal")),
|
||||||
|
responses((status = 200, body = ReadStateResponse), (status = 400, description = "Message invalide"), (status = 404, description = "Canal non trouvé")),
|
||||||
|
tag = "Channels",
|
||||||
|
security(("bearerAuth" = []))
|
||||||
|
)]
|
||||||
|
pub async fn set_read_state(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(channel_id): Path<Uuid>,
|
||||||
|
Json(payload): Json<SetReadStateRequest>,
|
||||||
|
) -> Result<Json<ReadStateResponse>, HTTPError> {
|
||||||
|
state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?;
|
||||||
|
|
||||||
|
if let Some(message_id) = payload.last_read_message_id {
|
||||||
|
let message = state
|
||||||
|
.repositories
|
||||||
|
.message
|
||||||
|
.get_by_id(message_id)
|
||||||
|
.await?
|
||||||
|
.ok_or(HTTPError::BadRequest("Message not found".to_string()))?;
|
||||||
|
if message.channel_id != channel_id {
|
||||||
|
return Err(HTTPError::BadRequest(
|
||||||
|
"Message does not belong to this channel".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let read_state = state
|
||||||
|
.repositories
|
||||||
|
.read_state
|
||||||
|
.set(channel_id, user.id, payload.last_read_message_id)
|
||||||
|
.await?;
|
||||||
|
let unread_count = state
|
||||||
|
.repositories
|
||||||
|
.read_state
|
||||||
|
.unread_counts(&[channel_id], user.id)
|
||||||
|
.await?
|
||||||
|
.get(&channel_id)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
Ok(Json(ReadStateResponse {
|
||||||
|
channel_id,
|
||||||
|
last_read_message_id: read_state.last_read_message_id,
|
||||||
|
updated_at: Some(read_state.updated_at),
|
||||||
|
unread_count,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Récupère un channel par son ID
|
/// Récupère un channel par son ID
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -69,6 +155,26 @@ pub async fn get_by_id(
|
|||||||
Ok(Json(mapper::channel_model_to_channel_response(channel)))
|
Ok(Json(mapper::channel_model_to_channel_response(channel)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Liste les permissions directes configurées pour un canal.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/channels/{channel_id}/permissions",
|
||||||
|
params(("channel_id" = Uuid, Path, description = "ID du canal")),
|
||||||
|
responses((status = 200, body = ChannelPermissionsResponse), (status = 404, description = "Canal non trouvé")),
|
||||||
|
tag = "Channel Permissions"
|
||||||
|
)]
|
||||||
|
pub async fn list_permissions(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(channel_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ChannelPermissionsResponse>, HTTPError> {
|
||||||
|
state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?;
|
||||||
|
let (users, roles) = tokio::try_join!(
|
||||||
|
state.repositories.channel.list_user_permissions(channel_id),
|
||||||
|
state.repositories.channel.list_role_permissions(channel_id),
|
||||||
|
)?;
|
||||||
|
Ok(Json(mapper::channel_permissions_to_response(users, roles)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Crée un nouveau channel
|
/// Crée un nouveau channel
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::domain::dto::channel::{
|
use crate::domain::dto::channel::{
|
||||||
ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
|
ChannelPermissionsResponse, ChannelQueryParams, ChannelResponse, ChannelRolePermissionResponse,
|
||||||
ChannelUserPermissionResponse, CreateChannelRequest, UpdateChannelRequest,
|
ChannelUserPermissionResponse, CreateChannelRequest, UpdateChannelRequest,
|
||||||
};
|
};
|
||||||
use crate::models::{channel, channel_role_permission, channel_user_permission};
|
use crate::models::{channel, channel_role_permission, channel_user_permission};
|
||||||
@@ -23,6 +23,7 @@ pub fn channel_model_to_channel_response_with_permission(
|
|||||||
name: model.name,
|
name: model.name,
|
||||||
created_at: model.created_at,
|
created_at: model.created_at,
|
||||||
updated_at: model.updated_at,
|
updated_at: model.updated_at,
|
||||||
|
unread_count: None,
|
||||||
permission,
|
permission,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +72,16 @@ pub fn channel_role_permission_to_response(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn channel_permissions_to_response(
|
||||||
|
users: Vec<channel_user_permission::Model>,
|
||||||
|
roles: Vec<channel_role_permission::Model>,
|
||||||
|
) -> ChannelPermissionsResponse {
|
||||||
|
ChannelPermissionsResponse {
|
||||||
|
users: users.into_iter().map(channel_user_permission_to_response).collect(),
|
||||||
|
roles: roles.into_iter().map(channel_role_permission_to_response).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn query_params_to_channel_filter(params: ChannelQueryParams) -> ChannelFilter {
|
pub fn query_params_to_channel_filter(params: ChannelQueryParams) -> ChannelFilter {
|
||||||
ChannelFilter {
|
ChannelFilter {
|
||||||
server_id: params.server_id,
|
server_id: params.server_id,
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ pub fn router() -> Router<AppState> {
|
|||||||
.put(handlers::update)
|
.put(handlers::update)
|
||||||
.delete(handlers::delete),
|
.delete(handlers::delete),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/channels/{channel_id}/permissions",
|
||||||
|
get(handlers::list_permissions),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/channels/{channel_id}/permissions/users/{user_id}",
|
"/channels/{channel_id}/permissions/users/{user_id}",
|
||||||
get(handlers::get_user_permission)
|
get(handlers::get_user_permission)
|
||||||
@@ -23,4 +27,8 @@ pub fn router() -> Router<AppState> {
|
|||||||
.put(handlers::set_role_permission)
|
.put(handlers::set_role_permission)
|
||||||
.delete(handlers::remove_role_permission),
|
.delete(handlers::remove_role_permission),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/channels/{channel_id}/read-state",
|
||||||
|
get(handlers::get_read_state).put(handlers::set_read_state),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use crate::models::user::Model as User;
|
|||||||
use crate::routes::gateway::GatewayClient;
|
use crate::routes::gateway::GatewayClient;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{
|
extract::{
|
||||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
|
||||||
State,
|
State,
|
||||||
|
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||||
},
|
},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
};
|
};
|
||||||
@@ -56,7 +56,6 @@ async fn handle_socket(socket: WebSocket, state: AppState, user: User) {
|
|||||||
|
|
||||||
// Task pour recevoir les messages du WebSocket
|
// Task pour recevoir les messages du WebSocket
|
||||||
let client_clone = client.clone();
|
let client_clone = client.clone();
|
||||||
let state_clone = state.clone();
|
|
||||||
let mut recv_task = tokio::spawn(async move {
|
let mut recv_task = tokio::spawn(async move {
|
||||||
while let Some(Ok(message)) = receiver.next().await {
|
while let Some(Ok(message)) = receiver.next().await {
|
||||||
client_clone.on_message(message).await;
|
client_clone.on_message(message).await;
|
||||||
@@ -69,7 +68,7 @@ async fn handle_socket(socket: WebSocket, state: AppState, user: User) {
|
|||||||
_ = (&mut recv_task) => send_task.abort(),
|
_ = (&mut recv_task) => send_task.abort(),
|
||||||
};
|
};
|
||||||
|
|
||||||
state.gateway.remove_client(client.clone());
|
state.gateway.remove_client(&client);
|
||||||
// // Déconnexion (Disconnect)
|
// // Déconnexion (Disconnect)
|
||||||
client.on_disconnect().await;
|
client.on_disconnect().await;
|
||||||
}
|
}
|
||||||
|
|||||||
+101
-42
@@ -1,11 +1,10 @@
|
|||||||
use crate::models::category;
|
use crate::domain::events::message::{
|
||||||
use crate::models::channel;
|
MessageCreatedEvent, MessageDeletedEvent, MessageUpdatedEvent,
|
||||||
use crate::models::message;
|
};
|
||||||
use crate::models::server;
|
|
||||||
use crate::models::user::Model as User;
|
use crate::models::user::Model as User;
|
||||||
use crate::routes::category::mapper::category_model_to_category_response;
|
use crate::routes::category::mapper::category_model_to_category_response;
|
||||||
use crate::routes::channel::mapper::channel_model_to_channel_response;
|
use crate::routes::channel::mapper::channel_model_to_channel_response;
|
||||||
use crate::routes::message::mapper::message_model_to_message_response;
|
use crate::routes::message::mapper::message_model_to_message_response_with_server_id;
|
||||||
use crate::routes::server::mapper::server_model_to_server_response;
|
use crate::routes::server::mapper::server_model_to_server_response;
|
||||||
use axum::extract::ws::Message;
|
use axum::extract::ws::Message;
|
||||||
use event_bus::EventBus;
|
use event_bus::EventBus;
|
||||||
@@ -16,15 +15,22 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use crate::services::Services;
|
||||||
|
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug)]
|
||||||
pub struct GatewayManager {
|
pub struct GatewayManager {
|
||||||
// {UserID: {connection_id: GatewayClient}}
|
pub clients: RwLock<HashMap<ConnectionKey, GatewayClient>>,
|
||||||
pub clients: RwLock<HashMap<Uuid, HashMap<Uuid, GatewayClient>>>,
|
services: Arc<Services>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ConnectionKey {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub connection_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -37,19 +43,82 @@ pub struct GatewayClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl GatewayManager {
|
impl GatewayManager {
|
||||||
fn add_client(&self, gateway_client: GatewayClient) {
|
pub fn new(services: Arc<Services>) -> Self {
|
||||||
let mut clients = self.clients.write();
|
Self { clients: RwLock::new(HashMap::new()), services }
|
||||||
let user_id = gateway_client.user.id;
|
}
|
||||||
clients
|
/// Démarre les routeurs centraux des événements de messages.
|
||||||
.entry(user_id)
|
pub fn start(self: &Arc<Self>, event_bus: Arc<EventBus>) {
|
||||||
.or_insert_with(HashMap::new)
|
let manager = Arc::clone(self);
|
||||||
.insert(gateway_client.connection_id, gateway_client);
|
event_bus.on_async::<MessageCreatedEvent, _, _>("message_created", move |event| {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
async move {
|
||||||
|
manager.broadcast_message(
|
||||||
|
event.channel_id,
|
||||||
|
"add",
|
||||||
|
message_model_to_message_response_with_server_id(
|
||||||
|
event.message,
|
||||||
|
event.server_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let manager = Arc::clone(self);
|
||||||
|
event_bus.on_async::<MessageUpdatedEvent, _, _>("message_updated", move |event| {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
async move {
|
||||||
|
manager.broadcast_message(
|
||||||
|
event.channel_id,
|
||||||
|
"update",
|
||||||
|
message_model_to_message_response_with_server_id(
|
||||||
|
event.message,
|
||||||
|
event.server_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let manager = Arc::clone(self);
|
||||||
|
event_bus.on_async::<MessageDeletedEvent, _, _>("message_deleted", move |event| {
|
||||||
|
let manager = Arc::clone(&manager);
|
||||||
|
async move {
|
||||||
|
manager.broadcast_message(event.channel_id, "remove", event.message.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_client(&self, gateway_client: GatewayClient) {
|
pub(crate) fn add_client(&self, gateway_client: GatewayClient) {
|
||||||
let mut clients = self.clients.write();
|
let key = gateway_client.key();
|
||||||
if let Some(client_list) = clients.get_mut(&gateway_client.user.id) {
|
self.clients.write().insert(key, gateway_client);
|
||||||
client_list.remove(&gateway_client.connection_id);
|
}
|
||||||
|
|
||||||
|
pub(crate) fn remove_client(&self, gateway_client: &GatewayClient) {
|
||||||
|
let key = gateway_client.key();
|
||||||
|
self.clients.write().remove(&key);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fn broadcast_message<T: serde::Serialize>(
|
||||||
|
&self,
|
||||||
|
channel_id: Uuid,
|
||||||
|
action: &'static str,
|
||||||
|
content: T,
|
||||||
|
) {
|
||||||
|
let event = GatewayEvent {
|
||||||
|
namespace: "Message",
|
||||||
|
action,
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
let Ok(json) = serde_json::to_string(&event) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let users = self.services.realtime_registry.users_for_channel(channel_id);
|
||||||
|
let clients = self.clients.read();
|
||||||
|
for (key, client) in clients.iter() {
|
||||||
|
if users.contains(&key.user_id) {
|
||||||
|
let _ = client.sender.send(Message::Text(json.clone().into()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,16 +129,22 @@ impl GatewayClient {
|
|||||||
sender: mpsc::UnboundedSender<Message>,
|
sender: mpsc::UnboundedSender<Message>,
|
||||||
event_bus: Arc<EventBus>,
|
event_bus: Arc<EventBus>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let connection_id = Uuid::new_v4();
|
|
||||||
Self {
|
Self {
|
||||||
user,
|
user,
|
||||||
connection_id,
|
connection_id: Uuid::new_v4(),
|
||||||
sender,
|
sender,
|
||||||
event_bus,
|
event_bus,
|
||||||
_event_handles: Vec::new(),
|
_event_handles: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn key(&self) -> ConnectionKey {
|
||||||
|
ConnectionKey {
|
||||||
|
user_id: self.user.id,
|
||||||
|
connection_id: self.connection_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn subscribe_event<T, F, R>(
|
fn subscribe_event<T, F, R>(
|
||||||
&self,
|
&self,
|
||||||
event_name: &'static str,
|
event_name: &'static str,
|
||||||
@@ -105,22 +180,8 @@ impl GatewayClient {
|
|||||||
pub fn subscribe_to_events(&mut self) {
|
pub fn subscribe_to_events(&mut self) {
|
||||||
let mut handles = Vec::new();
|
let mut handles = Vec::new();
|
||||||
|
|
||||||
// Message
|
// Les messages sont routés par GatewayManager selon le channel_id.
|
||||||
handles.push(self.subscribe_event(
|
|
||||||
"message_created",
|
|
||||||
"Message",
|
|
||||||
"add",
|
|
||||||
message_model_to_message_response,
|
|
||||||
));
|
|
||||||
handles.push(self.subscribe_event(
|
|
||||||
"message_updated",
|
|
||||||
"Message",
|
|
||||||
"update",
|
|
||||||
message_model_to_message_response,
|
|
||||||
));
|
|
||||||
handles.push(self.subscribe_event("message_deleted", "Message", "remove", |id: Uuid| id));
|
|
||||||
|
|
||||||
// Channel
|
|
||||||
handles.push(self.subscribe_event(
|
handles.push(self.subscribe_event(
|
||||||
"channel_created",
|
"channel_created",
|
||||||
"Channel",
|
"Channel",
|
||||||
@@ -135,7 +196,6 @@ impl GatewayClient {
|
|||||||
));
|
));
|
||||||
handles.push(self.subscribe_event("channel_deleted", "Channel", "remove", |id: Uuid| id));
|
handles.push(self.subscribe_event("channel_deleted", "Channel", "remove", |id: Uuid| id));
|
||||||
|
|
||||||
// Category
|
|
||||||
handles.push(self.subscribe_event(
|
handles.push(self.subscribe_event(
|
||||||
"category_created",
|
"category_created",
|
||||||
"Category",
|
"Category",
|
||||||
@@ -150,7 +210,6 @@ impl GatewayClient {
|
|||||||
));
|
));
|
||||||
handles.push(self.subscribe_event("category_deleted", "Category", "remove", |id: Uuid| id));
|
handles.push(self.subscribe_event("category_deleted", "Category", "remove", |id: Uuid| id));
|
||||||
|
|
||||||
// Server
|
|
||||||
handles.push(self.subscribe_event(
|
handles.push(self.subscribe_event(
|
||||||
"server_created",
|
"server_created",
|
||||||
"Server",
|
"Server",
|
||||||
@@ -175,20 +234,20 @@ impl GatewayClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn on_connect(&mut self) {
|
async fn on_connect(&mut self) {
|
||||||
tracing::info!("Client connected: {:?}", self.user);
|
tracing::info!(user_id = %self.user.id, "Client connected");
|
||||||
self.subscribe_to_events();
|
self.subscribe_to_events();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_disconnect(&mut self) {
|
async fn on_disconnect(&mut self) {
|
||||||
tracing::info!("Client disconnected: {:?}", self.user);
|
tracing::info!(user_id = %self.user.id, "Client disconnected");
|
||||||
self.unsubscribe_all();
|
self.unsubscribe_all();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_message(&self, message: Message) {
|
async fn on_message(&self, message: Message) {
|
||||||
match message {
|
match message {
|
||||||
Message::Binary(content) => {}
|
Message::Binary(_) => {}
|
||||||
Message::Text(content) => {
|
Message::Text(content) => {
|
||||||
tracing::info!("Received text message: {}", content);
|
tracing::info!(user_id = %self.user.id, "Received text message: {}", content);
|
||||||
}
|
}
|
||||||
Message::Ping(_) => {}
|
Message::Ping(_) => {}
|
||||||
Message::Pong(_) => {}
|
Message::Pong(_) => {}
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
use crate::domain::dto::message::{CreateMessageRequest, MessageQueryParams, MessageResponse, UpdateMessageRequest};
|
|
||||||
use crate::core::state::AppState;
|
use crate::core::state::AppState;
|
||||||
|
use crate::domain::dto::message::{
|
||||||
|
CreateMessageRequest, MessagePageResponse, MessageQueryParams, MessageResponse,
|
||||||
|
UpdateMessageRequest,
|
||||||
|
};
|
||||||
use crate::http::context::CurrentUser;
|
use crate::http::context::CurrentUser;
|
||||||
use crate::http::error::HTTPError;
|
use crate::http::error::HTTPError;
|
||||||
use crate::routes::message::mapper;
|
use crate::routes::message::mapper;
|
||||||
use axum::{
|
use axum::{
|
||||||
|
Json,
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
Json,
|
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Liste tous les messages
|
/// Liste une fenêtre paginée de messages
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/messages",
|
path = "/messages",
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Liste des messages récupérée avec succès", body = [MessageResponse]),
|
(status = 200, description = "Fenêtre de messages récupérée avec succès", body = MessagePageResponse),
|
||||||
|
(status = 400, description = "Curseurs incompatibles ou canal manquant"),
|
||||||
(status = 500, description = "Erreur interne du serveur")
|
(status = 500, description = "Erreur interne du serveur")
|
||||||
),
|
),
|
||||||
params(
|
params(
|
||||||
@@ -26,15 +30,29 @@ use uuid::Uuid;
|
|||||||
pub async fn get_all(
|
pub async fn get_all(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(filters): Query<MessageQueryParams>,
|
Query(filters): Query<MessageQueryParams>,
|
||||||
) -> Result<Json<Vec<MessageResponse>>, HTTPError> {
|
) -> Result<Json<MessagePageResponse>, HTTPError> {
|
||||||
|
if filters.before_id.is_some() && filters.after_id.is_some() {
|
||||||
|
return Err(HTTPError::BadRequest(
|
||||||
|
"before_id and after_id cannot be used together".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let params = mapper::query_params_to_message_filter(filters);
|
let params = mapper::query_params_to_message_filter(filters);
|
||||||
let messages = state.repositories.message.filter(params).await?;
|
let page = state.repositories.message.filter(params).await?;
|
||||||
Ok(Json(
|
let oldest_id = page.messages.first().map(|message| message.id);
|
||||||
messages
|
let newest_id = page.messages.last().map(|message| message.id);
|
||||||
|
|
||||||
|
Ok(Json(MessagePageResponse {
|
||||||
|
messages: page
|
||||||
|
.messages
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(mapper::message_model_to_message_response)
|
.map(mapper::message_model_to_message_response)
|
||||||
.collect(),
|
.collect(),
|
||||||
))
|
oldest_id,
|
||||||
|
newest_id,
|
||||||
|
has_more_before: page.has_more_before,
|
||||||
|
has_more_after: page.has_more_after,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère un message par son ID
|
/// Récupère un message par son ID
|
||||||
@@ -86,7 +104,7 @@ pub async fn create(
|
|||||||
Json(payload): Json<CreateMessageRequest>,
|
Json(payload): Json<CreateMessageRequest>,
|
||||||
) -> Result<(StatusCode, Json<MessageResponse>), HTTPError> {
|
) -> Result<(StatusCode, Json<MessageResponse>), HTTPError> {
|
||||||
// Vérifier que le canal existe
|
// Vérifier que le canal existe
|
||||||
state
|
let channel = state
|
||||||
.repositories
|
.repositories
|
||||||
.channel
|
.channel
|
||||||
.get_by_id(payload.channel_id)
|
.get_by_id(payload.channel_id)
|
||||||
@@ -105,10 +123,17 @@ pub async fn create(
|
|||||||
))?;
|
))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = state.services.message.create_message(payload.channel_id, user.id, payload.content).await?;
|
let message = state
|
||||||
|
.services
|
||||||
|
.message
|
||||||
|
.create_message(payload.channel_id, user.id, payload.content)
|
||||||
|
.await?;
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(mapper::message_model_to_message_response(message)),
|
Json(mapper::message_model_to_message_response_with_server_id(
|
||||||
|
message,
|
||||||
|
channel.server_id,
|
||||||
|
)),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +175,11 @@ pub async fn update(
|
|||||||
return Err(HTTPError::Forbidden);
|
return Err(HTTPError::Forbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = state.services.message.update_message(id, payload.content).await?;
|
let message = state
|
||||||
|
.services
|
||||||
|
.message
|
||||||
|
.update_message(id, payload.content)
|
||||||
|
.await?;
|
||||||
|
|
||||||
Ok(Json(mapper::message_model_to_message_response(message)))
|
Ok(Json(mapper::message_model_to_message_response(message)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
use crate::models::message;
|
|
||||||
use crate::repositories::types::MessageFilter;
|
|
||||||
use crate::domain::dto::message::{
|
use crate::domain::dto::message::{
|
||||||
CreateMessageRequest, MessageQueryParams, MessageResponse, UpdateMessageRequest,
|
CreateMessageRequest, MessageQueryParams, MessageResponse, UpdateMessageRequest,
|
||||||
};
|
};
|
||||||
|
use crate::models::message;
|
||||||
|
use crate::repositories::types::MessageFilter;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sea_orm::Set;
|
use sea_orm::Set;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub fn message_model_to_message_response(model: message::Model) -> MessageResponse {
|
pub fn message_model_to_message_response(model: message::Model) -> MessageResponse {
|
||||||
|
message_model_to_message_response_with_server_id(model, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn message_model_to_message_response_with_server_id(
|
||||||
|
model: message::Model,
|
||||||
|
server_id: Option<Uuid>,
|
||||||
|
) -> MessageResponse {
|
||||||
MessageResponse {
|
MessageResponse {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
|
server_id,
|
||||||
channel_id: model.channel_id,
|
channel_id: model.channel_id,
|
||||||
user_id: model.user_id,
|
user_id: model.user_id,
|
||||||
content: model.content,
|
content: model.content,
|
||||||
@@ -48,8 +56,9 @@ pub fn update_request_to_am(
|
|||||||
|
|
||||||
pub fn query_params_to_message_filter(params: MessageQueryParams) -> MessageFilter {
|
pub fn query_params_to_message_filter(params: MessageQueryParams) -> MessageFilter {
|
||||||
MessageFilter {
|
MessageFilter {
|
||||||
channel_id: params.channel_id,
|
channel_id: Some(params.channel_id),
|
||||||
before_id: params.before_id,
|
before_id: params.before_id,
|
||||||
|
after_id: params.after_id,
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ use utoipa::{Modify, OpenApi};
|
|||||||
crate::domain::dto::channel::ChannelResponse,
|
crate::domain::dto::channel::ChannelResponse,
|
||||||
crate::domain::dto::channel::CreateChannelRequest,
|
crate::domain::dto::channel::CreateChannelRequest,
|
||||||
crate::domain::dto::channel::UpdateChannelRequest,
|
crate::domain::dto::channel::UpdateChannelRequest,
|
||||||
|
crate::domain::dto::channel::ReadStateResponse,
|
||||||
|
crate::domain::dto::channel::SetReadStateRequest,
|
||||||
crate::domain::dto::role::RoleResponse,
|
crate::domain::dto::role::RoleResponse,
|
||||||
crate::domain::dto::role::CreateRoleRequest,
|
crate::domain::dto::role::CreateRoleRequest,
|
||||||
crate::domain::dto::role::UpdateRoleRequest,
|
crate::domain::dto::role::UpdateRoleRequest,
|
||||||
|
|||||||
+97
-128
@@ -1,162 +1,131 @@
|
|||||||
use crate::core::state::AppState;
|
use crate::core::state::AppState;
|
||||||
use crate::http::context::Superuser;
|
use crate::domain::dto::role::{CreateRoleRequest, RoleQueryParams, RoleResponse, UpdateRoleRequest};
|
||||||
|
use crate::domain::dto::user::UserResponse;
|
||||||
|
use crate::http::context::CurrentUser;
|
||||||
use crate::http::error::HTTPError;
|
use crate::http::error::HTTPError;
|
||||||
use crate::domain::dto::role::{CreateRoleRequest, RoleResponse, UpdateRoleRequest};
|
use crate::permissions::ServerPermission;
|
||||||
use crate::routes::role::mapper;
|
use crate::routes::role::mapper;
|
||||||
use axum::{
|
use crate::routes::user::mapper as user_mapper;
|
||||||
Json,
|
use axum::{Json, extract::{Path, Query, State}, http::StatusCode};
|
||||||
extract::{Path, State},
|
|
||||||
http::StatusCode,
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Liste tous les groupes
|
async fn require_permission(
|
||||||
#[utoipa::path(
|
state: &AppState,
|
||||||
get,
|
user: &CurrentUser,
|
||||||
path = "/groups",
|
server_id: Uuid,
|
||||||
responses(
|
permission: ServerPermission,
|
||||||
(status = 200, description = "Liste des groupes récupérée avec succès", body = [RoleResponse]),
|
) -> Result<(), HTTPError> {
|
||||||
(status = 500, description = "Erreur interne du serveur")
|
if user.is_superuser {
|
||||||
),
|
return Ok(());
|
||||||
tag = "Roles"
|
}
|
||||||
)]
|
|
||||||
pub async fn get_all(State(state): State<AppState>) -> Result<Json<Vec<RoleResponse>>, HTTPError> {
|
let granted = state
|
||||||
let groups = state.repositories.role.get_all().await?;
|
.repositories
|
||||||
Ok(Json(
|
.server
|
||||||
groups
|
.get_user_permission(server_id, user.id)
|
||||||
.into_iter()
|
.await?
|
||||||
.map(mapper::group_model_to_group_response)
|
.map(|value| ServerPermission::from_bits_truncate(value.permissions as u64))
|
||||||
.collect(),
|
.unwrap_or_default();
|
||||||
))
|
|
||||||
|
if granted.contains(permission) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(HTTPError::Forbidden)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Récupère un groupe par son ID
|
#[utoipa::path(get, path = "/roles", params(RoleQueryParams), responses((status = 200, body = [RoleResponse])), tag = "Roles")]
|
||||||
#[utoipa::path(
|
pub async fn get_all(
|
||||||
get,
|
State(state): State<AppState>,
|
||||||
path = "/groups/{id}",
|
Query(filters): Query<RoleQueryParams>,
|
||||||
responses(
|
) -> Result<Json<Vec<RoleResponse>>, HTTPError> {
|
||||||
(status = 200, description = "Rolee trouvé", body = RoleResponse),
|
let roles = match filters.server_id {
|
||||||
(status = 404, description = "Rolee non trouvé"),
|
Some(server_id) => state.repositories.role.get_all_by_server(server_id).await?,
|
||||||
(status = 500, description = "Erreur interne du serveur")
|
None => state.repositories.role.get_all().await?,
|
||||||
),
|
};
|
||||||
params(
|
|
||||||
("id" = Uuid, Path, description = "ID du groupe")
|
Ok(Json(roles.into_iter().map(mapper::role_model_to_role_response).collect()))
|
||||||
),
|
}
|
||||||
tag = "Roles"
|
|
||||||
)]
|
#[utoipa::path(get, path = "/roles/{id}", params(("id" = Uuid, Path)), responses((status = 200, body = RoleResponse), (status = 404)), tag = "Roles")]
|
||||||
pub async fn get_by_id(
|
pub async fn get_by_id(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<Json<RoleResponse>, HTTPError> {
|
) -> Result<Json<RoleResponse>, HTTPError> {
|
||||||
let group = state
|
let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
|
||||||
.repositories
|
Ok(Json(mapper::role_model_to_role_response(role)))
|
||||||
.role
|
|
||||||
.get_by_id(id)
|
|
||||||
.await?
|
|
||||||
.ok_or(HTTPError::NotFound)?;
|
|
||||||
|
|
||||||
Ok(Json(mapper::group_model_to_group_response(group)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Crée un nouveau groupe
|
#[utoipa::path(post, path = "/roles", request_body = CreateRoleRequest, responses((status = 201, body = RoleResponse)), tag = "Roles", security(("bearerAuth" = [])))]
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/groups",
|
|
||||||
request_body = CreateRoleRequest,
|
|
||||||
responses(
|
|
||||||
(status = 201, description = "Role créé avec succès", body = RoleResponse),
|
|
||||||
(status = 404, description = "Serveur non trouvé"),
|
|
||||||
(status = 500, description = "Erreur interne du serveur")
|
|
||||||
),
|
|
||||||
tag = "Roles",
|
|
||||||
security(
|
|
||||||
("bearerAuth" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn create(
|
pub async fn create(
|
||||||
_admin: Superuser,
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<CreateRoleRequest>,
|
Json(payload): Json<CreateRoleRequest>,
|
||||||
) -> Result<(StatusCode, Json<RoleResponse>), HTTPError> {
|
) -> Result<(StatusCode, Json<RoleResponse>), HTTPError> {
|
||||||
// Vérifier que le serveur existe
|
state.repositories.server.get_by_id(payload.server_id).await?
|
||||||
state
|
|
||||||
.repositories
|
|
||||||
.server
|
|
||||||
.get_by_id(payload.server_id)
|
|
||||||
.await?
|
|
||||||
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
|
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
|
||||||
|
require_permission(&state, &user, payload.server_id, ServerPermission::MANAGE_ROLES).await?;
|
||||||
|
|
||||||
let active_model = mapper::create_request_to_am(payload);
|
let role = state.services.role.create_role(mapper::create_request_to_am(payload)).await?;
|
||||||
let group = state.services.role.create_role(active_model).await?;
|
Ok((StatusCode::CREATED, Json(mapper::role_model_to_role_response(role))))
|
||||||
Ok((
|
|
||||||
StatusCode::CREATED,
|
|
||||||
Json(mapper::group_model_to_group_response(group)),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Met à jour un groupe existant
|
#[utoipa::path(put, path = "/roles/{id}", request_body = UpdateRoleRequest, responses((status = 200, body = RoleResponse), (status = 404)), tag = "Roles", security(("bearerAuth" = [])))]
|
||||||
#[utoipa::path(
|
|
||||||
put,
|
|
||||||
path = "/groups/{id}",
|
|
||||||
request_body = UpdateRoleRequest,
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Role mis à jour avec succès", body = RoleResponse),
|
|
||||||
(status = 404, description = "Role non trouvé"),
|
|
||||||
(status = 500, description = "Erreur interne du serveur")
|
|
||||||
),
|
|
||||||
params(
|
|
||||||
("id" = Uuid, Path, description = "ID du groupe")
|
|
||||||
),
|
|
||||||
tag = "Roles",
|
|
||||||
security(
|
|
||||||
("bearerAuth" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn update(
|
pub async fn update(
|
||||||
_admin: Superuser,
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(payload): Json<UpdateRoleRequest>,
|
Json(payload): Json<UpdateRoleRequest>,
|
||||||
) -> Result<Json<RoleResponse>, HTTPError> {
|
) -> Result<Json<RoleResponse>, HTTPError> {
|
||||||
// Vérifier l'existence
|
let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
|
||||||
let group = state
|
require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_ROLES).await?;
|
||||||
.repositories
|
|
||||||
.role
|
|
||||||
.get_by_id(id)
|
|
||||||
.await?
|
|
||||||
.ok_or(HTTPError::NotFound)?;
|
|
||||||
|
|
||||||
let active_model = mapper::update_request_to_am(group.id, group.server_id, payload);
|
let role = state.services.role.update_role(mapper::update_request_to_am(role.id, role.server_id, payload)).await?;
|
||||||
let group = state.services.role.update_role(active_model).await?;
|
Ok(Json(mapper::role_model_to_role_response(role)))
|
||||||
|
|
||||||
Ok(Json(mapper::group_model_to_group_response(group)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Supprime un groupe
|
#[utoipa::path(delete, path = "/roles/{id}", responses((status = 204), (status = 404)), tag = "Roles", security(("bearerAuth" = [])))]
|
||||||
#[utoipa::path(
|
|
||||||
delete,
|
|
||||||
path = "/groups/{id}",
|
|
||||||
responses(
|
|
||||||
(status = 204, description = "Role supprimé avec succès"),
|
|
||||||
(status = 404, description = "Role non trouvé"),
|
|
||||||
(status = 500, description = "Erreur interne du serveur")
|
|
||||||
),
|
|
||||||
params(
|
|
||||||
("id" = Uuid, Path, description = "ID du groupe")
|
|
||||||
),
|
|
||||||
tag = "Roles",
|
|
||||||
security(
|
|
||||||
("bearerAuth" = [])
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn delete(
|
pub async fn delete(
|
||||||
_admin: Superuser,
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<StatusCode, HTTPError> {
|
) -> Result<StatusCode, HTTPError> {
|
||||||
if state.services.role.delete_role(id).await? {
|
let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_ROLES).await?;
|
||||||
} else {
|
|
||||||
Err(HTTPError::NotFound)
|
if state.services.role.delete_role(id).await? { Ok(StatusCode::NO_CONTENT) } else { Err(HTTPError::NotFound) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_members(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<UserResponse>>, HTTPError> {
|
||||||
|
let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
|
||||||
|
require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_MEMBERS).await?;
|
||||||
|
let members = state.repositories.role.get_members(id).await?;
|
||||||
|
Ok(Json(members.into_iter().map(user_mapper::user_model_to_user_response).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_member(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((id, user_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<StatusCode, HTTPError> {
|
||||||
|
let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
|
||||||
|
require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_MEMBERS).await?;
|
||||||
|
state.repositories.server.get_user(role.server_id, user_id).await?
|
||||||
|
.ok_or(HTTPError::BadRequest("User is not a member of this server".to_string()))?;
|
||||||
|
state.services.role.add_member(id, user_id, role.server_id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_member(
|
||||||
|
user: CurrentUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path((id, user_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<StatusCode, HTTPError> {
|
||||||
|
let role = state.repositories.role.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
|
||||||
|
require_permission(&state, &user, role.server_id, ServerPermission::MANAGE_MEMBERS).await?;
|
||||||
|
if state.services.role.remove_member(id, user_id, role.server_id).await? { Ok(StatusCode::NO_CONTENT) } else { Err(HTTPError::NotFound) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use crate::domain::dto::role::{CreateRoleRequest, RoleResponse, UpdateRoleReques
|
|||||||
use sea_orm::Set;
|
use sea_orm::Set;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub fn group_model_to_group_response(model: role::Model) -> RoleResponse {
|
pub fn role_model_to_role_response(model: role::Model) -> RoleResponse {
|
||||||
RoleResponse {
|
RoleResponse {
|
||||||
id: model.id,
|
id: model.id,
|
||||||
server_id: model.server_id,
|
server_id: model.server_id,
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
use crate::core::state::AppState;
|
use crate::core::state::AppState;
|
||||||
use axum::{routing::get, Router};
|
use axum::{routing::{get, put}, Router};
|
||||||
|
|
||||||
use super::handlers;
|
use super::handlers;
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/groups", get(handlers::get_all).post(handlers::create))
|
.route("/roles", get(handlers::get_all).post(handlers::create))
|
||||||
.route(
|
.route(
|
||||||
"/groups/{id}",
|
"/roles/{id}",
|
||||||
get(handlers::get_by_id)
|
get(handlers::get_by_id)
|
||||||
.put(handlers::update)
|
.put(handlers::update)
|
||||||
.delete(handlers::delete),
|
.delete(handlers::delete),
|
||||||
)
|
)
|
||||||
|
.route("/roles/{id}/members", get(handlers::get_members))
|
||||||
|
.route("/roles/{id}/members/{user_id}", put(handlers::add_member).delete(handlers::remove_member))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use crate::domain::dto::server::{
|
|||||||
};
|
};
|
||||||
use crate::http::context::{CurrentUser, Superuser};
|
use crate::http::context::{CurrentUser, Superuser};
|
||||||
use crate::http::error::HTTPError;
|
use crate::http::error::HTTPError;
|
||||||
|
use crate::permissions::ServerPermission;
|
||||||
use crate::routes::server::mapper;
|
use crate::routes::server::mapper;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
@@ -13,6 +14,27 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
async fn require_server_permission(
|
||||||
|
state: &AppState,
|
||||||
|
user: &CurrentUser,
|
||||||
|
server_id: Uuid,
|
||||||
|
permission: ServerPermission,
|
||||||
|
) -> Result<(), HTTPError> {
|
||||||
|
if user.is_superuser {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let granted = state
|
||||||
|
.repositories
|
||||||
|
.server
|
||||||
|
.get_user_permission(server_id, user.id)
|
||||||
|
.await?
|
||||||
|
.map(|value| ServerPermission::from_bits_truncate(value.permissions as u64))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if granted.contains(permission) { Ok(()) } else { Err(HTTPError::Forbidden) }
|
||||||
|
}
|
||||||
|
|
||||||
/// Liste tous les serveurs
|
/// Liste tous les serveurs
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
@@ -21,16 +43,29 @@ use uuid::Uuid;
|
|||||||
(status = 200, description = "Liste des serveurs récupérée avec succès", body = [ServerResponse]),
|
(status = 200, description = "Liste des serveurs récupérée avec succès", body = [ServerResponse]),
|
||||||
(status = 500, description = "Erreur interne du serveur")
|
(status = 500, description = "Erreur interne du serveur")
|
||||||
),
|
),
|
||||||
|
security(("bearerAuth" = [])),
|
||||||
tag = "Servers"
|
tag = "Servers"
|
||||||
)]
|
)]
|
||||||
pub async fn get_all(
|
pub async fn get_all(
|
||||||
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
) -> Result<Json<Vec<ServerResponse>>, HTTPError> {
|
) -> Result<Json<Vec<ServerResponse>>, HTTPError> {
|
||||||
let servers = state.repositories.server.get_all().await?;
|
let servers = state.repositories.server.get_all().await?;
|
||||||
|
let unread_counts = state
|
||||||
|
.repositories
|
||||||
|
.read_state
|
||||||
|
.unread_counts_by_server(user.id)
|
||||||
|
.await?;
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
servers
|
servers
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(mapper::server_model_to_server_response)
|
.map(|server| {
|
||||||
|
let server_id = server.id;
|
||||||
|
mapper::server_model_to_server_response_with_unread_count(
|
||||||
|
server,
|
||||||
|
unread_counts.get(&server_id).copied().unwrap_or(0),
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -112,7 +147,7 @@ pub async fn create(
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn update(
|
pub async fn update(
|
||||||
_admin: Superuser,
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(payload): Json<UpdateServerRequest>,
|
Json(payload): Json<UpdateServerRequest>,
|
||||||
@@ -125,6 +160,8 @@ pub async fn update(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or(HTTPError::NotFound)?;
|
.ok_or(HTTPError::NotFound)?;
|
||||||
|
|
||||||
|
require_server_permission(&state, &user, id, ServerPermission::MANAGE_SERVER).await?;
|
||||||
|
|
||||||
let server = state
|
let server = state
|
||||||
.services
|
.services
|
||||||
.server
|
.server
|
||||||
@@ -192,6 +229,23 @@ pub async fn get_user_permission(
|
|||||||
Ok(Json(mapper::server_user_permission_to_response(permission)))
|
Ok(Json(mapper::server_user_permission_to_response(permission)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Liste les permissions directes des utilisateurs d'un serveur.
|
||||||
|
#[utoipa::path(
|
||||||
|
get,
|
||||||
|
path = "/servers/{server_id}/permissions/users",
|
||||||
|
params(("server_id" = Uuid, Path, description = "ID du serveur")),
|
||||||
|
responses((status = 200, body = [ServerUserPermissionResponse])),
|
||||||
|
tag = "Server Permissions"
|
||||||
|
)]
|
||||||
|
pub async fn list_user_permissions(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(server_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<Vec<ServerUserPermissionResponse>>, HTTPError> {
|
||||||
|
state.repositories.server.get_by_id(server_id).await?.ok_or(HTTPError::NotFound)?;
|
||||||
|
let permissions = state.repositories.server.get_user_permissions(server_id).await?;
|
||||||
|
Ok(Json(permissions.into_iter().map(mapper::server_user_permission_to_response).collect()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Définit ou remplace les permissions directes d'un utilisateur sur un serveur.
|
/// Définit ou remplace les permissions directes d'un utilisateur sur un serveur.
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
put,
|
put,
|
||||||
@@ -208,10 +262,14 @@ pub async fn get_user_permission(
|
|||||||
tag = "Server Permissions"
|
tag = "Server Permissions"
|
||||||
)]
|
)]
|
||||||
pub async fn set_user_permission(
|
pub async fn set_user_permission(
|
||||||
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((server_id, user_id)): Path<(Uuid, Uuid)>,
|
Path((server_id, user_id)): Path<(Uuid, Uuid)>,
|
||||||
Json(payload): Json<SetServerPermissionRequest>,
|
Json(payload): Json<SetServerPermissionRequest>,
|
||||||
) -> Result<Json<ServerUserPermissionResponse>, HTTPError> {
|
) -> Result<Json<ServerUserPermissionResponse>, HTTPError> {
|
||||||
|
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?;
|
||||||
|
state.repositories.server.get_user(server_id, user_id).await?
|
||||||
|
.ok_or(HTTPError::BadRequest("User is not a member of this server".to_string()))?;
|
||||||
state
|
state
|
||||||
.repositories
|
.repositories
|
||||||
.server
|
.server
|
||||||
@@ -244,9 +302,11 @@ pub async fn set_user_permission(
|
|||||||
tag = "Server Permissions"
|
tag = "Server Permissions"
|
||||||
)]
|
)]
|
||||||
pub async fn remove_user_permission(
|
pub async fn remove_user_permission(
|
||||||
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((server_id, user_id)): Path<(Uuid, Uuid)>,
|
Path((server_id, user_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> Result<StatusCode, HTTPError> {
|
) -> Result<StatusCode, HTTPError> {
|
||||||
|
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?;
|
||||||
if state
|
if state
|
||||||
.repositories
|
.repositories
|
||||||
.server
|
.server
|
||||||
@@ -311,10 +371,12 @@ pub async fn get_role_permission(
|
|||||||
tag = "Server Permissions"
|
tag = "Server Permissions"
|
||||||
)]
|
)]
|
||||||
pub async fn set_role_permission(
|
pub async fn set_role_permission(
|
||||||
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((server_id, role_id)): Path<(Uuid, Uuid)>,
|
Path((server_id, role_id)): Path<(Uuid, Uuid)>,
|
||||||
Json(payload): Json<SetServerPermissionRequest>,
|
Json(payload): Json<SetServerPermissionRequest>,
|
||||||
) -> Result<Json<ServerRolePermissionResponse>, HTTPError> {
|
) -> Result<Json<ServerRolePermissionResponse>, HTTPError> {
|
||||||
|
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_ROLES).await?;
|
||||||
state
|
state
|
||||||
.repositories
|
.repositories
|
||||||
.server
|
.server
|
||||||
@@ -347,9 +409,11 @@ pub async fn set_role_permission(
|
|||||||
tag = "Server Permissions"
|
tag = "Server Permissions"
|
||||||
)]
|
)]
|
||||||
pub async fn remove_role_permission(
|
pub async fn remove_role_permission(
|
||||||
|
user: CurrentUser,
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path((server_id, role_id)): Path<(Uuid, Uuid)>,
|
Path((server_id, role_id)): Path<(Uuid, Uuid)>,
|
||||||
) -> Result<StatusCode, HTTPError> {
|
) -> Result<StatusCode, HTTPError> {
|
||||||
|
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_ROLES).await?;
|
||||||
if state
|
if state
|
||||||
.repositories
|
.repositories
|
||||||
.server
|
.server
|
||||||
@@ -405,5 +469,6 @@ pub async fn get_tree(
|
|||||||
tree.orders,
|
tree.orders,
|
||||||
tree.channels,
|
tree.channels,
|
||||||
tree.categories,
|
tree.categories,
|
||||||
|
tree.unread_counts,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,9 +17,19 @@ pub fn server_model_to_server_response(model: server::Model) -> ServerResponse {
|
|||||||
is_default: model.is_default,
|
is_default: model.is_default,
|
||||||
created_at: model.created_at,
|
created_at: model.created_at,
|
||||||
updated_at: model.updated_at,
|
updated_at: model.updated_at,
|
||||||
|
unread_count: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn server_model_to_server_response_with_unread_count(
|
||||||
|
model: server::Model,
|
||||||
|
unread_count: u64,
|
||||||
|
) -> ServerResponse {
|
||||||
|
let mut response = server_model_to_server_response(model);
|
||||||
|
response.unread_count = Some(unread_count);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_request_to_am(req: CreateServerRequest) -> server::ActiveModel {
|
pub fn create_request_to_am(req: CreateServerRequest) -> server::ActiveModel {
|
||||||
server::ActiveModel {
|
server::ActiveModel {
|
||||||
id: Set(Uuid::new_v4()),
|
id: Set(Uuid::new_v4()),
|
||||||
@@ -66,6 +76,7 @@ pub fn build_server_tree(
|
|||||||
orders: Vec<server_item_order::Model>,
|
orders: Vec<server_item_order::Model>,
|
||||||
channels: Vec<ChannelWithPermissions>,
|
channels: Vec<ChannelWithPermissions>,
|
||||||
categories: Vec<CategoryWithPermissions>,
|
categories: Vec<CategoryWithPermissions>,
|
||||||
|
unread_counts: HashMap<Uuid, u64>,
|
||||||
) -> ServerTreeResponse {
|
) -> ServerTreeResponse {
|
||||||
let order_map: HashMap<(Option<Uuid>, Uuid), i64> = orders
|
let order_map: HashMap<(Option<Uuid>, Uuid), i64> = orders
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -119,7 +130,9 @@ pub fn build_server_tree(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|c| {
|
.map(|c| {
|
||||||
let chan_perm_bits = c.permissions.map(|p| p.bits()).unwrap_or(0);
|
let chan_perm_bits = c.permissions.map(|p| p.bits()).unwrap_or(0);
|
||||||
channel_model_to_channel_response_with_permission(c.channel, Some(chan_perm_bits))
|
let mut response = channel_model_to_channel_response_with_permission(c.channel, Some(chan_perm_bits));
|
||||||
|
response.unread_count = Some(*unread_counts.get(&response.id).unwrap_or(&0));
|
||||||
|
response
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -135,10 +148,11 @@ pub fn build_server_tree(
|
|||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(i64::MAX);
|
.unwrap_or(i64::MAX);
|
||||||
let chan_perm_bits = chan_with_perm.permissions.map(|p| p.bits()).unwrap_or(0);
|
let chan_perm_bits = chan_with_perm.permissions.map(|p| p.bits()).unwrap_or(0);
|
||||||
let chan_response = channel_model_to_channel_response_with_permission(
|
let mut chan_response = channel_model_to_channel_response_with_permission(
|
||||||
chan_with_perm.channel,
|
chan_with_perm.channel,
|
||||||
Some(chan_perm_bits),
|
Some(chan_perm_bits),
|
||||||
);
|
);
|
||||||
|
chan_response.unread_count = Some(*unread_counts.get(&chan_response.id).unwrap_or(&0));
|
||||||
root_items.push((
|
root_items.push((
|
||||||
ServerExplorerItemResponse::Channel(chan_response),
|
ServerExplorerItemResponse::Channel(chan_response),
|
||||||
order_key,
|
order_key,
|
||||||
@@ -232,6 +246,7 @@ mod tests {
|
|||||||
channel(second_id, Some(category_id), "first"),
|
channel(second_id, Some(category_id), "first"),
|
||||||
],
|
],
|
||||||
vec![category],
|
vec![category],
|
||||||
|
HashMap::new(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let ServerExplorerItemResponse::Category(_, channels) = &response.items[0] else {
|
let ServerExplorerItemResponse::Category(_, channels) = &response.items[0] else {
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ pub fn router() -> Router<AppState> {
|
|||||||
.delete(handlers::delete),
|
.delete(handlers::delete),
|
||||||
)
|
)
|
||||||
.route("/servers/{server_id}/tree", get(handlers::get_tree))
|
.route("/servers/{server_id}/tree", get(handlers::get_tree))
|
||||||
|
.route(
|
||||||
|
"/servers/{server_id}/permissions/users",
|
||||||
|
get(handlers::list_user_permissions),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/servers/{server_id}/permissions/users/{user_id}",
|
"/servers/{server_id}/permissions/users/{user_id}",
|
||||||
get(handlers::get_user_permission)
|
get(handlers::get_user_permission)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
use crate::core::state::AppState;
|
use crate::core::state::AppState;
|
||||||
|
use crate::domain::dto::user::{
|
||||||
|
CreateUserRequest, UpdateUserRequest, UserQueryParams, UserResponse,
|
||||||
|
};
|
||||||
use crate::http::context::Superuser;
|
use crate::http::context::Superuser;
|
||||||
use crate::http::error::HTTPError;
|
use crate::http::error::HTTPError;
|
||||||
use crate::domain::dto::user::{CreateUserRequest, UpdateUserRequest, UserResponse};
|
|
||||||
use crate::routes::user::mapper;
|
use crate::routes::user::mapper;
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -14,10 +16,10 @@ use uuid::Uuid;
|
|||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
get,
|
get,
|
||||||
path = "/users",
|
path = "/users",
|
||||||
|
params(UserQueryParams),
|
||||||
responses(
|
responses(
|
||||||
(status = 200, description = "Liste des utilisateurs récupérée avec succès", body = [UserResponse]),
|
(status = 200, description = "Liste des utilisateurs récupérée avec succès", body = [UserResponse]),
|
||||||
(status = 401, description = "Non autorisé"),
|
(status = 401, description = "Non autorisé"),
|
||||||
(status = 403, description = "Interdit"),
|
|
||||||
(status = 500, description = "Erreur interne du serveur")
|
(status = 500, description = "Erreur interne du serveur")
|
||||||
),
|
),
|
||||||
tag = "Users",
|
tag = "Users",
|
||||||
@@ -26,10 +28,16 @@ use uuid::Uuid;
|
|||||||
)
|
)
|
||||||
)]
|
)]
|
||||||
pub async fn get_all(
|
pub async fn get_all(
|
||||||
_admin: Superuser,
|
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
Query(filters): Query<UserQueryParams>,
|
||||||
) -> Result<Json<Vec<UserResponse>>, HTTPError> {
|
) -> Result<Json<Vec<UserResponse>>, HTTPError> {
|
||||||
let users = state.repositories.user.get_all().await?;
|
let users = state
|
||||||
|
.repositories
|
||||||
|
.user
|
||||||
|
.filter(crate::repositories::types::UserFilter {
|
||||||
|
server_id: filters.server_id,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
Ok(Json(
|
Ok(Json(
|
||||||
users
|
users
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
+62
-8
@@ -1,7 +1,10 @@
|
|||||||
use crate::services::ServicesContext;
|
use crate::domain::events::message::{
|
||||||
|
MessageCreatedEvent, MessageDeletedEvent, MessageUpdatedEvent,
|
||||||
|
};
|
||||||
use crate::models::{channel, message};
|
use crate::models::{channel, message};
|
||||||
|
use crate::services::ServicesContext;
|
||||||
use event_bus::Scope;
|
use event_bus::Scope;
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, TransactionTrait, Set};
|
use sea_orm::{ActiveModelTrait, EntityTrait, QuerySelect, Set, TransactionTrait};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -51,7 +54,15 @@ impl MessageService {
|
|||||||
scopes.push(Scope::uuid("server", server_id));
|
scopes.push(Scope::uuid("server", server_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
event_bus.emit_scoped("message_created", scopes, msg.clone());
|
event_bus.emit_scoped(
|
||||||
|
"message_created",
|
||||||
|
scopes,
|
||||||
|
MessageCreatedEvent {
|
||||||
|
server_id,
|
||||||
|
channel_id: msg.channel_id,
|
||||||
|
message: msg.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Ok(msg)
|
Ok(msg)
|
||||||
}
|
}
|
||||||
@@ -78,7 +89,27 @@ impl MessageService {
|
|||||||
|
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
event_bus.emit("message_updated", msg.clone());
|
let server_id = channel::Entity::find_by_id(msg.channel_id)
|
||||||
|
.select_only()
|
||||||
|
.column(channel::Column::ServerId)
|
||||||
|
.into_tuple::<Option<Uuid>>()
|
||||||
|
.one(db)
|
||||||
|
.await?
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let mut scopes = vec![Scope::uuid("channel", msg.channel_id)];
|
||||||
|
if let Some(server_id) = server_id {
|
||||||
|
scopes.push(Scope::uuid("server", server_id));
|
||||||
|
}
|
||||||
|
event_bus.emit_scoped(
|
||||||
|
"message_updated",
|
||||||
|
scopes,
|
||||||
|
MessageUpdatedEvent {
|
||||||
|
server_id,
|
||||||
|
channel_id: msg.channel_id,
|
||||||
|
message: msg.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Ok(msg)
|
Ok(msg)
|
||||||
}
|
}
|
||||||
@@ -89,16 +120,39 @@ impl MessageService {
|
|||||||
|
|
||||||
let txn = db.begin().await?;
|
let txn = db.begin().await?;
|
||||||
|
|
||||||
let res = message::Entity::delete_by_id(id)
|
let existing = message::Entity::find_by_id(id).one(db).await?;
|
||||||
.exec(&txn)
|
let Some(existing) = existing else {
|
||||||
.await?;
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let server_id = channel::Entity::find_by_id(existing.channel_id)
|
||||||
|
.select_only()
|
||||||
|
.column(channel::Column::ServerId)
|
||||||
|
.into_tuple::<Option<Uuid>>()
|
||||||
|
.one(db)
|
||||||
|
.await?
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let res = message::Entity::delete_by_id(id).exec(&txn).await?;
|
||||||
|
|
||||||
let deleted = res.rows_affected > 0;
|
let deleted = res.rows_affected > 0;
|
||||||
|
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
if deleted {
|
if deleted {
|
||||||
event_bus.emit("message_deleted", id);
|
let mut scopes = vec![Scope::uuid("channel", existing.channel_id)];
|
||||||
|
if let Some(server_id) = server_id {
|
||||||
|
scopes.push(Scope::uuid("server", server_id));
|
||||||
|
}
|
||||||
|
event_bus.emit_scoped(
|
||||||
|
"message_deleted",
|
||||||
|
scopes,
|
||||||
|
MessageDeletedEvent {
|
||||||
|
server_id,
|
||||||
|
channel_id: existing.channel_id,
|
||||||
|
message: existing,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(deleted)
|
Ok(deleted)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub mod channel;
|
|||||||
pub mod message;
|
pub mod message;
|
||||||
mod permission;
|
mod permission;
|
||||||
pub mod permission_sync;
|
pub mod permission_sync;
|
||||||
|
pub mod realtime_registry;
|
||||||
pub mod role;
|
pub mod role;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
mod server_order;
|
mod server_order;
|
||||||
@@ -30,6 +31,7 @@ pub struct ServicesContext {
|
|||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Services {
|
pub struct Services {
|
||||||
|
pub realtime_registry: Arc<realtime_registry::RealtimeRegistry>,
|
||||||
pub permission_sync: Arc<PermissionSyncService>,
|
pub permission_sync: Arc<PermissionSyncService>,
|
||||||
pub server_order: Arc<ServerOrderService>,
|
pub server_order: Arc<ServerOrderService>,
|
||||||
pub channel: Arc<ChannelService>,
|
pub channel: Arc<ChannelService>,
|
||||||
@@ -49,6 +51,7 @@ impl Services {
|
|||||||
services: OnceLock::new(),
|
services: OnceLock::new(),
|
||||||
});
|
});
|
||||||
let permission_sync = Arc::new(PermissionSyncService::new(service_context.clone()));
|
let permission_sync = Arc::new(PermissionSyncService::new(service_context.clone()));
|
||||||
|
let realtime_registry = Arc::new(realtime_registry::RealtimeRegistry::default());
|
||||||
let server_order = Arc::new(ServerOrderService::new(service_context.clone()));
|
let server_order = Arc::new(ServerOrderService::new(service_context.clone()));
|
||||||
let channel = Arc::new(ChannelService::new(service_context.clone()));
|
let channel = Arc::new(ChannelService::new(service_context.clone()));
|
||||||
let server = Arc::new(ServerService::new(service_context.clone()));
|
let server = Arc::new(ServerService::new(service_context.clone()));
|
||||||
@@ -59,6 +62,7 @@ impl Services {
|
|||||||
let permission = Arc::new(PermissionService::new(service_context.clone()));
|
let permission = Arc::new(PermissionService::new(service_context.clone()));
|
||||||
|
|
||||||
let services = Self {
|
let services = Self {
|
||||||
|
realtime_registry,
|
||||||
permission_sync,
|
permission_sync,
|
||||||
server_order,
|
server_order,
|
||||||
channel,
|
channel,
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ impl PermissionSyncService {
|
|||||||
/// Enregistre les listeners sur l'EventBus pour mettre à jour le cache
|
/// Enregistre les listeners sur l'EventBus pour mettre à jour le cache
|
||||||
/// des permissions calculées lors des modifications de structure ou de droits.
|
/// des permissions calculées lors des modifications de structure ou de droits.
|
||||||
pub async fn start_listen_event(&self) {
|
pub async fn start_listen_event(&self) {
|
||||||
let event_bus = self.service_context.event_bus.clone();
|
|
||||||
let repositories = self.service_context.repositories.clone();
|
let repositories = self.service_context.repositories.clone();
|
||||||
|
let event_bus = self.service_context.event_bus.clone();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Événements Serveur & Membres Serveur
|
// Événements Serveur & Membres Serveur
|
||||||
|
|||||||
@@ -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(®istry);
|
||||||
|
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(®istry);
|
||||||
|
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
@@ -1,6 +1,6 @@
|
|||||||
use crate::services::ServicesContext;
|
use crate::services::ServicesContext;
|
||||||
use crate::models::role;
|
use crate::models::role;
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, TransactionTrait, Set};
|
use sea_orm::{ActiveModelTrait, EntityTrait, TransactionTrait};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -23,13 +23,13 @@ impl RoleService {
|
|||||||
|
|
||||||
let txn = db.begin().await?;
|
let txn = db.begin().await?;
|
||||||
|
|
||||||
let group = active.insert(&txn).await?;
|
let role = active.insert(&txn).await?;
|
||||||
|
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
event_bus.emit("group_created", group.clone());
|
event_bus.emit("role_created", role.clone());
|
||||||
|
|
||||||
Ok(group)
|
Ok(role)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_role(
|
pub async fn update_role(
|
||||||
@@ -41,13 +41,13 @@ impl RoleService {
|
|||||||
|
|
||||||
let txn = db.begin().await?;
|
let txn = db.begin().await?;
|
||||||
|
|
||||||
let group = active.update(&txn).await?;
|
let role = active.update(&txn).await?;
|
||||||
|
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
event_bus.emit("group_updated", group.clone());
|
event_bus.emit("role_updated", role.clone());
|
||||||
|
|
||||||
Ok(group)
|
Ok(role)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_role(&self, id: Uuid) -> Result<bool, anyhow::Error> {
|
pub async fn delete_role(&self, id: Uuid) -> Result<bool, anyhow::Error> {
|
||||||
@@ -65,9 +65,29 @@ impl RoleService {
|
|||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
if deleted {
|
if deleted {
|
||||||
event_bus.emit("group_deleted", id);
|
event_bus.emit("role_deleted", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(deleted)
|
Ok(deleted)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn add_member(&self, role_id: Uuid, user_id: Uuid, server_id: Uuid) -> Result<bool, anyhow::Error> {
|
||||||
|
let added = self.service_context.repositories.role.add_member(role_id, user_id).await?;
|
||||||
|
if added {
|
||||||
|
self.service_context
|
||||||
|
.event_bus
|
||||||
|
.emit("role_user_created", (role_id, user_id, server_id));
|
||||||
|
}
|
||||||
|
Ok(added)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_member(&self, role_id: Uuid, user_id: Uuid, server_id: Uuid) -> Result<bool, anyhow::Error> {
|
||||||
|
let removed = self.service_context.repositories.role.remove_member(role_id, user_id).await?;
|
||||||
|
if removed {
|
||||||
|
self.service_context
|
||||||
|
.event_bus
|
||||||
|
.emit("role_user_deleted", (role_id, user_id, server_id));
|
||||||
|
}
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user