Добавил систему чатов

This commit is contained in:
Artem VV 2023-05-19 21:15:28 +07:00
parent be1d03b459
commit f2a6469a55
10 changed files with 632 additions and 7 deletions

View file

@ -42,14 +42,17 @@ model timetable {
} }
model users { model users {
id Int @id(map: "pk_users") @default(autoincrement()) id Int @id(map: "pk_users") @default(autoincrement())
login String @unique @db.VarChar(25) login String @unique @db.VarChar(25)
pass String @db.VarChar(100) pass String @db.VarChar(100)
fullName String? @db.VarChar(100) fullName String? @db.VarChar(100)
is_admin Boolean @default(false) is_admin Boolean @default(false)
lore String? @db.Text() lore String? @db.Text()
timetable timetable[] timetable timetable[]
user_session user_session[] user_session user_session[]
chat_message chat_message[]
user_in_chat user_in_chat[]
} }
model user_session { model user_session {
@ -58,3 +61,33 @@ model user_session {
user users @relation(fields: [usersId], references: [id]) user users @relation(fields: [usersId], references: [id])
} }
model chat {
id String @id @default(uuid())
title String
chat_message chat_message[]
user_in_chat user_in_chat[]
}
model chat_message {
id String @id @default(uuid())
text String
user users @relation(fields: [userId], references: [id])
chat chat @relation(fields: [chatId], references: [id])
sendAt DateTime @default(now())
chatId String
userId Int
}
model user_in_chat {
id Int @id @default(autoincrement())
chatId String
userId Int
chat chat @relation(fields: [chatId], references: [id])
user users @relation(fields: [userId], references: [id])
}

View file

@ -0,0 +1,31 @@
---
import type { users, timetable } from "@prisma/client";
import { getUserChats } from "../db";
export interface Props {
user: users;
}
const { user } = Astro.props;
const chats = await getUserChats(user.id);
---
<div class="col-4 border-end">
<ol class="list-group list-group-flush">
<a class="list-group-item d-flex justify-content-between align-items-start" role="button" href="/newChat">
<div class="ms-2 me-auto">
<div class="fw-bold">+ Создать чат</div>
</div>
</a>
{
chats.map((e) => (
<a class="list-group-item d-flex justify-content-between align-items-start" role="button" href={`/chats?openChat=${e.id}`}>
<div class="ms-2 me-auto">
<div class="fw-bold">{e.title}</div>
</div>
</a>
))
}
</ol>
</div>

View file

@ -0,0 +1,195 @@
---
import type { users } from "@prisma/client";
import type { CompositeChat } from "../db";
export interface Props {
user: users;
chat: CompositeChat;
}
const { user, chat } = Astro.props;
const formatDate = (dt: Date) => {
return (
("0" + dt.getDay()).substr(-2) +
"." +
("0" + (0 + dt.getMonth())).substr(-2) +
"." +
dt.getFullYear() +
" в " +
dt.getHours() +
":" +
("0" + dt.getMinutes()).substr(-2)
);
};
let lastMessageAt: Date | null = null;
if (chat.messages.length > 0) {
lastMessageAt = chat.messages[chat.messages.length - 1].sendAt;
lastMessageAt.setSeconds(lastMessageAt.getSeconds() + 1);
}
---
<div class="col d-flex flex-column" data-chatId={chat.chat.id}>
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="#">{chat.chat.title}</a>
</div>
</nav>
<div id="chatHolder" class="flex-grow-1" data-lastMessageAt={lastMessageAt}>
{
chat.messages.map((e) => (
<div class="list-group m-1" data-messageId={e.id}>
<div class="list-group-item list-group-item-action">
<p class="mb-1">{e.text}</p>
<small class="text-muted">
{e.user.fullName ?? e.user.login} • {formatDate(e.sendAt)}
</small>
</div>
</div>
))
}
</div>
<div class="container-fluid bg-light p-4 d-flex flex-row gap-2">
<input type="text" class="form-control" id="messageText" placeholder="Введите сообщение" />
<button type="button" class="btn btn-primary" id="sendMessage">Отправить</button>
</div>
</div>
<script>
import type { CompositeChat } from "../db";
const attr = (attrName: string) => document.querySelector(`[${attrName}]`)!.getAttribute(attrName)!;
const chatId = () => attr("data-chatId");
const lastMessageAt = () => new Date(attr("data-lastMessageAt"));
const formatDate = (dt: Date) => {
return (
("0" + dt.getDay()).substr(-2) +
"." +
("0" + (0 + dt.getMonth())).substr(-2) +
"." +
dt.getFullYear() +
" в " +
dt.getHours() +
":" +
("0" + dt.getMinutes()).substr(-2)
);
};
const renderMessageTemplate = (messageId: string, text: string, sender: string, sendAt: Date) => `<div class="list-group m-1" data-messageId=${messageId}>
<div class="list-group-item list-group-item-action">
<p class="mb-1">${text}</p>
<small class="text-muted">
${sender} • ${formatDate(sendAt)}
</small>
</div>
</div>`;
async function renderMessages(data: CompositeChat) {
const chatHolder = document.getElementById("chatHolder")!;
const lastMessage = chatHolder.querySelectorAll(`[data-messageId]`).item(chatHolder.childElementCount - 1);
const lastMessageId = lastMessage !== null ? lastMessage.getAttribute("data-messageId") : null;
for (const message of data.messages) {
if (lastMessageId !== null && message.id === lastMessageId) continue;
const sender = message.user.fullName ?? message.user.login;
const messageHtml = renderMessageTemplate(message.id, message.text, sender, new Date(message.sendAt));
chatHolder.innerHTML += messageHtml;
}
chatHolder.scrollTop = chatHolder.scrollHeight;
const lastMessageInPayload = data.messages[data.messages.length - 1];
if (lastMessageInPayload === undefined || lastMessageInPayload === null) return;
const lastMessageAt = new Date(lastMessageInPayload.sendAt);
chatHolder.setAttribute("data-lastMessageAt", lastMessageAt.toISOString());
}
async function fetchMessages() {
try {
const fd = new FormData();
fd.append("chatId", chatId());
fd.append("since", lastMessageAt().toISOString());
const resp = await fetch("/chatapi/getMessages", {
method: "POST",
body: fd,
});
const json = await resp.json();
if (json.ok) {
const messagesData: CompositeChat = json.data;
renderMessages(messagesData);
} else {
throw new Error(json.reason);
}
} catch (e) {
console.error(e);
if (e instanceof Error) {
alert(`Ошибка загрузки сообщений: ${e.message}`);
}
}
}
async function sendMessage(message: string) {
try {
const fd = new FormData();
fd.append("chatId", chatId());
fd.append("message", message);
const resp = await fetch("/chatapi/sendMessage", {
method: "POST",
body: fd,
});
const json = await resp.json();
if (json.ok) {
fetchMessages();
} else {
throw new Error(json.reason);
}
} catch (e) {
console.error(e);
if (e instanceof Error) {
alert(`Ошибка отправки сообщения: ${e.message}`);
}
}
}
document.addEventListener("DOMContentLoaded", () => {
const messageText = document.getElementById("messageText")! as HTMLInputElement;
const sendMessageBtn = document.getElementById("sendMessage")!;
sendMessageBtn.addEventListener("click", () => {
const text = messageText.value;
if (text.length === 0) return;
messageText.value = "";
messageText.focus();
sendMessage(text);
});
messageText.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
sendMessageBtn.click();
}
});
const chatHolder = document.getElementById("chatHolder")!;
chatHolder.scrollTop = chatHolder.scrollHeight;
setInterval(() => {
fetchMessages();
}, 1000);
});
</script>
<style>
#chatHolder {
max-height: calc(100vh - 13rem);
overflow: hidden;
overflow-y: scroll;
}
</style>

