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