diff --git a/apps/client/src/app/createApp.ts b/apps/client/src/app/createApp.ts index 350e1f8..f9d7764 100644 --- a/apps/client/src/app/createApp.ts +++ b/apps/client/src/app/createApp.ts @@ -7,10 +7,11 @@ import { logout as requestLogout, updateAppearance, } from "../auth/AuthClient"; +import { blockUser } from "../blocks/BlockClient"; import type { FriendSummary } from "../friends/FriendClient"; import { addFriend, listFriends, removeFriend } from "../friends/FriendClient"; import type { Game } from "../game/Game"; -import { loadConversation } from "../messaging/DirectMessageClient"; +import { loadConversation, loadUnreadCounts } from "../messaging/DirectMessageClient"; import { createRoom, listRoomTemplates } from "../rooms/RoomClient"; import { ClientLogger } from "../telemetry/ClientLogger"; import { CharacterEditor } from "../ui/CharacterEditor"; @@ -46,6 +47,7 @@ export function createApp(root: HTMLElement): void { let reconnectTimeout: ReturnType | undefined; let countdownInterval: ReturnType | undefined; let reconnecting = false; + const unreadCounts = new Map(); shell.className = "app-shell"; stage.className = "game-stage"; @@ -133,6 +135,26 @@ export function createApp(root: HTMLElement): void { friendsPanel.showError(message); } }, + async onBlock(friend) { + if (!user) { + return; + } + + try { + await blockUser(friend.id); + await refreshFriends(); + + if (directMessagePanel.isOpenFor(friend.id)) { + directMessagePanel.hide(); + } + + status.textContent = `blocked ${friend.username}`; + } catch (error) { + const message = error instanceof Error ? error.message : "Block failed"; + status.textContent = message; + friendsPanel.showError(message); + } + }, }); async function ensureGame(): Promise { @@ -159,10 +181,38 @@ export function createApp(root: HTMLElement): void { roomBrowser.hide(); }, onDirectMessage(message) { - if (!directMessagePanel.append(message) && message.fromUserId !== user?.id) { + const appended = directMessagePanel.append(message); + + if (appended) { + if (message.fromUserId !== user?.id) { + clearUnread(message.fromUserId); + } + return; + } + + if (message.fromUserId !== user?.id) { + incrementUnread(message.fromUserId); status.textContent = "new direct message"; } }, + onDirectTyping(message) { + directMessagePanel.setFriendTyping(message.fromUserId, message.isTyping); + }, + onDirectRead(message) { + if (message.readerUserId === user?.id) { + clearUnread(message.otherUserId); + } else { + directMessagePanel.markRead(message.messageIds); + } + }, + onDirectEdited(message) { + directMessagePanel.updateEdited(message); + }, + onDirectDeleted(message) { + if (!directMessagePanel.markDeleted(message.id) && message.fromUserId !== user?.id) { + decrementUnread(message.fromUserId); + } + }, onDisconnected() { if (!user || !gameStarted) { return; @@ -197,6 +247,19 @@ export function createApp(root: HTMLElement): void { onSend(friendId, text) { return game?.sendDirectMessage(friendId, text) ?? false; }, + onTypingChange(friendId, isTyping) { + game?.sendDirectTyping(friendId, isTyping); + }, + onRead(friendId) { + game?.markDirectMessagesRead(friendId); + clearUnread(friendId); + }, + onEdit(messageId, text) { + return game?.editDirectMessage(messageId, text) ?? false; + }, + onDelete(messageId) { + return game?.deleteDirectMessage(messageId) ?? false; + }, }); const disconnectedDialog = new DisconnectedDialog({ @@ -496,7 +559,14 @@ export function createApp(root: HTMLElement): void { } try { - friendsPanel.setFriends(await listFriends()); + const [friends, unread] = await Promise.all([listFriends(), loadUnreadCounts()]); + unreadCounts.clear(); + + for (const item of unread) { + unreadCounts.set(item.friendId, item.count); + } + + friendsPanel.setFriends(friends, unreadCounts); } catch (error) { const message = error instanceof Error ? error.message : "Friends failed"; status.textContent = message; @@ -504,6 +574,29 @@ export function createApp(root: HTMLElement): void { } } + function incrementUnread(friendId: string): void { + const next = (unreadCounts.get(friendId) ?? 0) + 1; + unreadCounts.set(friendId, next); + friendsPanel.setUnreadCount(friendId, next); + } + + function clearUnread(friendId: string): void { + unreadCounts.delete(friendId); + friendsPanel.setUnreadCount(friendId, 0); + } + + function decrementUnread(friendId: string): void { + const next = Math.max(0, (unreadCounts.get(friendId) ?? 0) - 1); + + if (next === 0) { + clearUnread(friendId); + return; + } + + unreadCounts.set(friendId, next); + friendsPanel.setUnreadCount(friendId, next); + } + function clearReconnectSchedule(): void { if (reconnectTimeout) { clearTimeout(reconnectTimeout); diff --git a/apps/client/src/blocks/BlockClient.test.ts b/apps/client/src/blocks/BlockClient.test.ts new file mode 100644 index 0000000..35c87f5 --- /dev/null +++ b/apps/client/src/blocks/BlockClient.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { DEFAULT_AVATAR_APPEARANCE } from "@tilezo/protocol/appearance"; +import { blockUser, listBlockedUsers, unblockUser } from "./BlockClient"; + +const originalFetch = globalThis.fetch; + +describe("BlockClient", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("lists blocked users", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + blockedUsers: [ + { + id: "user_2", + username: "Kai", + appearance: DEFAULT_AVATAR_APPEARANCE, + blockedAt: "2026-06-13T00:00:00.000Z", + }, + ], + }), + )) as unknown as typeof fetch; + + await expect(listBlockedUsers()).resolves.toEqual([ + { + id: "user_2", + username: "Kai", + appearance: DEFAULT_AVATAR_APPEARANCE, + blockedAt: "2026-06-13T00:00:00.000Z", + }, + ]); + }); + + test("blocks and unblocks users", async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (async ( + url: Parameters[0], + init: Parameters[1], + ) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify({ ok: true })); + }) as unknown as typeof fetch; + + await blockUser("user_2"); + await unblockUser("user_2"); + + expect(calls.map((call) => [call.url, call.init?.method])).toEqual([ + ["http://localhost:3000/blocked-users", "POST"], + ["http://localhost:3000/blocked-users/user_2", "DELETE"], + ]); + expect(calls[0]?.init?.body).toBe(JSON.stringify({ userId: "user_2" })); + }); +}); diff --git a/apps/client/src/blocks/BlockClient.ts b/apps/client/src/blocks/BlockClient.ts new file mode 100644 index 0000000..06c4b13 --- /dev/null +++ b/apps/client/src/blocks/BlockClient.ts @@ -0,0 +1,65 @@ +import type { AvatarAppearance } from "@tilezo/protocol/appearance"; +import { DEFAULT_API_URL } from "../assets"; + +export type BlockedUserSummary = { + id: string; + username: string; + appearance: AvatarAppearance; + blockedAt: string; +}; + +export async function listBlockedUsers(): Promise { + const response = await fetch(`${getApiUrl()}/blocked-users`, { credentials: "include" }); + const body = await readJson< + { blockedUsers?: BlockedUserSummary[] } | { error?: { message?: string } } + >(response); + + if (!response.ok) { + throw new Error(body && "error" in body ? body.error?.message : "Blocked users failed"); + } + + return Array.isArray((body as { blockedUsers?: unknown }).blockedUsers) + ? (body as { blockedUsers: BlockedUserSummary[] }).blockedUsers + : []; +} + +export async function blockUser(userId: string): Promise { + const response = await fetch(`${getApiUrl()}/blocked-users`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ userId }), + }); + const body = await readJson<{ ok?: boolean } | { error?: { message?: string } }>(response); + + if (!response.ok) { + throw new Error(body && "error" in body ? body.error?.message : "Block failed"); + } +} + +export async function unblockUser(userId: string): Promise { + const response = await fetch(`${getApiUrl()}/blocked-users/${encodeURIComponent(userId)}`, { + method: "DELETE", + credentials: "include", + }); + const body = await readJson<{ ok?: boolean } | { error?: { message?: string } }>(response); + + if (!response.ok) { + throw new Error(body && "error" in body ? body.error?.message : "Unblock failed"); + } +} + +function getApiUrl(): string { + const runtimeConfigured = + typeof window === "undefined" ? undefined : window.TILEZO_CONFIG?.PUBLIC_API_URL; + const buildConfigured = typeof process === "undefined" ? undefined : process.env.PUBLIC_API_URL; + return runtimeConfigured ?? buildConfigured ?? DEFAULT_API_URL; +} + +async function readJson(response: Response): Promise { + try { + return (await response.json()) as T; + } catch { + return undefined; + } +} diff --git a/apps/client/src/game/Game.ts b/apps/client/src/game/Game.ts index 26b98ac..eb12b1c 100644 --- a/apps/client/src/game/Game.ts +++ b/apps/client/src/game/Game.ts @@ -2,6 +2,10 @@ import type { AvatarAppearance } from "@tilezo/protocol/appearance"; import type { ClientMessage, DirectMessage, + DirectMessageDeletedMessage, + DirectMessageEditedMessage, + DirectMessageReadReceiptMessage, + DirectMessageTypingStatusMessage, PublicRoomSummary, RoomSnapshotMessage, } from "@tilezo/protocol/messages"; @@ -17,6 +21,10 @@ type GameOptions = { setRooms: (rooms: PublicRoomSummary[]) => void; onRoomJoined: (snapshot: RoomSnapshotMessage) => void; onDirectMessage: (message: DirectMessage) => void; + onDirectTyping: (message: DirectMessageTypingStatusMessage) => void; + onDirectRead: (message: DirectMessageReadReceiptMessage) => void; + onDirectEdited: (message: DirectMessageEditedMessage) => void; + onDirectDeleted: (message: DirectMessageDeletedMessage) => void; onDisconnected: () => void; }; @@ -83,6 +91,22 @@ export class Game { this.options.onDirectMessage(message); } + if (message.type === "dm.typing") { + this.options.onDirectTyping(message); + } + + if (message.type === "dm.read") { + this.options.onDirectRead(message); + } + + if (message.type === "dm.edited") { + this.options.onDirectEdited(message); + } + + if (message.type === "dm.deleted") { + this.options.onDirectDeleted(message); + } + if (message.type === "error") { this.options.setStatus(`${message.code}: ${message.message}`); } @@ -143,6 +167,22 @@ export class Game { return this.sendIfConnected({ type: "dm.send", toUserId, text }); } + sendDirectTyping(toUserId: string, isTyping: boolean): boolean { + return this.sendIfConnected({ type: "dm.typing", toUserId, isTyping }); + } + + markDirectMessagesRead(friendId: string): boolean { + return this.sendIfConnected({ type: "dm.read", friendId }); + } + + editDirectMessage(messageId: string, text: string): boolean { + return this.sendIfConnected({ type: "dm.edit", messageId, text }); + } + + deleteDirectMessage(messageId: string): boolean { + return this.sendIfConnected({ type: "dm.delete", messageId }); + } + async reconnect(): Promise { // Drop stale avatars before reconnecting so the player does not see a frozen copy of // the previous room while the server re-sends a snapshot (or, in edge cases where no diff --git a/apps/client/src/messaging/DirectMessageClient.test.ts b/apps/client/src/messaging/DirectMessageClient.test.ts index 2870306..ce36356 100644 --- a/apps/client/src/messaging/DirectMessageClient.test.ts +++ b/apps/client/src/messaging/DirectMessageClient.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { DirectMessage } from "@tilezo/protocol/messages"; import { DEFAULT_API_URL } from "../assets"; -import { loadConversation } from "./DirectMessageClient"; +import { loadConversation, loadUnreadCounts } from "./DirectMessageClient"; const originalFetch = globalThis.fetch; type FetchArgs = Parameters; @@ -46,4 +46,20 @@ describe("loadConversation", () => { await expect(loadConversation("user_2")).rejects.toThrow("only message your friends"); }); + + test("loads unread counts", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (async (url: FetchArgs[0], init?: FetchArgs[1]) => { + requests.push({ url: String(url), init }); + return Response.json({ unread: [{ friendId: "user_2", count: 3 }] }); + }) as unknown as typeof fetch; + + await expect(loadUnreadCounts()).resolves.toEqual([{ friendId: "user_2", count: 3 }]); + expect(requests).toEqual([ + { + url: `${DEFAULT_API_URL}/direct-messages/unread`, + init: { credentials: "include" }, + }, + ]); + }); }); diff --git a/apps/client/src/messaging/DirectMessageClient.ts b/apps/client/src/messaging/DirectMessageClient.ts index b53a624..d748d31 100644 --- a/apps/client/src/messaging/DirectMessageClient.ts +++ b/apps/client/src/messaging/DirectMessageClient.ts @@ -3,6 +3,11 @@ import { DEFAULT_API_URL } from "../assets"; export type { DirectMessage }; +export type DirectMessageUnreadCount = { + friendId: string; + count: number; +}; + export async function loadConversation(friendId: string): Promise { const response = await fetch(`${getApiUrl()}/friends/${encodeURIComponent(friendId)}/messages`, { credentials: "include", @@ -20,6 +25,25 @@ export async function loadConversation(friendId: string): Promise { + const response = await fetch(`${getApiUrl()}/direct-messages/unread`, { + credentials: "include", + }); + const body = await readJson< + { unread?: DirectMessageUnreadCount[] } | { error?: { message?: string } } + >(response); + + if (!response.ok) { + throw new Error( + body && "error" in body ? body.error?.message : "Could not load unread messages", + ); + } + + return Array.isArray((body as { unread?: unknown }).unread) + ? (body as { unread: DirectMessageUnreadCount[] }).unread + : []; +} + function getApiUrl(): string { const runtimeConfigured = typeof window === "undefined" ? undefined : window.TILEZO_CONFIG?.PUBLIC_API_URL; diff --git a/apps/client/src/styles.css b/apps/client/src/styles.css index 343ce1f..0b04f68 100644 --- a/apps/client/src/styles.css +++ b/apps/client/src/styles.css @@ -560,6 +560,25 @@ select { word-break: break-word; } +.dm-message-text { + display: block; +} + +.dm-message-actions { + display: flex; + justify-content: flex-end; + gap: 4px; + margin-top: 4px; +} + +.dm-message-action { + min-width: 0; + height: 22px; + padding: 0 5px; + border-width: 1px; + font-size: 10px; +} + .dm-message-theirs { align-self: flex-start; background: #d7d2c4; @@ -572,6 +591,35 @@ select { color: #fafaf5; } +.dm-message-mine[data-read="true"]::after { + display: block; + margin-top: 3px; + color: #dff1ec; + content: "read"; + font-size: 10px; + font-weight: 700; + text-align: right; +} + +.dm-message-deleted { + opacity: 0.72; + font-style: italic; +} + +.dm-typing { + min-height: 16px; + margin: 0; + padding: 0 12px; + color: #5f5a51; + font-size: 11px; + font-weight: 700; + visibility: hidden; +} + +.dm-typing.visible { + visibility: visible; +} + .dm-form { display: flex; gap: 8px; @@ -702,6 +750,13 @@ select { gap: 2px; } +.friend-name-row { + display: flex; + align-items: center; + min-width: 0; + gap: 6px; +} + .friend-details strong { overflow: hidden; color: #302c27; @@ -711,6 +766,19 @@ select { white-space: nowrap; } +.friend-details .friend-unread-badge { + min-width: 20px; + padding: 1px 5px; + border: 1px solid #1f2d2f; + border-radius: 7px; + background: #e2c45e; + color: #1f2d2f; + font-size: 10px; + font-weight: 900; + line-height: 1.2; + text-align: center; +} + .friend-details span { overflow: hidden; color: #5f5a51; @@ -726,6 +794,7 @@ select { } .friend-join-button, +.friend-block-button, .friend-remove-button { width: 74px; height: 30px; diff --git a/apps/client/src/ui/DirectMessagePanel.test.ts b/apps/client/src/ui/DirectMessagePanel.test.ts index 367b62c..3474c61 100644 --- a/apps/client/src/ui/DirectMessagePanel.test.ts +++ b/apps/client/src/ui/DirectMessagePanel.test.ts @@ -3,6 +3,7 @@ import type { DirectMessage } from "@tilezo/protocol/messages"; import { DirectMessagePanel } from "./DirectMessagePanel"; const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document"); +const originalPrompt = globalThis.prompt; function dm(over: Partial): DirectMessage { return { @@ -17,29 +18,46 @@ function dm(over: Partial): DirectMessage { } describe("DirectMessagePanel", () => { - afterEach(() => restoreDocument()); + afterEach(() => { + restoreDocument(); + globalThis.prompt = originalPrompt; + }); test("opens a conversation, renders history, and aligns own messages", () => { installDocument(); - const panel = new DirectMessagePanel({ onSend: () => undefined }); + const read: string[] = []; + const panel = new DirectMessagePanel({ + onSend: () => undefined, + onRead(friendId) { + read.push(friendId); + }, + }); panel.open( { id: "user_2", username: "Kai" }, - [dm({ text: "hello" }), dm({ fromUserId: "user_1", text: "hey" })], + [dm({ text: "hello" }), dm({ fromUserId: "user_1", text: "hey", readAt: "2026-06-13" })], "user_1", ); expect(panel.element.classList.contains("hidden")).toBe(false); expect(panel.isOpenFor("user_2")).toBe(true); const list = panel.element.children[1] as unknown as FakeElement; - expect(list.children.map((c) => c.textContent)).toEqual(["hello", "hey"]); + expect(messageTexts(list)).toEqual(["hello", "hey"]); expect(list.children[0]?.className).toBe("dm-message dm-message-theirs"); expect(list.children[1]?.className).toBe("dm-message dm-message-mine"); + expect(list.children[1]?.dataset.read).toBe("true"); + expect(read).toEqual(["user_2"]); }); test("appends only messages that belong to the open conversation", () => { installDocument(); - const panel = new DirectMessagePanel({ onSend: () => undefined }); + const read: string[] = []; + const panel = new DirectMessagePanel({ + onSend: () => undefined, + onRead(friendId) { + read.push(friendId); + }, + }); panel.open({ id: "user_2", username: "Kai" }, [], "user_1"); const list = panel.element.children[1] as unknown as FakeElement; @@ -47,7 +65,8 @@ describe("DirectMessagePanel", () => { expect(panel.append(dm({ fromUserId: "user_3", toUserId: "user_1", text: "other" }))).toBe( false, ); - expect(list.children.map((c) => c.textContent)).toEqual(["live"]); + expect(messageTexts(list)).toEqual(["live"]); + expect(read).toEqual(["user_2", "user_2"]); }); test("sends the typed message for the open friend and clears the input", () => { @@ -61,7 +80,7 @@ describe("DirectMessagePanel", () => { }); panel.open({ id: "user_2", username: "Kai" }, [], "user_1"); - const form = panel.element.children[2] as unknown as FakeElement; + const form = panel.element.children[3] as unknown as FakeElement; const input = form.children[0] as FakeElement; input.value = " yo "; form.dispatch("submit", { preventDefault() {} }); @@ -79,15 +98,127 @@ describe("DirectMessagePanel", () => { }); panel.open({ id: "user_2", username: "Kai" }, [], "user_1"); - const form = panel.element.children[2] as unknown as FakeElement; + const form = panel.element.children[3] as unknown as FakeElement; const input = form.children[0] as FakeElement; input.value = " try again "; form.dispatch("submit", { preventDefault() {} }); expect(input.value).toBe(" try again "); }); + + test("emits local typing changes and clears them after send", () => { + installDocument(); + const typing: Array<{ friendId: string; isTyping: boolean }> = []; + const panel = new DirectMessagePanel({ + onSend: () => undefined, + onTypingChange(friendId, isTyping) { + typing.push({ friendId, isTyping }); + }, + }); + panel.open({ id: "user_2", username: "Kai" }, [], "user_1"); + + const form = panel.element.children[3] as unknown as FakeElement; + const input = form.children[0] as FakeElement; + input.value = "hey"; + input.dispatch("input", {}); + input.value = "hey there"; + input.dispatch("input", {}); + form.dispatch("submit", { preventDefault() {} }); + + expect(typing).toEqual([ + { friendId: "user_2", isTyping: true }, + { friendId: "user_2", isTyping: false }, + ]); + }); + + test("shows incoming typing state for the open conversation", () => { + installDocument(); + const panel = new DirectMessagePanel({ onSend: () => undefined }); + panel.open({ id: "user_2", username: "Kai" }, [], "user_1"); + const typingStatus = panel.element.children[2] as unknown as FakeElement; + + expect(panel.setFriendTyping("user_3", true)).toBe(false); + expect(panel.setFriendTyping("user_2", true)).toBe(true); + expect(typingStatus.textContent).toBe("Kai is typing"); + expect(typingStatus.classList.contains("visible")).toBe(true); + + expect(panel.append(dm({ text: "sent" }))).toBe(true); + expect(typingStatus.textContent).toBe(""); + expect(typingStatus.classList.contains("visible")).toBe(false); + }); + + test("marks own messages read from receipts", () => { + installDocument(); + const panel = new DirectMessagePanel({ onSend: () => undefined }); + panel.open( + { id: "user_2", username: "Kai" }, + [dm({ id: "dm_own", fromUserId: "user_1", text: "hey" })], + "user_1", + ); + const list = panel.element.children[1] as unknown as FakeElement; + + expect(list.children[0]?.dataset.read).toBe("false"); + expect(panel.markRead(["dm_other"])).toBe(false); + expect(panel.markRead(["dm_own"])).toBe(true); + expect(list.children[0]?.dataset.read).toBe("true"); + }); + + test("updates edited and deleted messages", () => { + installDocument(); + const panel = new DirectMessagePanel({ onSend: () => undefined }); + panel.open( + { id: "user_2", username: "Kai" }, + [dm({ id: "dm_own", fromUserId: "user_1", text: "first" })], + "user_1", + ); + const list = panel.element.children[1] as unknown as FakeElement; + + expect(panel.updateEdited({ id: "dm_own", text: "second", editedAt: "2026-06-13" })).toBe(true); + expect(messageTexts(list)).toEqual(["second (edited)"]); + expect(list.children[0]?.dataset.edited).toBe("true"); + + expect(panel.markDeleted("dm_own")).toBe(true); + expect(messageTexts(list)).toEqual(["Message deleted"]); + expect(list.children[0]?.dataset.deleted).toBe("true"); + expect(list.children[0]?.classList.contains("dm-message-deleted")).toBe(true); + }); + + test("routes edit and delete message actions", () => { + installDocument(); + const edited: Array<{ messageId: string; text: string }> = []; + const deleted: string[] = []; + globalThis.prompt = (() => "edited text") as typeof prompt; + const panel = new DirectMessagePanel({ + onSend: () => undefined, + onEdit(messageId, text) { + edited.push({ messageId, text }); + return undefined; + }, + onDelete(messageId) { + deleted.push(messageId); + return undefined; + }, + }); + panel.open( + { id: "user_2", username: "Kai" }, + [dm({ id: "dm_own", fromUserId: "user_1", text: "first" })], + "user_1", + ); + const list = panel.element.children[1] as unknown as FakeElement; + const actions = list.children[0]?.children[1]; + + (actions?.children[0] as FakeElement | undefined)?.dispatch("click", {}); + (actions?.children[1] as FakeElement | undefined)?.dispatch("click", {}); + + expect(edited).toEqual([{ messageId: "dm_own", text: "edited text" }]); + expect(deleted).toEqual(["dm_own"]); + }); }); +function messageTexts(list: FakeElement): string[] { + return list.children.map((item) => item.children[0]?.textContent ?? ""); +} + function installDocument() { Object.defineProperty(globalThis, "document", { configurable: true, @@ -112,6 +243,7 @@ type FakeEvent = { preventDefault?: () => void; target?: FakeElement }; class FakeElement { readonly children: FakeElement[] = []; readonly classList = new FakeClassList(this); + readonly dataset: Record = {}; readonly listeners = new Map void>>(); autocomplete = ""; className = ""; @@ -155,6 +287,26 @@ class FakeElement { this.parentElement?.dispatch(type, event); } + closest(selector: string): FakeElement | undefined { + if ( + selector === "button[data-edit-message-id]" && + this.tagName === "button" && + this.dataset.editMessageId + ) { + return this; + } + + if ( + selector === "button[data-delete-message-id]" && + this.tagName === "button" && + this.dataset.deleteMessageId + ) { + return this; + } + + return this.parentElement?.closest(selector); + } + focus(): void {} } @@ -173,6 +325,19 @@ class FakeClassList { return this.getClasses().includes(className); } + toggle(className: string, force?: boolean): boolean { + const hasClass = this.contains(className); + const shouldAdd = force ?? !hasClass; + + if (shouldAdd && !hasClass) { + this.add(className); + } else if (!shouldAdd && hasClass) { + this.remove(className); + } + + return shouldAdd; + } + private getClasses(): string[] { return this.element.className.split(" ").filter(Boolean); } diff --git a/apps/client/src/ui/DirectMessagePanel.ts b/apps/client/src/ui/DirectMessagePanel.ts index 98c45be..3e67f01 100644 --- a/apps/client/src/ui/DirectMessagePanel.ts +++ b/apps/client/src/ui/DirectMessagePanel.ts @@ -2,6 +2,10 @@ import type { DirectMessage } from "@tilezo/protocol/messages"; type DirectMessagePanelOptions = { onSend: (friendId: string, text: string) => boolean | undefined; + onTypingChange?: (friendId: string, isTyping: boolean) => void; + onRead?: (friendId: string) => void; + onEdit?: (messageId: string, text: string) => boolean | undefined; + onDelete?: (messageId: string) => boolean | undefined; }; type Conversation = { @@ -15,9 +19,13 @@ export class DirectMessagePanel { private readonly title = document.createElement("h2"); private readonly messageList = document.createElement("div"); + private readonly typingStatus = document.createElement("p"); private readonly form = document.createElement("form"); private readonly input = document.createElement("input"); + private typingTimeout?: ReturnType; + private isTyping = false; private conversation?: Conversation; + private readonly messageElements = new Map(); constructor(private readonly options: DirectMessagePanelOptions) { this.element.className = "dm-panel hidden"; @@ -36,6 +44,8 @@ export class DirectMessagePanel { closeButton.addEventListener("click", () => this.hide()); this.messageList.className = "dm-list"; + this.typingStatus.className = "dm-typing"; + this.messageList.addEventListener("click", (event) => this.handleMessageAction(event)); this.form.className = "dm-form"; this.input.type = "text"; @@ -60,11 +70,13 @@ export class DirectMessagePanel { } this.input.value = ""; + this.setOwnTyping(false); }); + this.input.addEventListener("input", () => this.handleInputChanged()); actions.append(closeButton); header.append(this.title, actions); - this.element.append(header, this.messageList, this.form); + this.element.append(header, this.messageList, this.typingStatus, this.form); } open( @@ -72,15 +84,19 @@ export class DirectMessagePanel { history: DirectMessage[], selfUserId: string, ): void { + this.setOwnTyping(false); this.conversation = { friendId: friend.id, friendName: friend.username, selfUserId }; this.title.textContent = `Chat with ${friend.username}`; this.messageList.replaceChildren(); + this.messageElements.clear(); + this.setFriendTyping(friend.id, false); for (const message of history) { this.renderMessage(message); } this.element.classList.remove("hidden"); + this.options.onRead?.(friend.id); this.scrollToLatest(); this.input.focus(); } @@ -92,19 +108,85 @@ export class DirectMessagePanel { } this.renderMessage(message); + if (message.fromUserId === this.conversation.selfUserId) { + this.setOwnTyping(false); + } else { + this.setFriendTyping(message.fromUserId, false); + this.options.onRead?.(message.fromUserId); + } this.scrollToLatest(); return true; } hide(): void { + this.setOwnTyping(false); this.element.classList.add("hidden"); this.conversation = undefined; + this.typingStatus.textContent = ""; + this.typingStatus.classList.remove("visible"); } isOpenFor(friendId: string): boolean { return !this.isHidden() && this.conversation?.friendId === friendId; } + setFriendTyping(friendId: string, isTyping: boolean): boolean { + if (!this.conversation || this.conversation.friendId !== friendId || this.isHidden()) { + return false; + } + + this.typingStatus.textContent = isTyping ? `${this.conversation.friendName} is typing` : ""; + this.typingStatus.classList.toggle("visible", isTyping); + return true; + } + + markRead(messageIds: string[]): boolean { + if (!this.conversation || this.isHidden()) { + return false; + } + + let updated = false; + + for (const messageId of messageIds) { + const element = this.messageElements.get(messageId); + + if (element) { + element.dataset.read = "true"; + updated = true; + } + } + + return updated; + } + + updateEdited(message: { id: string; text: string; editedAt: string }): boolean { + const element = this.messageElements.get(message.id); + + if (!element || element.dataset.deleted === "true") { + return false; + } + + element.dataset.text = message.text; + element.dataset.edited = "true"; + this.setMessageBody(element, formatMessageText(message.text, message.editedAt)); + return true; + } + + markDeleted(messageId: string): boolean { + const element = this.messageElements.get(messageId); + + if (!element) { + return false; + } + + element.dataset.deleted = "true"; + element.dataset.text = ""; + element.classList.add("dm-message-deleted"); + this.setMessageBody(element, "Message deleted"); + element.replaceChildren(element.children[0] as HTMLElement); + return true; + } + private belongsToConversation(message: DirectMessage): boolean { const { friendId, selfUserId } = this.conversation ?? { friendId: "", selfUserId: "" }; return ( @@ -116,16 +198,125 @@ export class DirectMessagePanel { private renderMessage(message: DirectMessage): void { const mine = message.fromUserId === this.conversation?.selfUserId; const item = document.createElement("div"); + const body = document.createElement("span"); item.className = mine ? "dm-message dm-message-mine" : "dm-message dm-message-theirs"; - item.textContent = message.text; + item.dataset.messageId = message.id; + item.dataset.read = mine && message.readAt ? "true" : "false"; + item.dataset.text = message.deletedAt ? "" : message.text; + item.dataset.edited = message.editedAt ? "true" : "false"; + item.dataset.deleted = message.deletedAt ? "true" : "false"; + body.className = "dm-message-text"; + body.textContent = formatMessageText(message.text, message.editedAt, message.deletedAt); + item.append(body); + + if (message.deletedAt) { + item.classList.add("dm-message-deleted"); + } else if (mine) { + item.append(this.createMessageActions(message.id)); + } + this.messageList.append(item); + this.messageElements.set(message.id, item); + } + + private createMessageActions(messageId: string): HTMLElement { + const actions = document.createElement("span"); + const editButton = document.createElement("button"); + const deleteButton = document.createElement("button"); + + actions.className = "dm-message-actions"; + editButton.type = "button"; + editButton.className = "dm-message-action"; + editButton.dataset.editMessageId = messageId; + editButton.textContent = "Edit"; + deleteButton.type = "button"; + deleteButton.className = "dm-message-action"; + deleteButton.dataset.deleteMessageId = messageId; + deleteButton.textContent = "Delete"; + actions.append(editButton, deleteButton); + return actions; + } + + private handleMessageAction(event: Event): void { + const target = event.target as Element | null; + const editButton = target?.closest("button[data-edit-message-id]"); + const deleteButton = target?.closest("button[data-delete-message-id]"); + + if (editButton) { + const messageId = editButton.dataset.editMessageId ?? ""; + const element = this.messageElements.get(messageId); + const currentText = element?.dataset.text ?? ""; + const nextText = prompt("Edit message", currentText)?.trim(); + + if (!messageId || !nextText || nextText === currentText) { + return; + } + + this.options.onEdit?.(messageId, nextText); + return; + } + + if (deleteButton) { + const messageId = deleteButton.dataset.deleteMessageId ?? ""; + + if (messageId) { + this.options.onDelete?.(messageId); + } + } + } + + private setMessageBody(element: HTMLElement, text: string): void { + const body = element.children[0] as HTMLElement | undefined; + + if (body) { + body.textContent = text; + } } private scrollToLatest(): void { this.messageList.scrollTop = this.messageList.scrollHeight; } + private handleInputChanged(): void { + const hasText = this.input.value.trim().length > 0; + + if (!hasText) { + this.setOwnTyping(false); + return; + } + + this.setOwnTyping(true); + this.typingTimeout = setTimeout(() => { + this.setOwnTyping(false); + }, 1800); + } + + private setOwnTyping(isTyping: boolean): void { + if (this.typingTimeout) { + clearTimeout(this.typingTimeout); + this.typingTimeout = undefined; + } + + if (this.isTyping === isTyping) { + return; + } + + this.isTyping = isTyping; + + if (this.conversation) { + this.options.onTypingChange?.(this.conversation.friendId, isTyping); + } + } + private isHidden(): boolean { return this.element.classList.contains("hidden"); } } + +function formatMessageText(text: string, editedAt?: string, deletedAt?: string): string { + if (deletedAt) { + return "Message deleted"; + } + + return editedAt ? `${text} (edited)` : text; +} diff --git a/apps/client/src/ui/FriendsPanel.test.ts b/apps/client/src/ui/FriendsPanel.test.ts index d857669..a039d24 100644 --- a/apps/client/src/ui/FriendsPanel.test.ts +++ b/apps/client/src/ui/FriendsPanel.test.ts @@ -23,6 +23,7 @@ describe("FriendsPanel", () => { refreshes += 1; }, onRemove() {}, + onBlock() {}, }); panel.show(); @@ -41,6 +42,7 @@ describe("FriendsPanel", () => { installDocument(); const joined: string[] = []; const messaged: string[] = []; + const blocked: string[] = []; const removed: string[] = []; const panel = new FriendsPanel({ onAdd() {}, @@ -54,34 +56,48 @@ describe("FriendsPanel", () => { onRemove(friendId) { removed.push(friendId); }, + onBlock(friend) { + blocked.push(friend.id); + }, }); - panel.setFriends([ - { - id: "user_2", - username: "Kai", - appearance: DEFAULT_AVATAR_APPEARANCE, - online: true, - roomId: "studio", - canJoinRoom: true, - }, - ]); + panel.setFriends( + [ + { + id: "user_2", + username: "Kai", + appearance: DEFAULT_AVATAR_APPEARANCE, + online: true, + roomId: "studio", + canJoinRoom: true, + }, + ], + new Map([["user_2", 3]]), + ); const item = getList(panel).children[0]; const details = item?.children[1]; const actions = item?.children[2]; expect(item?.className).toBe("friend-item online"); - expect(details?.children[0]?.textContent).toBe("Kai"); + expect(details?.children[0]?.children[0]?.textContent).toBe("Kai"); + expect(details?.children[0]?.children[1]?.textContent).toBe("3"); + expect(details?.children[0]?.children[1]?.hidden).toBe(false); expect(details?.children[1]?.textContent).toBe("online in studio"); (actions?.children[0] as FakeElement | undefined)?.dispatch("click", {}); (actions?.children[1] as FakeElement | undefined)?.dispatch("click", {}); (actions?.children[2] as FakeElement | undefined)?.dispatch("click", {}); + (actions?.children[3] as FakeElement | undefined)?.dispatch("click", {}); expect(joined).toEqual(["studio"]); expect(messaged).toEqual(["user_2"]); + expect(blocked).toEqual(["user_2"]); expect(removed).toEqual(["user_2"]); + + panel.setUnreadCount("user_2", 0); + const rerenderedDetails = getList(panel).children[0]?.children[1]; + expect(rerenderedDetails?.children[0]?.children[1]?.hidden).toBe(true); }); test("destroys avatar previews before rerendering friends", () => { @@ -106,6 +122,7 @@ describe("FriendsPanel", () => { onMessage() {}, onRefresh() {}, onRemove() {}, + onBlock() {}, }); panel.setFriends([ @@ -175,6 +192,7 @@ class FakeElement { textContent = ""; type = ""; value = ""; + hidden = false; constructor(readonly tagName: string) {} @@ -231,6 +249,14 @@ class FakeElement { return this; } + if ( + selector === "button[data-block-friend-id]" && + this.tagName === "button" && + this.dataset.blockFriendId + ) { + return this; + } + return this.parentElement?.closest(selector); } diff --git a/apps/client/src/ui/FriendsPanel.ts b/apps/client/src/ui/FriendsPanel.ts index 4078eb3..c60305c 100644 --- a/apps/client/src/ui/FriendsPanel.ts +++ b/apps/client/src/ui/FriendsPanel.ts @@ -8,6 +8,7 @@ type FriendsPanelOptions = { onMessage: (friend: FriendSummary) => void; onRefresh: () => void; onRemove: (friendId: string) => void; + onBlock: (friend: FriendSummary) => void; }; type AvatarPreviewLike = Pick; @@ -23,6 +24,7 @@ export class FriendsPanel { private readonly closeButton = document.createElement("button"); private readonly previewDestroyers: (() => void)[] = []; private friends: FriendSummary[] = []; + private unreadCounts = new Map(); constructor(private readonly options: FriendsPanelOptions) { this.element.className = "friends-panel hidden"; @@ -73,6 +75,7 @@ export class FriendsPanel { const target = event.target as Element | null; const joinButton = target?.closest("button[data-room-id]"); const messageButton = target?.closest("button[data-message-friend-id]"); + const blockButton = target?.closest("button[data-block-friend-id]"); const removeButton = target?.closest("button[data-friend-id]"); if (joinButton && !joinButton.disabled) { @@ -90,6 +93,15 @@ export class FriendsPanel { return; } + if (blockButton) { + const friend = this.friends.find((f) => f.id === blockButton.dataset.blockFriendId); + + if (friend) { + this.options.onBlock(friend); + } + return; + } + if (removeButton && !removeButton.disabled) { this.options.onRemove(removeButton.dataset.friendId ?? ""); } @@ -110,13 +122,26 @@ export class FriendsPanel { this.element.classList.add("hidden"); } - setFriends(friends: FriendSummary[]): void { + setFriends(friends: FriendSummary[], unreadCounts?: Map): void { this.friends = friends; + if (unreadCounts) { + this.unreadCounts = new Map(unreadCounts); + } this.message.textContent = ""; this.message.classList.remove("visible"); this.render(); } + setUnreadCount(friendId: string, count: number): void { + if (count > 0) { + this.unreadCounts.set(friendId, count); + } else { + this.unreadCounts.delete(friendId); + } + + this.render(); + } + showError(message: string): void { this.message.textContent = message; this.message.classList.add("visible"); @@ -143,11 +168,14 @@ export class FriendsPanel { const item = document.createElement("article"); const preview = this.options.createAvatarPreview?.() ?? new AvatarPreview(document); const details = document.createElement("div"); + const nameRow = document.createElement("div"); const name = document.createElement("strong"); + const unreadBadge = document.createElement("span"); const meta = document.createElement("span"); const actions = document.createElement("div"); const joinButton = document.createElement("button"); const messageButton = document.createElement("button"); + const blockButton = document.createElement("button"); const removeButton = document.createElement("button"); item.className = friend.online ? "friend-item online" : "friend-item"; @@ -156,6 +184,7 @@ export class FriendsPanel { void preview.mount(); this.previewDestroyers.push(() => preview.destroy()); details.className = "friend-details"; + nameRow.className = "friend-name-row"; actions.className = "friend-actions"; joinButton.type = "button"; joinButton.className = "primary-button friend-join-button"; @@ -166,20 +195,29 @@ export class FriendsPanel { messageButton.className = "secondary-button friend-message-button"; messageButton.dataset.messageFriendId = friend.id; messageButton.textContent = "Message"; + blockButton.type = "button"; + blockButton.className = "secondary-button friend-block-button"; + blockButton.dataset.blockFriendId = friend.id; + blockButton.textContent = "Block"; removeButton.type = "button"; removeButton.className = "secondary-button friend-remove-button"; removeButton.dataset.friendId = friend.id; removeButton.textContent = "Remove"; name.textContent = friend.username; + const unreadCount = this.unreadCounts.get(friend.id) ?? 0; + unreadBadge.className = "friend-unread-badge"; + unreadBadge.textContent = unreadCount > 99 ? "99+" : unreadCount.toString(); + unreadBadge.hidden = unreadCount === 0; meta.textContent = friend.online ? friend.roomId ? `online in ${friend.roomId}` : "online" : "offline"; - details.append(name, meta); - actions.append(joinButton, messageButton, removeButton); + nameRow.append(name, unreadBadge); + details.append(nameRow, meta); + actions.append(joinButton, messageButton, blockButton, removeButton); item.append(preview.element, details, actions); return item; } diff --git a/apps/server/src/blocks/blocks.test.ts b/apps/server/src/blocks/blocks.test.ts new file mode 100644 index 0000000..2ba642b --- /dev/null +++ b/apps/server/src/blocks/blocks.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { DEFAULT_AVATAR_APPEARANCE } from "@tilezo/protocol"; +import { BlockError, BlockService, type BlockStore, DrizzleBlockStore } from "./blocks"; + +function createStore(): BlockStore & { pairs: Set } { + const pairs = new Set(); + return { + pairs, + async blockUser(blockerUserId, blockedUserId) { + pairs.add(pairKey(blockerUserId, blockedUserId)); + }, + async unblockUser(blockerUserId, blockedUserId) { + pairs.delete(pairKey(blockerUserId, blockedUserId)); + }, + async isBlocked(blockerUserId, blockedUserId) { + return pairs.has(pairKey(blockerUserId, blockedUserId)); + }, + async isBlockedEitherDirection(userId, otherUserId) { + return pairs.has(pairKey(userId, otherUserId)) || pairs.has(pairKey(otherUserId, userId)); + }, + async listBlockedUsers() { + return []; + }, + }; +} + +describe("BlockService", () => { + test("blocks, unblocks, and checks either direction", async () => { + const store = createStore(); + const service = new BlockService(store); + + await service.block("user_1", "user_2"); + + expect(await service.isBlocked("user_1", "user_2")).toBe(true); + expect(await service.isBlockedEitherDirection("user_2", "user_1")).toBe(true); + + await service.unblock("user_1", "user_2"); + expect(await service.isBlockedEitherDirection("user_1", "user_2")).toBe(false); + }); + + test("rejects self-blocks", async () => { + const service = new BlockService(createStore()); + + await expect(service.block("user_1", "user_1")).rejects.toBeInstanceOf(BlockError); + }); +}); + +describe("DrizzleBlockStore", () => { + test("maps blocked user rows", async () => { + const row = { + id: "user_2", + username: "Kai", + appearance: DEFAULT_AVATAR_APPEARANCE, + blockedAt: new Date("2026-06-13T00:00:00.000Z"), + }; + const store = new DrizzleBlockStore(queryDouble([[row]])); + + await expect(store.listBlockedUsers("user_1")).resolves.toEqual([ + { + id: "user_2", + username: "Kai", + appearance: DEFAULT_AVATAR_APPEARANCE, + blockedAt: "2026-06-13T00:00:00.000Z", + }, + ]); + }); + + test("checks blocked state", async () => { + const store = new DrizzleBlockStore(queryDouble([[{ blockerUserId: "user_1" }], []])); + + await expect(store.isBlocked("user_1", "user_2")).resolves.toBe(true); + await expect(store.isBlockedEitherDirection("user_1", "user_2")).resolves.toBe(false); + }); +}); + +function pairKey(blockerUserId: string, blockedUserId: string): string { + return `${blockerUserId}:${blockedUserId}`; +} + +function queryDouble( + results: unknown[][] = [], + // biome-ignore lint/suspicious/noExplicitAny: a structural stand-in for the Drizzle database. +): any { + let index = 0; + const chain: Record = { + // biome-ignore lint/suspicious/noThenProperty: Drizzle query builders are awaitable and chainable. + then(resolve: (value: unknown) => unknown, reject?: (reason: unknown) => unknown) { + return Promise.resolve(results[index++] ?? []).then(resolve, reject); + }, + }; + + for (const method of [ + "select", + "from", + "where", + "orderBy", + "limit", + "values", + "insert", + "onConflictDoNothing", + "delete", + "innerJoin", + ]) { + chain[method] = () => chain; + } + + return chain; +} diff --git a/apps/server/src/blocks/blocks.ts b/apps/server/src/blocks/blocks.ts new file mode 100644 index 0000000..bd0d8b8 --- /dev/null +++ b/apps/server/src/blocks/blocks.ts @@ -0,0 +1,138 @@ +import type { AvatarAppearance } from "@tilezo/protocol"; +import { and, asc, eq, or } from "drizzle-orm"; +import type { TilezoDatabase } from "../db/db"; +import { blockedUsers, users } from "../db/schema"; + +export type BlockedUserSummary = { + id: string; + username: string; + appearance: AvatarAppearance; + blockedAt: string; +}; + +type BlockedUserRow = { + id: string; + username: string; + appearance: AvatarAppearance; + blockedAt: Date; +}; + +export type BlockStore = { + blockUser(blockerUserId: string, blockedUserId: string): Promise; + unblockUser(blockerUserId: string, blockedUserId: string): Promise; + isBlocked(blockerUserId: string, blockedUserId: string): Promise; + isBlockedEitherDirection(userId: string, otherUserId: string): Promise; + listBlockedUsers(blockerUserId: string): Promise; +}; + +export class BlockError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + } +} + +export class BlockService { + constructor(private readonly store: BlockStore) {} + + async block(userId: string, blockedUserId: string): Promise { + if (userId === blockedUserId) { + throw new BlockError("INVALID_BLOCK", "You cannot block yourself"); + } + + await this.store.blockUser(userId, blockedUserId); + } + + unblock(userId: string, blockedUserId: string): Promise { + return this.store.unblockUser(userId, blockedUserId); + } + + isBlocked(blockerUserId: string, blockedUserId: string): Promise { + return this.store.isBlocked(blockerUserId, blockedUserId); + } + + isBlockedEitherDirection(userId: string, otherUserId: string): Promise { + return this.store.isBlockedEitherDirection(userId, otherUserId); + } + + list(userId: string): Promise { + return this.store.listBlockedUsers(userId); + } +} + +export class DrizzleBlockStore implements BlockStore { + constructor(private readonly db: TilezoDatabase) {} + + async blockUser(blockerUserId: string, blockedUserId: string): Promise { + await this.db + .insert(blockedUsers) + .values({ blockerUserId, blockedUserId }) + .onConflictDoNothing(); + } + + async unblockUser(blockerUserId: string, blockedUserId: string): Promise { + await this.db + .delete(blockedUsers) + .where( + and( + eq(blockedUsers.blockerUserId, blockerUserId), + eq(blockedUsers.blockedUserId, blockedUserId), + ), + ); + } + + async isBlocked(blockerUserId: string, blockedUserId: string): Promise { + const [row] = await this.db + .select({ blockerUserId: blockedUsers.blockerUserId }) + .from(blockedUsers) + .where( + and( + eq(blockedUsers.blockerUserId, blockerUserId), + eq(blockedUsers.blockedUserId, blockedUserId), + ), + ) + .limit(1); + return Boolean(row); + } + + async isBlockedEitherDirection(userId: string, otherUserId: string): Promise { + const [row] = await this.db + .select({ blockerUserId: blockedUsers.blockerUserId }) + .from(blockedUsers) + .where( + or( + and(eq(blockedUsers.blockerUserId, userId), eq(blockedUsers.blockedUserId, otherUserId)), + and(eq(blockedUsers.blockerUserId, otherUserId), eq(blockedUsers.blockedUserId, userId)), + ), + ) + .limit(1); + return Boolean(row); + } + + async listBlockedUsers(blockerUserId: string): Promise { + const rows = await this.db + .select({ + id: users.id, + username: users.username, + appearance: users.appearance, + blockedAt: blockedUsers.createdAt, + }) + .from(blockedUsers) + .innerJoin(users, eq(users.id, blockedUsers.blockedUserId)) + .where(eq(blockedUsers.blockerUserId, blockerUserId)) + .orderBy(asc(users.usernameKey)); + + return rows.map(toSummary); + } +} + +function toSummary(row: BlockedUserRow): BlockedUserSummary { + return { + id: row.id, + username: row.username, + appearance: row.appearance, + blockedAt: row.blockedAt.toISOString(), + }; +} diff --git a/apps/server/src/db/migrations/0011_smart_william_stryker.sql b/apps/server/src/db/migrations/0011_smart_william_stryker.sql new file mode 100644 index 0000000..53cc17f --- /dev/null +++ b/apps/server/src/db/migrations/0011_smart_william_stryker.sql @@ -0,0 +1,11 @@ +CREATE TABLE "blocked_users" ( + "blocker_user_id" text NOT NULL, + "blocked_user_id" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "blocked_users_blocker_user_id_blocked_user_id_pk" PRIMARY KEY("blocker_user_id","blocked_user_id"), + CONSTRAINT "blocked_users_no_self_check" CHECK ("blocked_users"."blocker_user_id" <> "blocked_users"."blocked_user_id") +); +--> statement-breakpoint +ALTER TABLE "blocked_users" ADD CONSTRAINT "blocked_users_blocker_user_id_users_id_fk" FOREIGN KEY ("blocker_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "blocked_users" ADD CONSTRAINT "blocked_users_blocked_user_id_users_id_fk" FOREIGN KEY ("blocked_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "blocked_users_blocked_user_id_idx" ON "blocked_users" USING btree ("blocked_user_id"); \ No newline at end of file diff --git a/apps/server/src/db/migrations/0012_lively_supernaut.sql b/apps/server/src/db/migrations/0012_lively_supernaut.sql new file mode 100644 index 0000000..cbb7128 --- /dev/null +++ b/apps/server/src/db/migrations/0012_lively_supernaut.sql @@ -0,0 +1,2 @@ +ALTER TABLE "direct_messages" ADD COLUMN "read_at" timestamp with time zone;--> statement-breakpoint +CREATE INDEX "direct_messages_unread_idx" ON "direct_messages" USING btree ("recipient_user_id","read_at","created_at"); \ No newline at end of file diff --git a/apps/server/src/db/migrations/0013_bouncy_whizzer.sql b/apps/server/src/db/migrations/0013_bouncy_whizzer.sql new file mode 100644 index 0000000..d687c07 --- /dev/null +++ b/apps/server/src/db/migrations/0013_bouncy_whizzer.sql @@ -0,0 +1,3 @@ +ALTER TABLE "direct_messages" ADD COLUMN "edited_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "direct_messages" ADD COLUMN "deleted_at" timestamp with time zone;--> statement-breakpoint +CREATE INDEX "direct_messages_deleted_idx" ON "direct_messages" USING btree ("deleted_at"); \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0011_snapshot.json b/apps/server/src/db/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..5e69be4 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0011_snapshot.json @@ -0,0 +1,750 @@ +{ + "id": "311ce1a6-c05c-4ed1-a0ab-5baed1d931dd", + "prevId": "ed7ab693-b9b2-49a2-872f-1d143752bb0f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.blocked_users": { + "name": "blocked_users", + "schema": "", + "columns": { + "blocker_user_id": { + "name": "blocker_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blocked_user_id": { + "name": "blocked_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blocked_users_blocked_user_id_idx": { + "name": "blocked_users_blocked_user_id_idx", + "columns": [ + { + "expression": "blocked_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocked_users_blocker_user_id_users_id_fk": { + "name": "blocked_users_blocker_user_id_users_id_fk", + "tableFrom": "blocked_users", + "tableTo": "users", + "columnsFrom": ["blocker_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocked_users_blocked_user_id_users_id_fk": { + "name": "blocked_users_blocked_user_id_users_id_fk", + "tableFrom": "blocked_users", + "tableTo": "users", + "columnsFrom": ["blocked_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blocked_users_blocker_user_id_blocked_user_id_pk": { + "name": "blocked_users_blocker_user_id_blocked_user_id_pk", + "columns": ["blocker_user_id", "blocked_user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "blocked_users_no_self_check": { + "name": "blocked_users_no_self_check", + "value": "\"blocked_users\".\"blocker_user_id\" <> \"blocked_users\".\"blocked_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.direct_messages": { + "name": "direct_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "direct_messages_pair_idx": { + "name": "direct_messages_pair_idx", + "columns": [ + { + "expression": "sender_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_recipient_idx": { + "name": "direct_messages_recipient_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "direct_messages_sender_user_id_users_id_fk": { + "name": "direct_messages_sender_user_id_users_id_fk", + "tableFrom": "direct_messages", + "tableTo": "users", + "columnsFrom": ["sender_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "direct_messages_recipient_user_id_users_id_fk": { + "name": "direct_messages_recipient_user_id_users_id_fk", + "tableFrom": "direct_messages", + "tableTo": "users", + "columnsFrom": ["recipient_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "direct_messages_no_self_check": { + "name": "direct_messages_no_self_check", + "value": "\"direct_messages\".\"sender_user_id\" <> \"direct_messages\".\"recipient_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.friendships": { + "name": "friendships", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "friend_user_id": { + "name": "friend_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "friendships_friend_user_id_idx": { + "name": "friendships_friend_user_id_idx", + "columns": [ + { + "expression": "friend_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "friendships_status_idx": { + "name": "friendships_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "friendships_user_id_users_id_fk": { + "name": "friendships_user_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendships_friend_user_id_users_id_fk": { + "name": "friendships_friend_user_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": ["friend_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendships_requested_by_user_id_users_id_fk": { + "name": "friendships_requested_by_user_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": ["requested_by_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "friendships_user_id_friend_user_id_pk": { + "name": "friendships_user_id_friend_user_id_pk", + "columns": ["user_id", "friend_user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "friendships_no_self_check": { + "name": "friendships_no_self_check", + "value": "\"friendships\".\"user_id\" <> \"friendships\".\"friend_user_id\"" + }, + "friendships_status_check": { + "name": "friendships_status_check", + "value": "\"friendships\".\"status\" IN ('pending', 'accepted')" + } + }, + "isRLSEnabled": false + }, + "public.room_items": { + "name": "room_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_type": { + "name": "item_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "x": { + "name": "x", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y": { + "name": "y", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "z": { + "name": "z", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rotation": { + "name": "rotation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_items_room_id_idx": { + "name": "room_items_room_id_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "room_items_room_id_position_idx": { + "name": "room_items_room_id_position_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "x", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "y", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "z", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "room_items_room_id_rooms_id_fk": { + "name": "room_items_room_id_rooms_id_fk", + "tableFrom": "room_items", + "tableTo": "rooms", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rooms": { + "name": "rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "access": { + "name": "access", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rooms_owner_user_id_idx": { + "name": "rooms_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rooms_visibility_name_id_idx": { + "name": "rooms_visibility_name_id_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rooms_owner_user_id_users_id_fk": { + "name": "rooms_owner_user_id_users_id_fk", + "tableFrom": "rooms", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rooms_slug_unique": { + "name": "rooms_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_room_sessions": { + "name": "user_room_sessions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_room_sessions_room_id_idx": { + "name": "user_room_sessions_room_id_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_room_sessions_user_id_users_id_fk": { + "name": "user_room_sessions_user_id_users_id_fk", + "tableFrom": "user_room_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_room_sessions_room_id_rooms_id_fk": { + "name": "user_room_sessions_room_id_rooms_id_fk", + "tableFrom": "user_room_sessions", + "tableTo": "rooms", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username_key": { + "name": "username_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "appearance": { + "name": "appearance", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"hair\":\"short\",\"hairColor\":\"#7a4424\",\"skinTone\":\"#f2c097\",\"shirt\":\"crew\",\"shirtColor\":\"#2f5f7f\",\"pants\":\"straight\",\"pantsColor\":\"#d2c294\",\"shoes\":\"boots\",\"shoesColor\":\"#5b4218\"}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_key_unique": { + "name": "users_username_key_unique", + "nullsNotDistinct": false, + "columns": ["username_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/src/db/migrations/meta/0012_snapshot.json b/apps/server/src/db/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..ad304c9 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0012_snapshot.json @@ -0,0 +1,783 @@ +{ + "id": "6227311c-a38d-4649-8c1a-56d5b2e1fe53", + "prevId": "311ce1a6-c05c-4ed1-a0ab-5baed1d931dd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.blocked_users": { + "name": "blocked_users", + "schema": "", + "columns": { + "blocker_user_id": { + "name": "blocker_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blocked_user_id": { + "name": "blocked_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blocked_users_blocked_user_id_idx": { + "name": "blocked_users_blocked_user_id_idx", + "columns": [ + { + "expression": "blocked_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocked_users_blocker_user_id_users_id_fk": { + "name": "blocked_users_blocker_user_id_users_id_fk", + "tableFrom": "blocked_users", + "tableTo": "users", + "columnsFrom": ["blocker_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocked_users_blocked_user_id_users_id_fk": { + "name": "blocked_users_blocked_user_id_users_id_fk", + "tableFrom": "blocked_users", + "tableTo": "users", + "columnsFrom": ["blocked_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blocked_users_blocker_user_id_blocked_user_id_pk": { + "name": "blocked_users_blocker_user_id_blocked_user_id_pk", + "columns": ["blocker_user_id", "blocked_user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "blocked_users_no_self_check": { + "name": "blocked_users_no_self_check", + "value": "\"blocked_users\".\"blocker_user_id\" <> \"blocked_users\".\"blocked_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.direct_messages": { + "name": "direct_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "direct_messages_pair_idx": { + "name": "direct_messages_pair_idx", + "columns": [ + { + "expression": "sender_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_recipient_idx": { + "name": "direct_messages_recipient_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_unread_idx": { + "name": "direct_messages_unread_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "direct_messages_sender_user_id_users_id_fk": { + "name": "direct_messages_sender_user_id_users_id_fk", + "tableFrom": "direct_messages", + "tableTo": "users", + "columnsFrom": ["sender_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "direct_messages_recipient_user_id_users_id_fk": { + "name": "direct_messages_recipient_user_id_users_id_fk", + "tableFrom": "direct_messages", + "tableTo": "users", + "columnsFrom": ["recipient_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "direct_messages_no_self_check": { + "name": "direct_messages_no_self_check", + "value": "\"direct_messages\".\"sender_user_id\" <> \"direct_messages\".\"recipient_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.friendships": { + "name": "friendships", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "friend_user_id": { + "name": "friend_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "friendships_friend_user_id_idx": { + "name": "friendships_friend_user_id_idx", + "columns": [ + { + "expression": "friend_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "friendships_status_idx": { + "name": "friendships_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "friendships_user_id_users_id_fk": { + "name": "friendships_user_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendships_friend_user_id_users_id_fk": { + "name": "friendships_friend_user_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": ["friend_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendships_requested_by_user_id_users_id_fk": { + "name": "friendships_requested_by_user_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": ["requested_by_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "friendships_user_id_friend_user_id_pk": { + "name": "friendships_user_id_friend_user_id_pk", + "columns": ["user_id", "friend_user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "friendships_no_self_check": { + "name": "friendships_no_self_check", + "value": "\"friendships\".\"user_id\" <> \"friendships\".\"friend_user_id\"" + }, + "friendships_status_check": { + "name": "friendships_status_check", + "value": "\"friendships\".\"status\" IN ('pending', 'accepted')" + } + }, + "isRLSEnabled": false + }, + "public.room_items": { + "name": "room_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_type": { + "name": "item_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "x": { + "name": "x", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y": { + "name": "y", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "z": { + "name": "z", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rotation": { + "name": "rotation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_items_room_id_idx": { + "name": "room_items_room_id_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "room_items_room_id_position_idx": { + "name": "room_items_room_id_position_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "x", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "y", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "z", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "room_items_room_id_rooms_id_fk": { + "name": "room_items_room_id_rooms_id_fk", + "tableFrom": "room_items", + "tableTo": "rooms", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rooms": { + "name": "rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "access": { + "name": "access", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rooms_owner_user_id_idx": { + "name": "rooms_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rooms_visibility_name_id_idx": { + "name": "rooms_visibility_name_id_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rooms_owner_user_id_users_id_fk": { + "name": "rooms_owner_user_id_users_id_fk", + "tableFrom": "rooms", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rooms_slug_unique": { + "name": "rooms_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_room_sessions": { + "name": "user_room_sessions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_room_sessions_room_id_idx": { + "name": "user_room_sessions_room_id_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_room_sessions_user_id_users_id_fk": { + "name": "user_room_sessions_user_id_users_id_fk", + "tableFrom": "user_room_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_room_sessions_room_id_rooms_id_fk": { + "name": "user_room_sessions_room_id_rooms_id_fk", + "tableFrom": "user_room_sessions", + "tableTo": "rooms", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username_key": { + "name": "username_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "appearance": { + "name": "appearance", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"hair\":\"short\",\"hairColor\":\"#7a4424\",\"skinTone\":\"#f2c097\",\"shirt\":\"crew\",\"shirtColor\":\"#2f5f7f\",\"pants\":\"straight\",\"pantsColor\":\"#d2c294\",\"shoes\":\"boots\",\"shoesColor\":\"#5b4218\"}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_key_unique": { + "name": "users_username_key_unique", + "nullsNotDistinct": false, + "columns": ["username_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/src/db/migrations/meta/0013_snapshot.json b/apps/server/src/db/migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..4793d49 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0013_snapshot.json @@ -0,0 +1,810 @@ +{ + "id": "81aa6c50-ef27-49ec-9b86-9f03884aa691", + "prevId": "6227311c-a38d-4649-8c1a-56d5b2e1fe53", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.blocked_users": { + "name": "blocked_users", + "schema": "", + "columns": { + "blocker_user_id": { + "name": "blocker_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blocked_user_id": { + "name": "blocked_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blocked_users_blocked_user_id_idx": { + "name": "blocked_users_blocked_user_id_idx", + "columns": [ + { + "expression": "blocked_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocked_users_blocker_user_id_users_id_fk": { + "name": "blocked_users_blocker_user_id_users_id_fk", + "tableFrom": "blocked_users", + "tableTo": "users", + "columnsFrom": ["blocker_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocked_users_blocked_user_id_users_id_fk": { + "name": "blocked_users_blocked_user_id_users_id_fk", + "tableFrom": "blocked_users", + "tableTo": "users", + "columnsFrom": ["blocked_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blocked_users_blocker_user_id_blocked_user_id_pk": { + "name": "blocked_users_blocker_user_id_blocked_user_id_pk", + "columns": ["blocker_user_id", "blocked_user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "blocked_users_no_self_check": { + "name": "blocked_users_no_self_check", + "value": "\"blocked_users\".\"blocker_user_id\" <> \"blocked_users\".\"blocked_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.direct_messages": { + "name": "direct_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "sender_user_id": { + "name": "sender_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "edited_at": { + "name": "edited_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "direct_messages_pair_idx": { + "name": "direct_messages_pair_idx", + "columns": [ + { + "expression": "sender_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_recipient_idx": { + "name": "direct_messages_recipient_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_unread_idx": { + "name": "direct_messages_unread_idx", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direct_messages_deleted_idx": { + "name": "direct_messages_deleted_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "direct_messages_sender_user_id_users_id_fk": { + "name": "direct_messages_sender_user_id_users_id_fk", + "tableFrom": "direct_messages", + "tableTo": "users", + "columnsFrom": ["sender_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "direct_messages_recipient_user_id_users_id_fk": { + "name": "direct_messages_recipient_user_id_users_id_fk", + "tableFrom": "direct_messages", + "tableTo": "users", + "columnsFrom": ["recipient_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "direct_messages_no_self_check": { + "name": "direct_messages_no_self_check", + "value": "\"direct_messages\".\"sender_user_id\" <> \"direct_messages\".\"recipient_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.friendships": { + "name": "friendships", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "friend_user_id": { + "name": "friend_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "friendships_friend_user_id_idx": { + "name": "friendships_friend_user_id_idx", + "columns": [ + { + "expression": "friend_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "friendships_status_idx": { + "name": "friendships_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "friendships_user_id_users_id_fk": { + "name": "friendships_user_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendships_friend_user_id_users_id_fk": { + "name": "friendships_friend_user_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": ["friend_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "friendships_requested_by_user_id_users_id_fk": { + "name": "friendships_requested_by_user_id_users_id_fk", + "tableFrom": "friendships", + "tableTo": "users", + "columnsFrom": ["requested_by_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "friendships_user_id_friend_user_id_pk": { + "name": "friendships_user_id_friend_user_id_pk", + "columns": ["user_id", "friend_user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "friendships_no_self_check": { + "name": "friendships_no_self_check", + "value": "\"friendships\".\"user_id\" <> \"friendships\".\"friend_user_id\"" + }, + "friendships_status_check": { + "name": "friendships_status_check", + "value": "\"friendships\".\"status\" IN ('pending', 'accepted')" + } + }, + "isRLSEnabled": false + }, + "public.room_items": { + "name": "room_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_type": { + "name": "item_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "x": { + "name": "x", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "y": { + "name": "y", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "z": { + "name": "z", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rotation": { + "name": "rotation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "state": { + "name": "state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_items_room_id_idx": { + "name": "room_items_room_id_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "room_items_room_id_position_idx": { + "name": "room_items_room_id_position_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "x", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "y", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "z", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "room_items_room_id_rooms_id_fk": { + "name": "room_items_room_id_rooms_id_fk", + "tableFrom": "room_items", + "tableTo": "rooms", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rooms": { + "name": "rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "access": { + "name": "access", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 25 + }, + "layout": { + "name": "layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rooms_owner_user_id_idx": { + "name": "rooms_owner_user_id_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rooms_visibility_name_id_idx": { + "name": "rooms_visibility_name_id_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "rooms_owner_user_id_users_id_fk": { + "name": "rooms_owner_user_id_users_id_fk", + "tableFrom": "rooms", + "tableTo": "users", + "columnsFrom": ["owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "rooms_slug_unique": { + "name": "rooms_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_room_sessions": { + "name": "user_room_sessions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_room_sessions_room_id_idx": { + "name": "user_room_sessions_room_id_idx", + "columns": [ + { + "expression": "room_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_room_sessions_user_id_users_id_fk": { + "name": "user_room_sessions_user_id_users_id_fk", + "tableFrom": "user_room_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_room_sessions_room_id_rooms_id_fk": { + "name": "user_room_sessions_room_id_rooms_id_fk", + "tableFrom": "user_room_sessions", + "tableTo": "rooms", + "columnsFrom": ["room_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username_key": { + "name": "username_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_version": { + "name": "token_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "appearance": { + "name": "appearance", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{\"hair\":\"short\",\"hairColor\":\"#7a4424\",\"skinTone\":\"#f2c097\",\"shirt\":\"crew\",\"shirtColor\":\"#2f5f7f\",\"pants\":\"straight\",\"pantsColor\":\"#d2c294\",\"shoes\":\"boots\",\"shoesColor\":\"#5b4218\"}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_key_unique": { + "name": "users_username_key_unique", + "nullsNotDistinct": false, + "columns": ["username_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json index 6f02ca0..9ba8a70 100644 --- a/apps/server/src/db/migrations/meta/_journal.json +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -78,6 +78,27 @@ "when": 1781391017000, "tag": "0010_friendship_requests", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1781431598898, + "tag": "0011_smart_william_stryker", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1781432320172, + "tag": "0012_lively_supernaut", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1781432687615, + "tag": "0013_bouncy_whizzer", + "breakpoints": true } ] } diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index 3111fa6..38a6137 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -110,6 +110,24 @@ export const friendships = pgTable( ], ); +export const blockedUsers = pgTable( + "blocked_users", + { + blockerUserId: text("blocker_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + blockedUserId: text("blocked_user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + }, + (table) => [ + primaryKey({ columns: [table.blockerUserId, table.blockedUserId] }), + index("blocked_users_blocked_user_id_idx").on(table.blockedUserId), + check("blocked_users_no_self_check", sql`${table.blockerUserId} <> ${table.blockedUserId}`), + ], +); + export const directMessages = pgTable( "direct_messages", { @@ -122,6 +140,9 @@ export const directMessages = pgTable( .references(() => users.id, { onDelete: "cascade" }), body: text("body").notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + readAt: timestamp("read_at", { withTimezone: true }), + editedAt: timestamp("edited_at", { withTimezone: true }), + deletedAt: timestamp("deleted_at", { withTimezone: true }), }, (table) => [ index("direct_messages_pair_idx").on( @@ -130,6 +151,8 @@ export const directMessages = pgTable( table.createdAt, ), index("direct_messages_recipient_idx").on(table.recipientUserId, table.createdAt), + index("direct_messages_unread_idx").on(table.recipientUserId, table.readAt, table.createdAt), + index("direct_messages_deleted_idx").on(table.deletedAt), check("direct_messages_no_self_check", sql`${table.senderUserId} <> ${table.recipientUserId}`), ], ); diff --git a/apps/server/src/http/router.test.ts b/apps/server/src/http/router.test.ts index 2090eb7..2fe22d8 100644 --- a/apps/server/src/http/router.test.ts +++ b/apps/server/src/http/router.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { DEFAULT_AVATAR_APPEARANCE } from "@tilezo/protocol"; import { AuthError, type AuthService } from "../auth/auth"; import { FixedWindowRateLimiter } from "../auth/rateLimit"; +import { BlockError, type BlockService } from "../blocks/blocks"; import { getConfig } from "../config"; import type { PersistenceStore } from "../db/persistence"; import { FriendError, type FriendService } from "../friends/friends"; @@ -45,6 +46,11 @@ function makeDeps(overrides: Partial = {}): RouterDeps { }), remove: async () => {}, } as unknown as FriendService, + blocks: { + list: async () => [], + block: async () => {}, + unblock: async () => {}, + } as unknown as BlockService, directMessages: { history: async () => [ { @@ -55,6 +61,7 @@ function makeDeps(overrides: Partial = {}): RouterDeps { sentAt: "2026-06-13T00:00:00.000Z", }, ], + unreadCounts: async () => [{ friendId: "user_2", count: 2 }], } as unknown as DirectMessageService, persistence: { listOwnedRooms: async () => [], @@ -407,6 +414,97 @@ describe("createHttpRouter", () => { expect(response.status).toBe(400); expect(await response.json()).toMatchObject({ error: { code: "NOT_FRIENDS" } }); }); + + test("returns unread direct message counts", async () => { + const route = createHttpRouter(makeDeps()); + + const response = await route( + request("/direct-messages/unread", { method: "GET", token: "good-token" }), + "ip", + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ unread: [{ friendId: "user_2", count: 2 }] }); + }); + }); + + describe("blocked users", () => { + test("blocks, lists, and unblocks users", async () => { + const blocked: string[] = []; + const unblocked: string[] = []; + const route = createHttpRouter( + makeDeps({ + blocks: { + list: async () => [ + { + id: "user_2", + username: "Kai", + appearance: DEFAULT_AVATAR_APPEARANCE, + blockedAt: "2026-06-13T00:00:00.000Z", + }, + ], + block: async (_userId: string, blockedUserId: string) => { + blocked.push(blockedUserId); + }, + unblock: async (_userId: string, blockedUserId: string) => { + unblocked.push(blockedUserId); + }, + } as unknown as BlockService, + }), + ); + + const post = await route( + request("/blocked-users", { + method: "POST", + token: "good-token", + body: { userId: "user_2" }, + }), + "ip", + ); + const list = await route( + request("/blocked-users", { method: "GET", token: "good-token" }), + "ip", + ); + const remove = await route( + request("/blocked-users/user_2", { method: "DELETE", token: "good-token" }), + "ip", + ); + + expect(post.status).toBe(200); + expect(list.status).toBe(200); + expect(await list.json()).toMatchObject({ blockedUsers: [{ id: "user_2" }] }); + expect(remove.status).toBe(200); + expect(blocked).toEqual(["user_2"]); + expect(unblocked).toEqual(["user_2"]); + }); + + test("rejects invalid and unauthenticated block requests", async () => { + const route = createHttpRouter( + makeDeps({ + blocks: { + list: async () => [], + block: async () => { + throw new BlockError("INVALID_BLOCK", "You cannot block yourself"); + }, + unblock: async () => {}, + } as unknown as BlockService, + }), + ); + + const unauthenticated = await route(request("/blocked-users", { method: "GET" }), "ip"); + const invalid = await route( + request("/blocked-users", { + method: "POST", + token: "good-token", + body: { userId: "user_1" }, + }), + "ip", + ); + + expect(unauthenticated.status).toBe(401); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toMatchObject({ error: { code: "INVALID_BLOCK" } }); + }); }); describe("rooms", () => { diff --git a/apps/server/src/http/router.ts b/apps/server/src/http/router.ts index 6250990..f2ef958 100644 --- a/apps/server/src/http/router.ts +++ b/apps/server/src/http/router.ts @@ -1,6 +1,7 @@ import { avatarAppearanceSchema } from "@tilezo/protocol"; import { AuthError, type AuthService, normalizeUsername } from "../auth/auth"; import type { FixedWindowRateLimiter } from "../auth/rateLimit"; +import { BlockError, type BlockService } from "../blocks/blocks"; import type { ServerConfig } from "../config"; import type { PersistenceStore } from "../db/persistence"; import { FriendError, type FriendService } from "../friends/friends"; @@ -21,6 +22,7 @@ export type RouterDeps = { metrics: Metrics; auth?: AuthService; friends?: FriendService; + blocks?: BlockService; directMessages?: DirectMessageService; persistence?: PersistenceStore; rooms: RoomManager; @@ -139,6 +141,14 @@ async function dispatch(ctx: RouteContext): Promise { return handleDirectMessageHistoryRequest(ctx); } + if (url.pathname === "/direct-messages/unread" && request.method === "GET") { + return handleDirectMessageUnreadRequest(ctx); + } + + if (url.pathname === "/blocked-users" || url.pathname.startsWith("/blocked-users/")) { + return handleBlockedUsersRequest(ctx); + } + if (url.pathname === "/friends" || url.pathname.startsWith("/friends/")) { return handleFriendsRequest(ctx); } @@ -394,6 +404,90 @@ async function handleClientEventRequest(ctx: RouteContext): Promise { return authJson({ ok: true }, 202); } +async function handleBlockedUsersRequest(ctx: RouteContext): Promise { + const { auth, blocks, requestLogger, url } = ctx; + + if (!auth || !blocks) { + requestLogger.warn("blocks.database_required"); + return authJson( + { error: { code: "DATABASE_REQUIRED", message: "Database is required for blocking" } }, + 503, + ); + } + + const user = await auth.verifyToken(readSessionToken(ctx.request) ?? ""); + + if (!user) { + requestLogger.warn("blocks.unauthenticated"); + return authJson( + { error: { code: "UNAUTHENTICATED", message: "Log in before managing blocks" } }, + 401, + ); + } + + try { + if (url.pathname === "/blocked-users" && ctx.request.method === "GET") { + return authJson({ blockedUsers: await blocks.list(user.id) }, 200); + } + + if (url.pathname === "/blocked-users" && ctx.request.method === "POST") { + const limited = enforceRateLimit( + ctx, + ctx.friendRateLimiter, + `user:${user.id}`, + "blocks.rate_limited", + "Too many block requests, try again shortly", + ); + if (limited) { + return limited; + } + + const body = await readJsonWithLimit(ctx.request, ctx.config.maxAuthBodyBytes); + + if (!body.ok) { + return badBody(body.reason, "INVALID_BLOCK", "Blocked user id is required"); + } + + const blockedUserId = (body.value as { userId?: unknown }).userId; + + if (typeof blockedUserId !== "string" || !blockedUserId.trim()) { + return authJson( + { error: { code: "INVALID_BLOCK", message: "Blocked user id is required" } }, + 400, + ); + } + + await blocks.block(user.id, blockedUserId.trim()); + requestLogger.info("blocks.added", { userId: user.id, blockedUserId }); + return authJson({ ok: true }, 200); + } + + if (url.pathname.startsWith("/blocked-users/") && ctx.request.method === "DELETE") { + const blockedUserId = decodeURIComponent(url.pathname.slice("/blocked-users/".length)); + + if (!blockedUserId) { + return authJson( + { error: { code: "INVALID_BLOCK", message: "Blocked user id is required" } }, + 400, + ); + } + + await blocks.unblock(user.id, blockedUserId); + requestLogger.info("blocks.removed", { userId: user.id, blockedUserId }); + return authJson({ ok: true }, 200); + } + + return authJson({ error: { code: "METHOD_NOT_ALLOWED", message: "Method not allowed" } }, 405); + } catch (error) { + if (error instanceof BlockError) { + return authJson({ error: { code: error.code, message: error.message } }, 400); + } + + requestLogger.error("blocks.failed", { userId: user.id, error }); + return authJson({ error: { code: "BLOCKS_FAILED", message: "Block request failed" } }, 400); + } +} + async function handleFriendsRequest(ctx: RouteContext): Promise { const { auth, friends, requestLogger, url } = ctx; @@ -525,6 +619,36 @@ async function handleDirectMessageHistoryRequest(ctx: RouteContext): Promise { + const { auth, directMessages, requestLogger } = ctx; + + if (!auth || !directMessages) { + return authJson( + { error: { code: "DATABASE_REQUIRED", message: "Database is required for messages" } }, + 503, + ); + } + + const user = await auth.verifyToken(readSessionToken(ctx.request) ?? ""); + + if (!user) { + return authJson( + { error: { code: "UNAUTHENTICATED", message: "Log in before reading messages" } }, + 401, + ); + } + + try { + return authJson({ unread: await directMessages.unreadCounts(user.id) }, 200); + } catch (error) { + requestLogger.error("dm.unread.failed", { userId: user.id, error }); + return authJson( + { error: { code: "DM_FAILED", message: "Could not load unread messages" } }, + 400, + ); + } +} + async function handleCreateRoomRequest(ctx: RouteContext): Promise { const { auth, persistence, rooms, requestLogger } = ctx; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 46be694..f564a99 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -3,6 +3,7 @@ import { MAX_RAW_MESSAGE_BYTES } from "@tilezo/protocol"; import type { Server } from "bun"; import { AuthPasswordLimiter, AuthService, DrizzleAuthStore } from "./auth/auth"; import { FixedWindowRateLimiter } from "./auth/rateLimit"; +import { BlockService, DrizzleBlockStore } from "./blocks/blocks"; import { getConfig, type ServerConfig } from "./config"; import { createDatabase } from "./db/db"; import { DrizzlePersistenceStore, type PersistenceStore } from "./db/persistence"; @@ -72,6 +73,7 @@ const database = createDatabase(config.databaseUrl); const persistence = database ? new DrizzlePersistenceStore(database) : undefined; const presence = new PresenceTracker(); const rooms = await RoomManager.create({ persistence, bots: DEFAULT_ROOM_BOTS }); +const blocks = database ? new BlockService(new DrizzleBlockStore(database)) : undefined; const friends = database ? new FriendService(new DrizzleFriendStore(database), (userId) => presence.get(userId), { canJoinRoom: (userId, roomId) => rooms.canJoinRoom(roomId, userId).ok, @@ -80,8 +82,10 @@ const friends = database : undefined; const directMessages = database && friends - ? new DirectMessageService(new DrizzleDirectMessageStore(database), (a, b) => - friends.areFriends(a, b), + ? new DirectMessageService( + new DrizzleDirectMessageStore(database), + (a, b) => friends.areFriends(a, b), + (a, b) => blocks?.isBlockedEitherDirection(a, b) ?? Promise.resolve(false), ) : undefined; const auth = database @@ -106,6 +110,7 @@ const router = createHttpRouter({ metrics, auth, friends, + blocks, directMessages, persistence, rooms, diff --git a/apps/server/src/messaging/messaging.test.ts b/apps/server/src/messaging/messaging.test.ts index 3e42a77..df543ac 100644 --- a/apps/server/src/messaging/messaging.test.ts +++ b/apps/server/src/messaging/messaging.test.ts @@ -31,6 +31,66 @@ function createStore(): DirectMessageStore & { saved: DirectMessageRecord[] } { ) .slice(-limit); }, + async listUnreadCounts(userId) { + const counts = new Map(); + + for (const message of saved) { + if (message.toUserId !== userId || message.readAt) { + continue; + } + + counts.set(message.fromUserId, (counts.get(message.fromUserId) ?? 0) + 1); + } + + return [...counts].map(([friendId, count]) => ({ friendId, count })); + }, + async markConversationRead(readerUserId, otherUserId) { + const readAt = "2026-06-13T10:02:00.000Z"; + const messageIds: string[] = []; + + for (const message of saved) { + if ( + message.fromUserId === otherUserId && + message.toUserId === readerUserId && + !message.readAt + ) { + message.readAt = readAt; + messageIds.push(message.id); + } + } + + return { readerUserId, otherUserId, messageIds, readAt }; + }, + async findMessage(messageId) { + return saved.find((message) => message.id === messageId); + }, + async editMessage(messageId, text) { + const message = saved.find((item) => item.id === messageId); + + if (!message) { + throw new Error("missing message"); + } + + message.text = text; + message.editedAt = "2026-06-13T10:03:00.000Z"; + return message; + }, + async deleteMessage(messageId) { + const message = saved.find((item) => item.id === messageId); + + if (!message) { + throw new Error("missing message"); + } + + message.deletedAt = "2026-06-13T10:04:00.000Z"; + message.text = ""; + return { + id: message.id, + fromUserId: message.fromUserId, + toUserId: message.toUserId, + deletedAt: message.deletedAt, + }; + }, }; } @@ -60,6 +120,21 @@ describe("DirectMessageService", () => { expect(store.saved).toHaveLength(0); }); + test("rejects blocked conversations without persisting", async () => { + const store = createStore(); + const service = new DirectMessageService( + store, + async () => true, + async () => true, + ); + + await expect(service.send("user_1", "user_2", "hi")).rejects.toThrow( + "cannot message this player", + ); + await expect(service.history("user_1", "user_2")).rejects.toBeInstanceOf(DirectMessageError); + expect(store.saved).toHaveLength(0); + }); + test("returns conversation history for friends and blocks it for non-friends", async () => { const store = createStore(); const friendly = new DirectMessageService(store, async () => true); @@ -72,6 +147,49 @@ describe("DirectMessageService", () => { const blocked = new DirectMessageService(store, async () => false); await expect(blocked.history("user_1", "user_2")).rejects.toBeInstanceOf(DirectMessageError); }); + + test("marks conversations read and returns unread counts", async () => { + const store = createStore(); + const service = new DirectMessageService(store, async () => true); + + await service.send("user_2", "user_1", "a"); + await service.send("user_2", "user_1", "b"); + + expect(await service.unreadCounts("user_1")).toEqual([{ friendId: "user_2", count: 2 }]); + + const receipt = await service.markRead("user_1", "user_2"); + expect(receipt.readerUserId).toBe("user_1"); + expect(receipt.otherUserId).toBe("user_2"); + expect(receipt.messageIds).toHaveLength(2); + expect(receipt.messageIds.every((id) => id.startsWith("dm_"))).toBe(true); + expect(await service.unreadCounts("user_1")).toEqual([]); + }); + + test("edits and deletes owned messages", async () => { + const store = createStore(); + const service = new DirectMessageService(store, async () => true); + const sent = await service.send("user_1", "user_2", "before"); + + await expect(service.edit("user_1", sent.id, "after")).resolves.toMatchObject({ + id: sent.id, + text: "after", + editedAt: "2026-06-13T10:03:00.000Z", + }); + await expect(service.delete("user_1", sent.id)).resolves.toMatchObject({ + id: sent.id, + deletedAt: "2026-06-13T10:04:00.000Z", + }); + }); + + test("rejects edits from non-senders and deleted messages", async () => { + const store = createStore(); + const service = new DirectMessageService(store, async () => true); + const sent = await service.send("user_1", "user_2", "before"); + + await expect(service.edit("user_2", sent.id, "after")).rejects.toThrow("own messages"); + await service.delete("user_1", sent.id); + await expect(service.edit("user_1", sent.id, "after")).rejects.toThrow("already been deleted"); + }); }); describe("DrizzleDirectMessageStore", () => { @@ -84,6 +202,9 @@ describe("DrizzleDirectMessageStore", () => { recipientUserId: "user_2", body: "hello", createdAt, + readAt: new Date("2026-06-13T10:02:00.000Z"), + editedAt: new Date("2026-06-13T10:03:00.000Z"), + deletedAt: null, }; const store = new DrizzleDirectMessageStore(queryDouble([[row]])); @@ -95,6 +216,8 @@ describe("DrizzleDirectMessageStore", () => { toUserId: "user_2", text: "hello", sentAt: "2026-06-13T10:00:00.000Z", + readAt: "2026-06-13T10:02:00.000Z", + editedAt: "2026-06-13T10:03:00.000Z", }); }); @@ -106,6 +229,9 @@ describe("DrizzleDirectMessageStore", () => { recipientUserId: "user_1", body: "second", createdAt: new Date("2026-06-13T10:01:00.000Z"), + readAt: null, + editedAt: null, + deletedAt: null, }; const older = { id: "dm_1", @@ -113,12 +239,85 @@ describe("DrizzleDirectMessageStore", () => { recipientUserId: "user_2", body: "first", createdAt, + readAt: null, + editedAt: null, + deletedAt: null, }; const store = new DrizzleDirectMessageStore(queryDouble([[newer, older]])); const history = await store.listConversation("user_1", "user_2", 50); expect(history.map((message) => message.text)).toEqual(["first", "second"]); }); + + test("hides deleted message body in mapped records", async () => { + const row = { + id: "dm_1", + senderUserId: "user_1", + recipientUserId: "user_2", + body: "secret", + createdAt, + readAt: null, + editedAt: null, + deletedAt: new Date("2026-06-13T10:04:00.000Z"), + }; + const store = new DrizzleDirectMessageStore(queryDouble([[row]])); + + await expect(store.findMessage("dm_1")).resolves.toEqual({ + id: "dm_1", + fromUserId: "user_1", + toUserId: "user_2", + text: "", + sentAt: "2026-06-13T10:00:00.000Z", + deletedAt: "2026-06-13T10:04:00.000Z", + }); + }); + + test("lists unread counts and marks conversations read", async () => { + const store = new DrizzleDirectMessageStore( + queryDouble([[{ friendId: "user_2", value: 3 }], [{ id: "dm_1" }, { id: "dm_2" }]]), + ); + + await expect(store.listUnreadCounts("user_1")).resolves.toEqual([ + { friendId: "user_2", count: 3 }, + ]); + await expect(store.markConversationRead("user_1", "user_2")).resolves.toMatchObject({ + readerUserId: "user_1", + otherUserId: "user_2", + messageIds: ["dm_1", "dm_2"], + }); + }); + + test("edits and deletes messages in the store", async () => { + const edited = { + id: "dm_1", + senderUserId: "user_1", + recipientUserId: "user_2", + body: "after", + createdAt, + readAt: null, + editedAt: new Date("2026-06-13T10:03:00.000Z"), + deletedAt: null, + }; + const deleted = { + id: "dm_1", + senderUserId: "user_1", + recipientUserId: "user_2", + deletedAt: new Date("2026-06-13T10:04:00.000Z"), + }; + const store = new DrizzleDirectMessageStore(queryDouble([[edited], [deleted]])); + + await expect(store.editMessage("dm_1", "after")).resolves.toMatchObject({ + id: "dm_1", + text: "after", + editedAt: "2026-06-13T10:03:00.000Z", + }); + await expect(store.deleteMessage("dm_1")).resolves.toEqual({ + id: "dm_1", + fromUserId: "user_1", + toUserId: "user_2", + deletedAt: "2026-06-13T10:04:00.000Z", + }); + }); }); // Minimal awaitable/chainable Drizzle query-builder stand-in: each builder method returns @@ -141,6 +340,9 @@ function queryDouble( "where", "orderBy", "limit", + "groupBy", + "update", + "set", "values", "returning", "insert", diff --git a/apps/server/src/messaging/messaging.ts b/apps/server/src/messaging/messaging.ts index c7aff33..c3a99c1 100644 --- a/apps/server/src/messaging/messaging.ts +++ b/apps/server/src/messaging/messaging.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, or } from "drizzle-orm"; +import { and, count, desc, eq, isNull, or } from "drizzle-orm"; import type { TilezoDatabase } from "../db/db"; import { directMessages } from "../db/schema"; import { createId } from "../util/ids"; @@ -9,6 +9,28 @@ export type DirectMessageRecord = { toUserId: string; text: string; sentAt: string; + readAt?: string; + editedAt?: string; + deletedAt?: string; +}; + +export type DirectMessageReadReceipt = { + readerUserId: string; + otherUserId: string; + messageIds: string[]; + readAt: string; +}; + +export type DirectMessageUnreadCount = { + friendId: string; + count: number; +}; + +export type DirectMessageDeletedRecord = { + id: string; + fromUserId: string; + toUserId: string; + deletedAt: string; }; export const DEFAULT_DM_HISTORY_LIMIT = 50; @@ -26,10 +48,19 @@ export type DirectMessageStore = { otherUserId: string, limit: number, ): Promise; + listUnreadCounts(userId: string): Promise; + markConversationRead( + readerUserId: string, + otherUserId: string, + ): Promise; + findMessage(messageId: string): Promise; + editMessage(messageId: string, text: string): Promise; + deleteMessage(messageId: string): Promise; }; // Friendship gate (injected): direct messages are only allowed between mutual friends. type FriendshipCheck = (userId: string, otherUserId: string) => Promise; +type BlockCheck = (userId: string, otherUserId: string) => Promise; export class DirectMessageError extends Error { constructor( @@ -44,6 +75,7 @@ export class DirectMessageService { constructor( private readonly store: DirectMessageStore, private readonly areFriends: FriendshipCheck, + private readonly isBlockedEitherDirection: BlockCheck = async () => false, ) {} async send(senderId: string, recipientId: string, text: string): Promise { @@ -51,9 +83,7 @@ export class DirectMessageService { throw new DirectMessageError("INVALID_RECIPIENT", "You cannot message yourself"); } - if (!(await this.areFriends(senderId, recipientId))) { - throw new DirectMessageError("NOT_FRIENDS", "You can only message your friends"); - } + await this.assertCanMessage(senderId, recipientId); return this.store.save({ id: createId("dm"), @@ -68,9 +98,7 @@ export class DirectMessageService { otherUserId: string, limit = DEFAULT_DM_HISTORY_LIMIT, ): Promise { - if (!(await this.areFriends(userId, otherUserId))) { - throw new DirectMessageError("NOT_FRIENDS", "You can only message your friends"); - } + await this.assertCanMessage(userId, otherUserId); const safeLimit = Math.max( 1, @@ -78,6 +106,71 @@ export class DirectMessageService { ); return this.store.listConversation(userId, otherUserId, safeLimit); } + + async markRead(readerUserId: string, otherUserId: string): Promise { + await this.assertCanMessage(readerUserId, otherUserId); + return this.store.markConversationRead(readerUserId, otherUserId); + } + + unreadCounts(userId: string): Promise { + return this.store.listUnreadCounts(userId); + } + + async edit(senderUserId: string, messageId: string, text: string): Promise { + const message = await this.requireEditableMessage(senderUserId, messageId); + await this.assertCanMessage(senderUserId, message.toUserId); + return this.store.editMessage(messageId, text); + } + + async delete(senderUserId: string, messageId: string): Promise { + const message = await this.requireEditableMessage(senderUserId, messageId); + await this.assertCanMessage(senderUserId, message.toUserId); + return this.store.deleteMessage(messageId); + } + + async canMessage(userId: string, otherUserId: string): Promise { + try { + await this.assertCanMessage(userId, otherUserId); + return true; + } catch (error) { + if (error instanceof DirectMessageError) { + return false; + } + + throw error; + } + } + + async assertCanMessage(userId: string, otherUserId: string): Promise { + if (!(await this.areFriends(userId, otherUserId))) { + throw new DirectMessageError("NOT_FRIENDS", "You can only message your friends"); + } + + if (await this.isBlockedEitherDirection(userId, otherUserId)) { + throw new DirectMessageError("BLOCKED", "You cannot message this player"); + } + } + + private async requireEditableMessage( + senderUserId: string, + messageId: string, + ): Promise { + const message = await this.store.findMessage(messageId); + + if (!message) { + throw new DirectMessageError("DM_NOT_FOUND", "Message not found"); + } + + if (message.fromUserId !== senderUserId) { + throw new DirectMessageError("DM_NOT_OWNED", "You can only change your own messages"); + } + + if (message.deletedAt) { + throw new DirectMessageError("DM_DELETED", "Message has already been deleted"); + } + + return message; + } } const DM_COLUMNS = { @@ -86,6 +179,9 @@ const DM_COLUMNS = { recipientUserId: directMessages.recipientUserId, body: directMessages.body, createdAt: directMessages.createdAt, + readAt: directMessages.readAt, + editedAt: directMessages.editedAt, + deletedAt: directMessages.deletedAt, } as const; type DirectMessageRow = { @@ -94,6 +190,9 @@ type DirectMessageRow = { recipientUserId: string; body: string; createdAt: Date; + readAt: Date | null; + editedAt: Date | null; + deletedAt: Date | null; }; export class DrizzleDirectMessageStore implements DirectMessageStore { @@ -140,6 +239,101 @@ export class DrizzleDirectMessageStore implements DirectMessageStore { return rows.reverse().map(toRecord); } + + async findMessage(messageId: string): Promise { + const [row] = await this.db + .select(DM_COLUMNS) + .from(directMessages) + .where(eq(directMessages.id, messageId)) + .limit(1); + + return row ? toRecord(row) : undefined; + } + + async listUnreadCounts(userId: string): Promise { + const rows = await this.db + .select({ + friendId: directMessages.senderUserId, + value: count(), + }) + .from(directMessages) + .where( + and( + eq(directMessages.recipientUserId, userId), + isNull(directMessages.readAt), + isNull(directMessages.deletedAt), + ), + ) + .groupBy(directMessages.senderUserId); + + return rows.map((row) => ({ friendId: row.friendId, count: row.value })); + } + + async markConversationRead( + readerUserId: string, + otherUserId: string, + ): Promise { + const readAt = new Date(); + const rows = await this.db + .update(directMessages) + .set({ readAt }) + .where( + and( + eq(directMessages.senderUserId, otherUserId), + eq(directMessages.recipientUserId, readerUserId), + isNull(directMessages.readAt), + isNull(directMessages.deletedAt), + ), + ) + .returning({ id: directMessages.id }); + + return { + readerUserId, + otherUserId, + messageIds: rows.map((row) => row.id), + readAt: readAt.toISOString(), + }; + } + + async editMessage(messageId: string, text: string): Promise { + const editedAt = new Date(); + const [row] = await this.db + .update(directMessages) + .set({ body: text, editedAt }) + .where(and(eq(directMessages.id, messageId), isNull(directMessages.deletedAt))) + .returning(DM_COLUMNS); + + if (!row) { + throw new Error("Direct message edit failed"); + } + + return toRecord(row); + } + + async deleteMessage(messageId: string): Promise { + const deletedAt = new Date(); + const [row] = await this.db + .update(directMessages) + .set({ deletedAt }) + .where(and(eq(directMessages.id, messageId), isNull(directMessages.deletedAt))) + .returning({ + id: directMessages.id, + senderUserId: directMessages.senderUserId, + recipientUserId: directMessages.recipientUserId, + deletedAt: directMessages.deletedAt, + }); + + if (!row?.deletedAt) { + throw new Error("Direct message delete failed"); + } + + return { + id: row.id, + fromUserId: row.senderUserId, + toUserId: row.recipientUserId, + deletedAt: row.deletedAt.toISOString(), + }; + } } function toRecord(row: DirectMessageRow): DirectMessageRecord { @@ -147,7 +341,10 @@ function toRecord(row: DirectMessageRow): DirectMessageRecord { id: row.id, fromUserId: row.senderUserId, toUserId: row.recipientUserId, - text: row.body, + text: row.deletedAt ? "" : row.body, sentAt: row.createdAt.toISOString(), + ...(row.readAt ? { readAt: row.readAt.toISOString() } : {}), + ...(row.editedAt ? { editedAt: row.editedAt.toISOString() } : {}), + ...(row.deletedAt ? { deletedAt: row.deletedAt.toISOString() } : {}), }; } diff --git a/apps/server/src/net/handleMessage.test.ts b/apps/server/src/net/handleMessage.test.ts index 6587b18..4abfb66 100644 --- a/apps/server/src/net/handleMessage.test.ts +++ b/apps/server/src/net/handleMessage.test.ts @@ -851,6 +851,225 @@ describe("direct messages", () => { message: "You can only message your friends", }); }); + + test("publishes direct message typing status to the recipient topic", async () => { + const rooms = await RoomManager.create(); + const ws = createSocket({ userId: "user_db_1", username: "Dan" }); + const published: { topic: string; message: ServerMessage }[] = []; + const directMessages = { + async assertCanMessage() {}, + } as unknown as DirectMessageService; + + handleMessage( + ws, + JSON.stringify({ type: "dm.typing", toUserId: "user_db_2", isTyping: true }), + { + rooms, + publish(topic, message) { + published.push({ topic, message }); + }, + directMessages, + }, + ); + await flushAsyncMessages(); + + expect(published).toEqual([ + { + topic: "user:user_db_2", + message: { + type: "dm.typing", + fromUserId: "user_db_1", + toUserId: "user_db_2", + isTyping: true, + }, + }, + ]); + }); + + test("surfaces direct message typing policy rejections as errors", async () => { + const rooms = await RoomManager.create(); + const ws = createSocket({ userId: "user_db_1", username: "Dan" }); + const directMessages = { + async assertCanMessage() { + throw new DirectMessageError("BLOCKED", "You cannot message this player"); + }, + } as unknown as DirectMessageService; + + handleMessage( + ws, + JSON.stringify({ type: "dm.typing", toUserId: "user_db_2", isTyping: true }), + { + rooms, + publish() {}, + directMessages, + }, + ); + await flushAsyncMessages(); + + expect(ws.sent).toContainEqual({ + type: "error", + code: "BLOCKED", + message: "You cannot message this player", + }); + }); + + test("marks direct messages read and publishes receipts to both users", async () => { + const rooms = await RoomManager.create(); + const ws = createSocket({ userId: "user_db_1", username: "Dan" }); + const published: { topic: string; message: ServerMessage }[] = []; + const directMessages = { + async markRead(readerUserId: string, otherUserId: string) { + return { + readerUserId, + otherUserId, + messageIds: ["dm_1", "dm_2"], + readAt: "2026-06-13T10:02:00.000Z", + }; + }, + } as unknown as DirectMessageService; + + handleMessage(ws, JSON.stringify({ type: "dm.read", friendId: "user_db_2" }), { + rooms, + publish(topic, message) { + published.push({ topic, message }); + }, + directMessages, + }); + await flushAsyncMessages(); + + const message: ServerMessage = { + type: "dm.read", + readerUserId: "user_db_1", + otherUserId: "user_db_2", + messageIds: ["dm_1", "dm_2"], + readAt: "2026-06-13T10:02:00.000Z", + }; + expect(published).toContainEqual({ topic: "user:user_db_1", message }); + expect(published).toContainEqual({ topic: "user:user_db_2", message }); + }); + + test("does not publish empty direct message read receipts", async () => { + const rooms = await RoomManager.create(); + const ws = createSocket({ userId: "user_db_1", username: "Dan" }); + const published: ServerMessage[] = []; + const directMessages = { + async markRead(readerUserId: string, otherUserId: string) { + return { + readerUserId, + otherUserId, + messageIds: [], + readAt: "2026-06-13T10:02:00.000Z", + }; + }, + } as unknown as DirectMessageService; + + handleMessage(ws, JSON.stringify({ type: "dm.read", friendId: "user_db_2" }), { + rooms, + publish(_topic, message) { + published.push(message); + }, + directMessages, + }); + await flushAsyncMessages(); + + expect(published).toEqual([]); + }); + + test("edits direct messages and publishes updates to both users", async () => { + const rooms = await RoomManager.create(); + const ws = createSocket({ userId: "user_db_1", username: "Dan" }); + const published: { topic: string; message: ServerMessage }[] = []; + const directMessages = { + async edit(fromUserId: string, id: string, text: string) { + return { + id, + fromUserId, + toUserId: "user_db_2", + text, + sentAt: "2026-06-13T10:00:00.000Z", + editedAt: "2026-06-13T10:03:00.000Z", + }; + }, + } as unknown as DirectMessageService; + + handleMessage(ws, JSON.stringify({ type: "dm.edit", messageId: "dm_1", text: "updated" }), { + rooms, + publish(topic, message) { + published.push({ topic, message }); + }, + directMessages, + }); + await flushAsyncMessages(); + + const message: ServerMessage = { + type: "dm.edited", + id: "dm_1", + fromUserId: "user_db_1", + toUserId: "user_db_2", + text: "updated", + editedAt: "2026-06-13T10:03:00.000Z", + }; + expect(published).toContainEqual({ topic: "user:user_db_1", message }); + expect(published).toContainEqual({ topic: "user:user_db_2", message }); + }); + + test("deletes direct messages and publishes tombstones to both users", async () => { + const rooms = await RoomManager.create(); + const ws = createSocket({ userId: "user_db_1", username: "Dan" }); + const published: { topic: string; message: ServerMessage }[] = []; + const directMessages = { + async delete(fromUserId: string, id: string) { + return { + id, + fromUserId, + toUserId: "user_db_2", + deletedAt: "2026-06-13T10:04:00.000Z", + }; + }, + } as unknown as DirectMessageService; + + handleMessage(ws, JSON.stringify({ type: "dm.delete", messageId: "dm_1" }), { + rooms, + publish(topic, message) { + published.push({ topic, message }); + }, + directMessages, + }); + await flushAsyncMessages(); + + const message: ServerMessage = { + type: "dm.deleted", + id: "dm_1", + fromUserId: "user_db_1", + toUserId: "user_db_2", + deletedAt: "2026-06-13T10:04:00.000Z", + }; + expect(published).toContainEqual({ topic: "user:user_db_1", message }); + expect(published).toContainEqual({ topic: "user:user_db_2", message }); + }); + + test("surfaces direct message edit rejections as errors", async () => { + const rooms = await RoomManager.create(); + const ws = createSocket({ userId: "user_db_1", username: "Dan" }); + const directMessages = { + async edit() { + throw new DirectMessageError("DM_NOT_OWNED", "You can only change your own messages"); + }, + } as unknown as DirectMessageService; + + handleMessage(ws, JSON.stringify({ type: "dm.edit", messageId: "dm_1", text: "updated" }), { + rooms, + publish() {}, + directMessages, + }); + await flushAsyncMessages(); + + expect(ws.sent).toContainEqual({ + type: "error", + code: "DM_NOT_OWNED", + message: "You can only change your own messages", + }); + }); }); describe("consumeRateLimit", () => { diff --git a/apps/server/src/net/handleMessage.ts b/apps/server/src/net/handleMessage.ts index a94ac57..0f33e03 100644 --- a/apps/server/src/net/handleMessage.ts +++ b/apps/server/src/net/handleMessage.ts @@ -259,6 +259,70 @@ export function handleMessage( break; } + case "dm.typing": { + if (!consumeRateLimit(ws, "typing", undefined, context.userRateLimits)) { + context.metrics?.increment("rate_limited.dm_typing"); + sendError(ws, "RATE_LIMITED", "Slow down before sending typing updates"); + return; + } + + if (!ws.data.username) { + sendError(ws, "UNAUTHENTICATED", "Log in before sending typing updates"); + return; + } + + void sendDirectTyping(ws, parsed.value.toUserId, parsed.value.isTyping, context); + break; + } + + case "dm.read": { + if (!consumeRateLimit(ws, "default", undefined, context.userRateLimits)) { + context.metrics?.increment("rate_limited.dm_read"); + sendError(ws, "RATE_LIMITED", "Slow down before marking messages read"); + return; + } + + if (!ws.data.username) { + sendError(ws, "UNAUTHENTICATED", "Log in before marking messages read"); + return; + } + + void markDirectMessagesRead(ws, parsed.value.friendId, context); + break; + } + + case "dm.edit": { + if (!consumeRateLimit(ws, "dm", undefined, context.userRateLimits)) { + context.metrics?.increment("rate_limited.dm_edit"); + sendError(ws, "RATE_LIMITED", "Slow down before editing another message"); + return; + } + + if (!ws.data.username) { + sendError(ws, "UNAUTHENTICATED", "Log in before editing messages"); + return; + } + + void editDirectMessage(ws, parsed.value.messageId, parsed.value.text, context); + break; + } + + case "dm.delete": { + if (!consumeRateLimit(ws, "dm", undefined, context.userRateLimits)) { + context.metrics?.increment("rate_limited.dm_delete"); + sendError(ws, "RATE_LIMITED", "Slow down before deleting another message"); + return; + } + + if (!ws.data.username) { + sendError(ws, "UNAUTHENTICATED", "Log in before deleting messages"); + return; + } + + void deleteDirectMessage(ws, parsed.value.messageId, context); + break; + } + case "ping": if (!consumeRateLimit(ws, "default", undefined, context.userRateLimits)) { context.metrics?.increment("rate_limited.ping"); @@ -676,6 +740,161 @@ function disconnectSupersededRoomSockets( } } +async function editDirectMessage( + ws: ServerWebSocket, + messageId: string, + text: string, + context: Context, +): Promise { + if (!context.directMessages) { + sendError(ws, "DM_UNAVAILABLE", "Direct messages are unavailable"); + return; + } + + try { + const record = await context.directMessages.edit(ws.data.userId, messageId, text); + const message: ServerMessage = { + type: "dm.edited", + id: record.id, + fromUserId: record.fromUserId, + toUserId: record.toUserId, + text: record.text, + editedAt: record.editedAt ?? record.sentAt, + }; + context.publish(userTopic(record.toUserId), message); + context.publish(userTopic(record.fromUserId), message); + context.metrics?.increment("dm_edit.accepted"); + } catch (error) { + if (error instanceof DirectMessageError) { + context.metrics?.increment(`dm_edit.rejected.${error.code}`); + sendError(ws, error.code, error.message); + return; + } + + context.logger?.warn("dm.edit.failed", { ...socketFields(ws), messageId, error }); + sendError(ws, "DM_FAILED", "Could not edit message"); + } +} + +async function deleteDirectMessage( + ws: ServerWebSocket, + messageId: string, + context: Context, +): Promise { + if (!context.directMessages) { + sendError(ws, "DM_UNAVAILABLE", "Direct messages are unavailable"); + return; + } + + try { + const record = await context.directMessages.delete(ws.data.userId, messageId); + const message: ServerMessage = { + type: "dm.deleted", + id: record.id, + fromUserId: record.fromUserId, + toUserId: record.toUserId, + deletedAt: record.deletedAt, + }; + context.publish(userTopic(record.toUserId), message); + context.publish(userTopic(record.fromUserId), message); + context.metrics?.increment("dm_delete.accepted"); + } catch (error) { + if (error instanceof DirectMessageError) { + context.metrics?.increment(`dm_delete.rejected.${error.code}`); + sendError(ws, error.code, error.message); + return; + } + + context.logger?.warn("dm.delete.failed", { ...socketFields(ws), messageId, error }); + sendError(ws, "DM_FAILED", "Could not delete message"); + } +} + +async function markDirectMessagesRead( + ws: ServerWebSocket, + friendId: string, + context: Context, +): Promise { + if (!context.directMessages) { + sendError(ws, "DM_UNAVAILABLE", "Direct messages are unavailable"); + return; + } + + try { + const receipt = await context.directMessages.markRead(ws.data.userId, friendId); + + if (receipt.messageIds.length === 0) { + return; + } + + const message: ServerMessage = { + type: "dm.read", + readerUserId: receipt.readerUserId, + otherUserId: receipt.otherUserId, + messageIds: receipt.messageIds, + readAt: receipt.readAt, + }; + context.publish(userTopic(receipt.readerUserId), message); + context.publish(userTopic(receipt.otherUserId), message); + context.metrics?.increment("dm_read.accepted"); + } catch (error) { + if (error instanceof DirectMessageError) { + context.metrics?.increment(`dm_read.rejected.${error.code}`); + sendError(ws, error.code, error.message); + return; + } + + context.logger?.warn("dm.read.failed", { ...socketFields(ws), friendId, error }); + sendError(ws, "DM_FAILED", "Could not mark messages read"); + } +} + +async function sendDirectTyping( + ws: ServerWebSocket, + toUserId: string, + isTyping: boolean, + context: Context, +): Promise { + if (!context.directMessages) { + sendError(ws, "DM_UNAVAILABLE", "Direct messages are unavailable"); + return; + } + + let states = ws.data.lastDirectTypingStates; + + if (!states) { + states = new Map(); + ws.data.lastDirectTypingStates = states; + } + + if (states.get(toUserId) === isTyping) { + return; + } + + try { + await context.directMessages.assertCanMessage(ws.data.userId, toUserId); + } catch (error) { + if (error instanceof DirectMessageError) { + context.metrics?.increment(`dm_typing.rejected.${error.code}`); + sendError(ws, error.code, error.message); + return; + } + + context.logger?.warn("dm.typing.failed", { ...socketFields(ws), toUserId, error }); + sendError(ws, "DM_FAILED", "Could not send typing update"); + return; + } + + states.set(toUserId, isTyping); + context.publish(userTopic(toUserId), { + type: "dm.typing", + fromUserId: ws.data.userId, + toUserId, + isTyping, + }); + context.metrics?.increment("dm_typing.accepted"); +} + async function sendDirectMessage( ws: ServerWebSocket, toUserId: string, diff --git a/apps/server/src/net/socketTypes.ts b/apps/server/src/net/socketTypes.ts index 41589b9..047bb94 100644 --- a/apps/server/src/net/socketTypes.ts +++ b/apps/server/src/net/socketTypes.ts @@ -9,6 +9,7 @@ export type SocketData = { appearance?: AvatarAppearance; rateLimits?: Partial>; lastTypingState?: boolean; + lastDirectTypingStates?: Map; }; export type RateLimitedMessageKind = "movement" | "chat" | "typing" | "dm" | "default"; diff --git a/docs/protocol.md b/docs/protocol.md index 384bdde..b1a8b3a 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -67,6 +67,65 @@ Send transient typing state for the current room. } ``` +### `dm.send` + +Send a friend-gated direct message to another user. + +```json +{ + "type": "dm.send", + "toUserId": "user_...", + "text": "Hi" +} +``` + +### `dm.typing` + +Send transient typing state for a direct-message conversation. The server only forwards the update +when the users are allowed to message each other. + +```json +{ + "type": "dm.typing", + "toUserId": "user_...", + "isTyping": true +} +``` + +### `dm.read` + +Mark unread messages from a friend as read in the direct-message conversation. + +```json +{ + "type": "dm.read", + "friendId": "user_..." +} +``` + +### `dm.edit` + +Edit a direct message sent by the authenticated user. + +```json +{ + "type": "dm.edit", + "messageId": "dm_...", + "text": "Updated message" +} +``` + +### `dm.delete` + +Delete a direct message sent by the authenticated user for both participants. + +```json +{ + "type": "dm.delete", + "messageId": "dm_..." +} +``` + ### `avatar.appearance.update` Broadcast the authenticated user's saved character appearance to the current room after the profile @@ -258,6 +317,80 @@ Broadcast after the server accepts a typing state update. } ``` +### `dm.message` + +Published to the sender and recipient user topics after the server accepts and persists a direct +message. + +```json +{ + "type": "dm.message", + "id": "dm_...", + "fromUserId": "user_...", + "toUserId": "user_...", + "text": "Hi", + "sentAt": "2026-05-10T12:00:00.000Z", + "readAt": "2026-05-10T12:01:00.000Z", + "editedAt": "2026-05-10T12:00:30.000Z" +} +``` + +### `dm.typing` + +Published to the recipient user topic after the server accepts a direct-message typing state update. + +```json +{ + "type": "dm.typing", + "fromUserId": "user_...", + "toUserId": "user_...", + "isTyping": true +} +``` + +### `dm.read` + +Published to both direct-message participants after the reader marks received messages as read. + +```json +{ + "type": "dm.read", + "readerUserId": "user_...", + "otherUserId": "user_...", + "messageIds": ["dm_..."], + "readAt": "2026-05-10T12:01:00.000Z" +} +``` + +### `dm.edited` + +Published to both direct-message participants after the sender edits one of their messages. + +```json +{ + "type": "dm.edited", + "id": "dm_...", + "fromUserId": "user_...", + "toUserId": "user_...", + "text": "Updated message", + "editedAt": "2026-05-10T12:00:30.000Z" +} +``` + +### `dm.deleted` + +Published to both direct-message participants after the sender deletes one of their messages. + +```json +{ + "type": "dm.deleted", + "id": "dm_...", + "fromUserId": "user_...", + "toUserId": "user_...", + "deletedAt": "2026-05-10T12:02:00.000Z" +} +``` + ### `pong` Response to `ping`. diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 50dcba3..c520132 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -31,6 +31,28 @@ export type DirectMessageSendMessage = { text: string; }; +export type DirectMessageTypingMessage = { + type: "dm.typing"; + toUserId: string; + isTyping: boolean; +}; + +export type DirectMessageReadMessage = { + type: "dm.read"; + friendId: string; +}; + +export type DirectMessageEditMessage = { + type: "dm.edit"; + messageId: string; + text: string; +}; + +export type DirectMessageDeleteMessage = { + type: "dm.delete"; + messageId: string; +}; + export type AvatarAppearanceUpdateMessage = { type: "avatar.appearance.update"; appearance: AvatarAppearance; @@ -48,6 +70,10 @@ export type ClientMessage = | ChatSayMessage | ChatTypingMessage | DirectMessageSendMessage + | DirectMessageTypingMessage + | DirectMessageReadMessage + | DirectMessageEditMessage + | DirectMessageDeleteMessage | AvatarAppearanceUpdateMessage | PingMessage; @@ -127,6 +153,41 @@ export type DirectMessage = { toUserId: string; text: string; sentAt: string; + readAt?: string; + editedAt?: string; + deletedAt?: string; +}; + +export type DirectMessageTypingStatusMessage = { + type: "dm.typing"; + fromUserId: string; + toUserId: string; + isTyping: boolean; +}; + +export type DirectMessageReadReceiptMessage = { + type: "dm.read"; + readerUserId: string; + otherUserId: string; + messageIds: string[]; + readAt: string; +}; + +export type DirectMessageEditedMessage = { + type: "dm.edited"; + id: string; + fromUserId: string; + toUserId: string; + text: string; + editedAt: string; +}; + +export type DirectMessageDeletedMessage = { + type: "dm.deleted"; + id: string; + fromUserId: string; + toUserId: string; + deletedAt: string; }; export type PongMessage = { @@ -151,5 +212,9 @@ export type ServerMessage = | ChatMessage | ChatTypingStatusMessage | DirectMessage + | DirectMessageTypingStatusMessage + | DirectMessageReadReceiptMessage + | DirectMessageEditedMessage + | DirectMessageDeletedMessage | PongMessage | ErrorMessage; diff --git a/packages/protocol/src/protocol.test.ts b/packages/protocol/src/protocol.test.ts index 44fb6bf..f988c49 100644 --- a/packages/protocol/src/protocol.test.ts +++ b/packages/protocol/src/protocol.test.ts @@ -72,6 +72,43 @@ describe("protocol parser", () => { expect(parseClientMessage({ type: "dm.send", toUserId: "", text: "hi" }).ok).toBe(false); }); + test("accepts direct message typing status updates", () => { + expect(parseClientMessage({ type: "dm.typing", toUserId: " user_2 ", isTyping: true })).toEqual( + { + ok: true, + value: { type: "dm.typing", toUserId: "user_2", isTyping: true }, + }, + ); + expect(parseClientMessage({ type: "dm.typing", toUserId: "user_2", isTyping: "yes" }).ok).toBe( + false, + ); + expect(parseClientMessage({ type: "dm.typing", toUserId: "", isTyping: true }).ok).toBe(false); + }); + + test("accepts direct message read acknowledgements", () => { + expect(parseClientMessage({ type: "dm.read", friendId: " user_2 " })).toEqual({ + ok: true, + value: { type: "dm.read", friendId: "user_2" }, + }); + expect(parseClientMessage({ type: "dm.read", friendId: "" }).ok).toBe(false); + }); + + test("accepts direct message edits and deletes", () => { + expect( + parseClientMessage({ type: "dm.edit", messageId: " dm_1 ", text: " updated " }), + ).toEqual({ + ok: true, + value: { type: "dm.edit", messageId: "dm_1", text: "updated" }, + }); + expect(parseClientMessage({ type: "dm.edit", messageId: "dm_1", text: "" }).ok).toBe(false); + expect(parseClientMessage({ type: "dm.edit", messageId: "", text: "updated" }).ok).toBe(false); + expect(parseClientMessage({ type: "dm.delete", messageId: " dm_1 " })).toEqual({ + ok: true, + value: { type: "dm.delete", messageId: "dm_1" }, + }); + expect(parseClientMessage({ type: "dm.delete", messageId: "" }).ok).toBe(false); + }); + test("rejects malformed raw messages", () => { expect(parseRawClientMessage("{bad json").ok).toBe(false); }); diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 55528ad..dc91bc7 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -15,6 +15,7 @@ export const MAX_RAW_MESSAGE_BYTES = 8 * 1024; export const USERNAME_MAX_LENGTH = 24; export const USER_ID_MAX_LENGTH = 64; export const ROOM_ID_MAX_LENGTH = 64; +export const MESSAGE_ID_MAX_LENGTH = 128; export const CHAT_MAX_LENGTH = 240; export const DIRECT_MESSAGE_MAX_LENGTH = 600; // Tile coordinates are bounded at the trust boundary so untrusted clients cannot @@ -77,6 +78,28 @@ export const dmSendMessageSchema = z.object({ text: directMessageText, }); +export const dmTypingMessageSchema = z.object({ + type: z.literal("dm.typing"), + toUserId: trimmedString(USER_ID_MAX_LENGTH), + isTyping: z.boolean(), +}); + +export const dmReadMessageSchema = z.object({ + type: z.literal("dm.read"), + friendId: trimmedString(USER_ID_MAX_LENGTH), +}); + +export const dmEditMessageSchema = z.object({ + type: z.literal("dm.edit"), + messageId: trimmedString(MESSAGE_ID_MAX_LENGTH), + text: directMessageText, +}); + +export const dmDeleteMessageSchema = z.object({ + type: z.literal("dm.delete"), + messageId: trimmedString(MESSAGE_ID_MAX_LENGTH), +}); + export const avatarAppearanceSchema = z.object({ hair: z.enum(AVATAR_HAIR_STYLES), hairColor: z.enum(AVATAR_HAIR_COLORS), @@ -106,6 +129,10 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ chatSayMessageSchema, chatTypingMessageSchema, dmSendMessageSchema, + dmTypingMessageSchema, + dmReadMessageSchema, + dmEditMessageSchema, + dmDeleteMessageSchema, avatarAppearanceUpdateMessageSchema, pingMessageSchema, ]); @@ -170,6 +197,37 @@ export const serverMessageSchema = z.discriminatedUnion("type", [ toUserId: z.string(), text: z.string(), sentAt: z.string(), + readAt: z.string().optional(), + editedAt: z.string().optional(), + deletedAt: z.string().optional(), + }), + z.object({ + type: z.literal("dm.typing"), + fromUserId: z.string(), + toUserId: z.string(), + isTyping: z.boolean(), + }), + z.object({ + type: z.literal("dm.read"), + readerUserId: z.string(), + otherUserId: z.string(), + messageIds: z.array(z.string()), + readAt: z.string(), + }), + z.object({ + type: z.literal("dm.edited"), + id: z.string(), + fromUserId: z.string(), + toUserId: z.string(), + text: z.string(), + editedAt: z.string(), + }), + z.object({ + type: z.literal("dm.deleted"), + id: z.string(), + fromUserId: z.string(), + toUserId: z.string(), + deletedAt: z.string(), }), z.object({ type: z.literal("chat.typing"),