View file

@ -18,6 +18,10 @@ const items = [
href: "/users", href: "/users",
title: "Пользователи", title: "Пользователи",
}, },
{
href: "/chats",
title: "Чаты",
},
]; ];
const itemsRight = [ const itemsRight = [

106
src/db.ts
View file

@ -1,4 +1,4 @@
import { PrismaClient, users } from "@prisma/client"; import { PrismaClient, chat, chat_message, users } from "@prisma/client";
import { blake3 } from "@noble/hashes/blake3"; import { blake3 } from "@noble/hashes/blake3";
import { bytesToHex as toHex } from "@noble/hashes/utils"; import { bytesToHex as toHex } from "@noble/hashes/utils";
@ -371,3 +371,107 @@ export async function updateUserTimetable(userLogin: string, oddUpdate: UpdateTT
.reduce((acc, val) => acc.concat(val), []), .reduce((acc, val) => acc.concat(val), []),
}); });
} }
export async function getUserChats(userId: number) {
const chats = await client.user_in_chat.findMany({
where: {
userId: userId,
},
include: {
chat: true,
},
});
return chats.map((x) => x.chat);
}
export type CompositeChat = {
chat: chat;
messages: (chat_message & {
user: users;
})[];
};
export async function getChatMessages(chatId: string, since?: Date): Promise<CompositeChat> {
const chat = (await client.chat.findFirst({
where: {
id: chatId,
},
}))!;
const messages = await client.chat_message.findMany({
where: {
chatId: chatId,
sendAt: {
gt: since,
},
},
include: {
user: true,
},
orderBy: {
sendAt: "asc",
},
});
return {
chat,
messages,
};
}
export async function createChatMessage(chatId: string, userId: number, message: string) {
const msg = await client.chat_message.create({
data: {
chatId: chatId,
userId: userId,
text: message,
},
});
return msg;
}
export async function createChat(title: string, userIds: number[]) {
const chat = await client.chat.create({
data: {
title: title,
user_in_chat: {
createMany: {
data: userIds.map((x) => {
return {
userId: x,
};
}),
},
},
},
});
return chat;
}
export async function userAllowedToChat(userId: number, chatId: string) {
const chat = await client.user_in_chat.findFirst({
where: {
chatId: chatId,
userId: userId,
},
});
return chat !== null;
}
export async function addUserToChat(userId: number, chatId: string) {
const chat = await client.user_in_chat.create({
data: {
chatId: chatId,
userId: userId,
},
});
return chat !== null;
}
export async function removeUserFromChat(userId: number, chatId: string) {
const chat = await client.user_in_chat.deleteMany({
where: {
chatId: chatId,
userId: userId,
},
});
return chat !== null;
}

