diff --git a/Makefile b/Makefile index fa1a6f10..9029ab4d 100644 --- a/Makefile +++ b/Makefile @@ -70,14 +70,19 @@ dev-db: -e POSTGRES_DB=database \ postgres:alpine @echo "Waiting for PostgreSQL to be ready..." - @sleep 5 # PostgreSQLが起動するまでの待機(必要に応じて調整) + @sleep 2 # PostgreSQLが起動するまでの待機(必要に応じて調整) @until docker exec postgres pg_isready -U user -d database; do \ echo "Waiting for PostgreSQL to be ready..."; \ sleep 1; \ done @echo "PostgreSQL is ready. Running seed..." - @cd server; bunx prisma generate; bunx prisma db push; cd .. - @make seed; + @cd server; \ + if command -v prisma; then\ + prisma generate; prisma db push;\ + else \ + bunx prisma generate; bunx prisma db push;\ + fi + @make seed @echo "Seeding completed." # Sync (install/update packages, generate prisma, etc) @@ -88,7 +93,7 @@ sync-web: sync-server: cd server; bun install --frozen-lockfile - cd server; bunx prisma generate + cd server; if command -v prisma; then prisma generate; else bunx prisma generate; fi # copy .env.sample -> .env only if .env is not there sync-root: diff --git a/common/zod/schemas.ts b/common/zod/schemas.ts index 52411ac6..e5187c7b 100644 --- a/common/zod/schemas.ts +++ b/common/zod/schemas.ts @@ -146,6 +146,7 @@ export const DMOverviewSchema = z.object({ name: NameSchema, thumbnail: z.string(), lastMsg: MessageSchema.optional(), + unreadMessages: z.number(), }); export const SharedRoomOverviewSchema = z.object({ diff --git a/flake.nix b/flake.nix index 25c451e5..d0f8f3a2 100644 --- a/flake.nix +++ b/flake.nix @@ -27,6 +27,8 @@ biome pkg-config openssl + lefthook + pkgs.prisma ] ++ [ rust-pkgs ]; diff --git a/server/package.json b/server/package.json index f2e0f362..c9b150d5 100644 --- a/server/package.json +++ b/server/package.json @@ -36,5 +36,6 @@ "globals": "^15.8.0", "prisma": "^5.11.0", "typescript": "^5.4.5" - } + }, + "trustedPackages": ["prisma"] } diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 67986b44..d8907549 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -52,9 +52,9 @@ model InterestSubject { id Int @id @default(autoincrement()) name String group String // such as Computer Science | name = ML + Interest Interest[] // ignore this @@unique([name, group]) - Interest Interest[] // ignore this } // User->Interest->InterestSubject @@ -138,6 +138,7 @@ model Message { createdAt DateTime @default(now()) // @readonly edited Boolean @default(false) content String + read Boolean relation Relationship? @relation(fields: [relationId], references: [id], onDelete: Cascade) relationId Int? sharedRoom SharedRoom? @relation(fields: [sharedRoomId], references: [id], onDelete: Cascade) diff --git a/server/src/database/chat.ts b/server/src/database/chat.ts index 6fafc7ef..0373c7f1 100644 --- a/server/src/database/chat.ts +++ b/server/src/database/chat.ts @@ -19,6 +19,7 @@ import { getPendingRequestsFromUser, getPendingRequestsToUser, } from "./requests"; +import { getUserByID } from "./users"; export async function getOverview( user: UserID, @@ -35,59 +36,17 @@ export async function getOverview( //マッチングしている人のオーバービュー const matchingOverview = await Promise.all( - matched.value.map(async (friend) => { - const lastMessageResult = await getLastMessage(user, friend.id); - const lastMessage = lastMessageResult.ok - ? lastMessageResult.value - : undefined; - const overview: DMOverview = { - isDM: true, - matchingStatus: "matched", - friendId: friend.id, - name: friend.name, - thumbnail: friend.pictureUrl, - lastMsg: lastMessage, - }; - return overview; - }), + matched.value.map(async (m) => getOverviewBetween(user, m.id)), ); //自分にリクエストを送ってきた人のオーバービュー const senderOverview = await Promise.all( - senders.value.map(async (sender) => { - const lastMessageResult = await getLastMessage(user, sender.id); - const lastMessage = lastMessageResult.ok - ? lastMessageResult.value - : undefined; - const overview: DMOverview = { - isDM: true, - matchingStatus: "otherRequest", - friendId: sender.id, - name: sender.name, - thumbnail: sender.pictureUrl, - lastMsg: lastMessage, - }; - return overview; - }), + senders.value.map((s) => getOverviewBetween(user, s.id)), ); //自分がリクエストを送った人のオーバービュー const receiverOverview = await Promise.all( - receivers.value.map(async (receiver) => { - const lastMessageResult = await getLastMessage(user, receiver.id); - const lastMessage = lastMessageResult.ok - ? lastMessageResult.value - : undefined; - const overview: DMOverview = { - isDM: true, - matchingStatus: "myRequest", - friendId: receiver.id, - name: receiver.name, - thumbnail: receiver.pictureUrl, - lastMsg: lastMessage, - }; - return overview; - }), + receivers.value.map((r) => getOverviewBetween(user, r.id)), ); const sharedRooms: { @@ -130,6 +89,67 @@ export async function getOverview( } } +async function getOverviewBetween( + user: number, + other: number, +): Promise { + const relR = await getRelation(user, other); + if (!relR.ok) throw relR.error; + const rel = relR.value; + + const friendId = + rel.receivingUserId === user ? rel.sendingUserId : rel.receivingUserId; + const lastMessage = getLastMessage(user, friendId).then((val) => { + if (val.ok) return val.value; + return undefined; + }); + const unreadCount = unreadMessages(user, rel.id).then((val) => { + if (val.ok) return val.value; + throw val.error; + }); + const friend = await getUserByID(friendId).then((val) => { + if (val.ok) return val.value; + throw val.error; + }); + const overview: DMOverview = { + isDM: true, + matchingStatus: "matched", + friendId: friendId, + name: friend.name, + thumbnail: friend.pictureUrl, + lastMsg: await lastMessage, + unreadMessages: await unreadCount, + }; + return overview; +} +export async function markAsRead( + rel: RelationshipID, + reader: UserID, + message: MessageID, +) { + const val = { + readerId: reader, + messageId: message, + relationId: rel, + }; + return await prisma.message.updateMany({ + where: { + id: { + lte: message, + }, + relationId: rel, + creator: { + not: { + equals: reader, + }, + }, + }, + data: { + read: true, + }, + }); +} + /** * DM の送信 * 送信者の id は呼び出す側で指定すること @@ -142,6 +162,7 @@ export async function sendDM( const message = await prisma.message.create({ data: { relationId: relation, + read: false, ...content, }, }); @@ -372,3 +393,24 @@ export async function getLastMessage( return Err(e); } } + +// only works on Relationship (= DM) for now. +export async function unreadMessages(userId: UserID, roomId: RelationshipID) { + try { + // FIXME: this makes request twice to the database. it's not efficient. + const unreadMessages = await prisma.message.count({ + where: { + read: false, + relationId: roomId, + creator: { + not: { + equals: userId, + }, + }, + }, + }); + return Ok(unreadMessages); + } catch (e) { + return Err(e); + } +} diff --git a/server/src/database/matches.ts b/server/src/database/matches.ts index 86df7857..7e39c96f 100644 --- a/server/src/database/matches.ts +++ b/server/src/database/matches.ts @@ -8,7 +8,7 @@ export async function getRelation( u2: UserID, ): Promise> { try { - // TODO!!!! FIXME!!!!!! FIX THIS findMany!!!!! + // FIXME: fix this findMany const rel = await prisma.relationship.findMany({ where: { OR: [ diff --git a/server/src/database/requests.ts b/server/src/database/requests.ts index 0b829093..cb4228cc 100644 --- a/server/src/database/requests.ts +++ b/server/src/database/requests.ts @@ -277,6 +277,29 @@ export async function getMatchedUser( } } +export async function getMatchedRelations( + userId: UserID, +): Promise> { + try { + const found = await prisma.relationship.findMany({ + where: { + status: "MATCHED", + OR: [ + { + sendingUserId: userId, + }, + { + receivingUserId: userId, + }, + ], + }, + }); + return Ok(found); + } catch (e) { + return Err(e); + } +} + export async function matchWithMemo(userId: UserID) { try { const result = await prisma.relationship.create({ diff --git a/server/src/router/chat.ts b/server/src/router/chat.ts index a6052e1f..ccf3ce8c 100644 --- a/server/src/router/chat.ts +++ b/server/src/router/chat.ts @@ -9,7 +9,8 @@ import { } from "common/zod/schemas"; import express from "express"; import * as db from "../database/chat"; -import { safeGetUserId } from "../firebase/auth/db"; +import { getRelation } from "../database/matches"; +import { getUserId, safeGetUserId } from "../firebase/auth/db"; import * as core from "../functions/chat"; import * as ws from "../lib/socket/socket"; @@ -56,6 +57,18 @@ router.get("/dm/with/:userid", async (req, res) => { return res.status(result.code).send(result.body); }); +router.post("/mark-as-read/:rel/:messageId", async (req, res) => { + const user = await getUserId(req); + const message = Number.parseInt(req.params.messageId); + const rel = Number.parseInt(req.params.rel); + try { + await db.markAsRead(rel, user, message); + return res.status(200).end("ok"); + } catch (err) { + return res.status(304).end("already marked"); + } +}); + // create a shared chat room. router.post("/shared", async (req, res) => { const user = await safeGetUserId(req); diff --git a/web/api/chat/chat.ts b/web/api/chat/chat.ts index 83e307d5..1960de73 100644 --- a/web/api/chat/chat.ts +++ b/web/api/chat/chat.ts @@ -4,6 +4,7 @@ import type { Message, MessageID, PersonalizedDMRoom, + RelationshipID, RoomOverview, SendMessage, ShareRoomID, @@ -12,7 +13,7 @@ import type { UserID, } from "common/types"; import { ErrUnauthorized, credFetch } from "~/firebase/auth/lib"; -import endpoints from "../internal/endpoints"; +import * as endpoints from "../internal/endpoints"; /* TODO import { UserID } from "common/types"; @@ -35,6 +36,20 @@ export async function deleteMessage( ); } +export async function markAsRead( + relationId: RelationshipID, + messageId: MessageID, +) { + const res = await credFetch( + "POST", + endpoints.markAsRead(relationId, messageId), + ); + if (res.status !== 200 && res.status !== 304) + throw new Error( + `on markAsRead(), expected status code of 200 or 304, but got ${res.status}`, + ); +} + export async function updateMessage( messageId: MessageID, newMessage: SendMessage, diff --git a/web/api/internal/endpoints.ts b/web/api/internal/endpoints.ts index e087c87f..50ea769c 100644 --- a/web/api/internal/endpoints.ts +++ b/web/api/internal/endpoints.ts @@ -281,6 +281,11 @@ export const rejectRequest = (opponentId: UserID) => { return `${origin}/requests/reject/${opponentId}`; }; +/** + **/ +export const markAsRead = (friendId: UserID, messageId: MessageID) => { + return `${origin}/chat/mark-as-read/${friendId}/${messageId}`; +}; /** * []実装済み * GET -> get personalized room overviews. diff --git a/web/app/chat/[id]/page.tsx b/web/app/chat/[id]/page.tsx index 6db7a216..86858436 100644 --- a/web/app/chat/[id]/page.tsx +++ b/web/app/chat/[id]/page.tsx @@ -20,6 +20,7 @@ export default function Page({ params }: { params: { id: string } }) { {room ? ( ) : ( + // FIXME: this isn't an error when it's just loading