View file

@ -0,0 +1,37 @@
import type { APIContext } from "astro";
import { getSessionUser, createChat } from "../../db";
import { Prisma } from "@prisma/client";
export async function post({ request, cookies }: APIContext) {
const response: { ok: boolean; reason?: string } = {
ok: true,
};
try {
const sessId = cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
const formData = await request.formData();
const title = formData.get("title");
const selectedUsers = formData.getAll("selectedUsers").map((x) => parseInt(x.toString()));
if (title === null || selectedUsers.length === 0 || selectedUsers.find((x) => isNaN(x))) {
throw new Error("Предоставлены некорректные данные для создания чата");
}
await createChat(title.toString(), [...selectedUsers, user.id]);
response.ok = true;
} catch (e: any) {
response.ok = false;
if (e instanceof Prisma.PrismaClientKnownRequestError) {
response.reason = `Неизвестная ошибка базы данных. Код ${e.code}`;
} else if (e instanceof Error) {
response.reason = e.message.trim();
} else {
response.reason = e.toString().trim();
}
}
return {
body: JSON.stringify(response),
};
}

View file

@ -0,0 +1,43 @@
import type { APIContext } from "astro";
import { getSessionUser, userAllowedToChat, getChatMessages, CompositeChat } from "../../db";
import { Prisma } from "@prisma/client";
export async function post({ request, cookies }: APIContext) {
const response: { ok: boolean; reason?: string; data?: CompositeChat } = {
ok: true,
};
try {
const sessId = cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
const formData = await request.formData();
const chatId = formData.get("chatId");
const since = formData.get("since");
if (chatId === null) {
throw new Error("Не предоставлены данные получения сообщений");
}
const userAllowed = await userAllowedToChat(user.id, chatId.toString());
if (!userAllowed) {
throw new Error("Нет доступа к чату");
}
const messages = await getChatMessages(chatId.toString(), since === null ? undefined : new Date(since.toString()));
response.ok = true;
response.data = messages;
} catch (e: any) {
response.ok = false;
if (e instanceof Prisma.PrismaClientKnownRequestError) {
response.reason = `Неизвестная ошибка базы данных. Код ${e.code}`;
} else if (e instanceof Error) {
response.reason = e.message.trim();
} else {
response.reason = e.toString().trim();
}
}
return {
body: JSON.stringify(response),
};
}

View file

@ -0,0 +1,42 @@
import type { APIContext } from "astro";
import { getSessionUser, userAllowedToChat, createChatMessage } from "../../db";
import { Prisma } from "@prisma/client";
export async function post({ request, cookies }: APIContext) {
const response: { ok: boolean; reason?: string } = {
ok: true,
};
try {
const sessId = cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
const formData = await request.formData();
const chatId = formData.get("chatId");
const message = formData.get("message");
if (chatId === null || message === null) {
throw new Error("Не предоставлены данные отправки сообщения");
}
const userAllowed = await userAllowedToChat(user.id, chatId.toString());
if (!userAllowed) {
throw new Error("Нет доступа к чату");
}
await createChatMessage(chatId.toString(), user.id, message.toString());
response.ok = true;
} catch (e: any) {
response.ok = false;
if (e instanceof Prisma.PrismaClientKnownRequestError) {
response.reason = `Неизвестная ошибка базы данных. Код ${e.code}`;
} else if (e instanceof Error) {
response.reason = e.message.trim();
} else {
response.reason = e.toString().trim();
}
}
return {
body: JSON.stringify(response),
};
}

48
src/pages/chats.astro Normal file
View file

@ -0,0 +1,48 @@
---
import Layout from "../layouts/Layout.astro";
import Navbar from "../components/Navbar.astro";
import ChatList from "../components/ChatList.astro";
import ChatView from "../components/ChatView.astro";
import { getUserSession, getSessionUser, CompositeChat, getChatMessages } from "../db";
import type { chat, chat_message, users } from "@prisma/client";
if (Astro.cookies.has("session")) {
const sessId = Astro.cookies.get("session").value!;
const dbSess = await getUserSession(sessId);
if (dbSess === null) {
Astro.cookies.delete("session");
return Astro.redirect("/login");
}
} else {
return Astro.redirect("/login");
}
let openChat: CompositeChat | null = null;
if (Astro.url.searchParams.has("openChat")) {
let openChatId = Astro.url.searchParams.get("openChat")!;
openChat = await getChatMessages(openChatId);
}
const sessId = Astro.cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
---
<Layout title="Чаты">
<main class="d-flex flex-column" style="min-height: 100vh;">
<Navbar is_user_admin={user.is_admin} />
<div class="flex-grow-1 container-fluid m-0 p-0 d-flex flex-row">
<ChatList user={user} />
{openChat !== null && <ChatView user={user} chat={openChat} />}
{
openChat === null && (
<div class="col flex-grow-1 d-flex text-center align-items-stretch align-self-center">
<div class="alert alert-info w-100 m-5" role="alert">
Выберите чат из списка слева
</div>
</div>
)
}
</div>
</main>
</Layout>

88
src/pages/newChat.astro Normal file
View file

@ -0,0 +1,88 @@
---
import Layout from "../layouts/Layout.astro";
import Navbar from "../components/Navbar.astro";
import { getUserSession, getSessionUser, searchUsers } from "../db";
if (Astro.cookies.has("session")) {
const sessId = Astro.cookies.get("session").value!;
const dbSess = await getUserSession(sessId);
if (dbSess === null) {
Astro.cookies.delete("session");
return Astro.redirect("/login");
}
} else {
return Astro.redirect("/login");
}
const sessId = Astro.cookies.get("session").value!;
const user = (await getSessionUser(sessId))!;
const users = (await searchUsers({})).filter((e) => e.id !== user.id);
---
<Layout title="Новый чат">
<main>
<Navbar is_user_admin={user.is_admin} />
<div class="container mt-5">
<form>
<div class="mb-3">
<label for="chatTitle" class="form-label">Название чата</label>
<input type="text" class="form-control" id="chatTitle" name="title" />
</div>
<label for="chatTitle" class="form-label">Кто участвует в чате:</label>
<div class="d-flex flex-wrap flex-row">
{
users.map((e) => (
<div class="mb-3 form-check col-2">
<input type="checkbox" class="form-check-input" id={`userCheck-${e.login}`} name="selectedUsers" value={e.id} />
<label class="form-check-label" for={`userCheck-${e.login}`}>
{e.login}
</label>
</div>
))
}
</div>
<button type="submit" class="btn btn-primary">Создать</button>
</form>
</div>
</main>
<script>
async function createChat(form: HTMLFormElement) {
try {
const fd = new FormData(form);
if (fd.get("title")?.length === 0) {
throw new Error("Не указано название чата");
}
if (fd.getAll("selectedUsers").length === 0) {
throw new Error("Не выбраны пользователи");
}
const resp = await fetch("/chatapi/createChat", {
method: "POST",
body: fd,
});
const json = await resp.json();
if (json.ok) {
alert("Чат создан");
window.location.href = "/chats";
} else {
throw new Error(json.reason);
}
} catch (e) {
console.error(e);
if (e instanceof Error) alert(e.message);
return;
}
}
document.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("form");
form?.addEventListener("submit", (e) => {
e.preventDefault();
createChat(form);
});
});
</script>
</Layout>