Sorry, an unexpected error has occurred. diff --git a/web/components/chat/RoomList.tsx b/web/components/chat/RoomList.tsx index 710feb3c..42c9e7cf 100644 --- a/web/components/chat/RoomList.tsx +++ b/web/components/chat/RoomList.tsx @@ -51,6 +51,7 @@ export function RoomList(props: RoomListProps) { rollUpName={true} lastMessage={room.lastMsg?.content} statusMessage="リクエストを受けました" + unreadCount={room.unreadMessages} /> ); @@ -72,6 +73,7 @@ export function RoomList(props: RoomListProps) { rollUpName={true} lastMessage={room.lastMsg?.content} statusMessage="リクエスト中 メッセージを送りましょう!" + unreadCount={room.unreadMessages} /> ); @@ -91,6 +93,7 @@ export function RoomList(props: RoomListProps) { pictureUrl={room.thumbnail} rollUpName={true} lastMessage={room.lastMsg?.content} + unreadCount={room.unreadMessages} /> ); diff --git a/web/components/chat/RoomWindow.tsx b/web/components/chat/RoomWindow.tsx index 75a1a9bd..2b5cac4a 100644 --- a/web/components/chat/RoomWindow.tsx +++ b/web/components/chat/RoomWindow.tsx @@ -26,6 +26,17 @@ export function RoomWindow(props: Props) { ); } + console.log("rendering"); + useEffect(() => { + (async () => { + const lastM = room.messages.at(-1); + if (lastM) { + console.log("marking as read: ", room.id, lastM.id); + await chat.markAsRead(room.id, lastM.id); + } + })(); + }, [room]); + const { state: { data: myId }, } = useMyID(); diff --git a/web/components/human/humanListItem.tsx b/web/components/human/humanListItem.tsx index 2fe31ebb..e067986b 100644 --- a/web/components/human/humanListItem.tsx +++ b/web/components/human/humanListItem.tsx @@ -7,6 +7,7 @@ type HumanListItemProps = { pictureUrl: string; lastMessage?: string; rollUpName?: boolean; // is currently only intended to be used in Chat + unreadCount?: number; // only intended to be used in chat statusMessage?: string; onDelete?: (id: number) => void; onOpen?: (user: { id: number; name: string; pictureUrl: string }) => void; @@ -22,8 +23,9 @@ export function HumanListItem(props: HumanListItemProps) { id, name, pictureUrl, - rollUpName, lastMessage, + rollUpName, + unreadCount, statusMessage, onDelete, onOpen, @@ -69,6 +71,9 @@ export function HumanListItem(props: HumanListItemProps) {

+ {unreadCount ? ( + {unreadCount} + ) : undefined} {onAccept && ( // biome-ignore lint/a11y/useButtonType: