mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-25 04:37:13 -05:00
457 lines
13 KiB
TypeScript
457 lines
13 KiB
TypeScript
import { subHours } from "date-fns";
|
|
import type { ExpressionBuilder, SqlBool, Transaction } from "kysely";
|
|
import { sql } from "kysely";
|
|
import * as R from "remeda";
|
|
import { db } from "~/db/sql";
|
|
import type { DB, Tables, TablesInsertable } from "~/db/tables";
|
|
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
|
import { shortNanoid } from "~/utils/id";
|
|
import {
|
|
commonUserSelect,
|
|
jsonObjectFrom,
|
|
userChatNameHue,
|
|
} from "~/utils/kysely.server";
|
|
import { toDBBoolean } from "~/utils/sql";
|
|
import type { ChatRoomType, PersistedSystemMessageType } from "./chat-types";
|
|
|
|
const MESSAGES_DEFAULT_LIMIT = 500;
|
|
|
|
/**
|
|
* How far back the room list looks for SendouQ memberships: an unmatched group goes inactive
|
|
* after an hour and a match room lives a day, so no open room hangs off an older membership.
|
|
* Keeps a veteran's thousands of past groups out of the lookup — raise rather than let a room vanish.
|
|
*/
|
|
const SQ_MEMBERSHIP_LOOKBACK_HOURS = 72;
|
|
|
|
/** Chat rooms by id. */
|
|
export async function findAllRoomsByIds(roomIds: number[]) {
|
|
if (roomIds.length === 0) return [];
|
|
|
|
return db
|
|
.selectFrom("ChatRoom")
|
|
.selectAll()
|
|
.where("ChatRoom.id", "in", roomIds)
|
|
.execute();
|
|
}
|
|
|
|
/**
|
|
* Ids of the open rooms the user participates in. Membership-first: every branch starts from the
|
|
* user's own membership rows so the cost tracks their participation, not the site's open room
|
|
* count. SendouQ branches narrow further to {@link SQ_MEMBERSHIP_LOOKBACK_HOURS}.
|
|
*/
|
|
export async function findAllOpenRoomIdsByUserId(
|
|
userId: number,
|
|
): Promise<number[]> {
|
|
const now = databaseTimestampNow();
|
|
const openRoom = (chatRoomIdColumn: string) =>
|
|
isOpenRoom(chatRoomIdColumn, now);
|
|
const joinedSince = dateToDatabaseTimestamp(
|
|
subHours(new Date(), SQ_MEMBERSHIP_LOOKBACK_HOURS),
|
|
);
|
|
|
|
// the opponent ids live in JSON, and only literal team ids get the two
|
|
// expression indexes over them picked, so they are fetched first
|
|
const tournamentTeamIds = (
|
|
await db
|
|
.selectFrom("TournamentTeamMember")
|
|
.select("TournamentTeamMember.tournamentTeamId")
|
|
.where("TournamentTeamMember.userId", "=", userId)
|
|
.execute()
|
|
).map((row) => row.tournamentTeamId);
|
|
|
|
const rooms = await Promise.all([
|
|
db
|
|
.selectFrom("GroupMember")
|
|
.innerJoin("Group", "Group.id", "GroupMember.groupId")
|
|
.select("Group.chatRoomId as id")
|
|
.where("GroupMember.userId", "=", userId)
|
|
.where("GroupMember.createdAt", ">", joinedSince)
|
|
.where(openRoom("Group.chatRoomId"))
|
|
.execute(),
|
|
// a side per query so both group id indexes are used, which an `or` denies
|
|
db
|
|
.selectFrom("GroupMember")
|
|
.innerJoin("GroupMatch", "GroupMatch.alphaGroupId", "GroupMember.groupId")
|
|
.select("GroupMatch.chatRoomId as id")
|
|
.where("GroupMember.userId", "=", userId)
|
|
.where("GroupMember.createdAt", ">", joinedSince)
|
|
.where(openRoom("GroupMatch.chatRoomId"))
|
|
.execute(),
|
|
db
|
|
.selectFrom("GroupMember")
|
|
.innerJoin("GroupMatch", "GroupMatch.bravoGroupId", "GroupMember.groupId")
|
|
.select("GroupMatch.chatRoomId as id")
|
|
.where("GroupMember.userId", "=", userId)
|
|
.where("GroupMember.createdAt", ">", joinedSince)
|
|
.where(openRoom("GroupMatch.chatRoomId"))
|
|
.execute(),
|
|
// a column per query so each opponent's expression index is used directly,
|
|
// mirroring the alpha/bravo split above
|
|
...(["opponentOne", "opponentTwo"] as const).map((column) =>
|
|
tournamentTeamIds.length === 0
|
|
? []
|
|
: db
|
|
.selectFrom("TournamentMatch")
|
|
.select("TournamentMatch.chatRoomId as id")
|
|
.where(opponentTeamId(column), "in", tournamentTeamIds)
|
|
.where(openRoom("TournamentMatch.chatRoomId"))
|
|
.execute(),
|
|
),
|
|
db
|
|
.selectFrom("TournamentTeamMember")
|
|
.innerJoin(
|
|
"TournamentTeam",
|
|
"TournamentTeam.id",
|
|
"TournamentTeamMember.tournamentTeamId",
|
|
)
|
|
.select("TournamentTeam.chatRoomId as id")
|
|
.where("TournamentTeamMember.userId", "=", userId)
|
|
.where(openRoom("TournamentTeam.chatRoomId"))
|
|
.execute(),
|
|
db
|
|
.selectFrom("ScrimPostUser")
|
|
.innerJoin("ScrimPost", "ScrimPost.id", "ScrimPostUser.scrimPostId")
|
|
.select("ScrimPost.chatRoomId as id")
|
|
.where("ScrimPostUser.userId", "=", userId)
|
|
.where(openRoom("ScrimPost.chatRoomId"))
|
|
.execute(),
|
|
db
|
|
.selectFrom("ScrimPostRequestUser")
|
|
.innerJoin(
|
|
"ScrimPostRequest",
|
|
"ScrimPostRequest.id",
|
|
"ScrimPostRequestUser.scrimPostRequestId",
|
|
)
|
|
.innerJoin("ScrimPost", "ScrimPost.id", "ScrimPostRequest.scrimPostId")
|
|
.select("ScrimPost.chatRoomId as id")
|
|
.where("ScrimPostRequestUser.userId", "=", userId)
|
|
.where("ScrimPostRequest.isAccepted", "=", 1)
|
|
.where(openRoom("ScrimPost.chatRoomId"))
|
|
.execute(),
|
|
]);
|
|
|
|
return R.unique(
|
|
rooms
|
|
.flat()
|
|
.map((room) => room.id)
|
|
.filter((id) => id !== null),
|
|
);
|
|
}
|
|
|
|
/** Returns the latest `limit` messages of a room, oldest first, authors resolved live. */
|
|
export async function findAllMessagesByRoomId(
|
|
roomId: number,
|
|
{ limit = MESSAGES_DEFAULT_LIMIT }: { limit?: number } = {},
|
|
) {
|
|
const rows = await db
|
|
.selectFrom("ChatMessage")
|
|
.select(messageWithAuthorSelect)
|
|
.where("ChatMessage.roomId", "=", roomId)
|
|
.orderBy("ChatMessage.id", "desc")
|
|
.limit(limit)
|
|
.execute();
|
|
|
|
return rows.reverse();
|
|
}
|
|
|
|
/** Returns a message with its author resolved live. */
|
|
export function findMessageById(messageId: number) {
|
|
return db
|
|
.selectFrom("ChatMessage")
|
|
.select(messageWithAuthorSelect)
|
|
.where("ChatMessage.id", "=", messageId)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
/** Per-room message stats for the user: unread count (messages newer than their read indicator) and the latest message. Rooms with no messages are left out. */
|
|
export async function findMessageStatsByRoomIds(
|
|
userId: number,
|
|
roomIds: number[],
|
|
): Promise<
|
|
Array<{
|
|
roomId: number;
|
|
unreadCount: number;
|
|
latestMessageId: number;
|
|
latestMessageCreatedAt: number;
|
|
}>
|
|
> {
|
|
if (roomIds.length === 0) return [];
|
|
|
|
return db
|
|
.selectFrom("ChatMessage")
|
|
.leftJoin("ChatMessageReadIndicator", (join) =>
|
|
join
|
|
.onRef("ChatMessageReadIndicator.roomId", "=", "ChatMessage.roomId")
|
|
.on("ChatMessageReadIndicator.userId", "=", userId),
|
|
)
|
|
.select(({ eb, fn, val }) => [
|
|
"ChatMessage.roomId",
|
|
fn
|
|
.sum<number>(
|
|
eb
|
|
.case()
|
|
.when(
|
|
"ChatMessage.id",
|
|
">",
|
|
fn.coalesce("ChatMessageReadIndicator.lastSeenMessageId", val(0)),
|
|
)
|
|
.then(1)
|
|
.else(0)
|
|
.end(),
|
|
)
|
|
.as("unreadCount"),
|
|
fn.max<number>("ChatMessage.id").as("latestMessageId"),
|
|
fn.max<number>("ChatMessage.createdAt").as("latestMessageCreatedAt"),
|
|
])
|
|
.where("ChatMessage.roomId", "in", roomIds)
|
|
.groupBy("ChatMessage.roomId")
|
|
.execute();
|
|
}
|
|
|
|
/** Inserts a chat room, returning the row. Called in the owning entity's insert transaction. */
|
|
export function insertRoom(
|
|
args: { type: Tables["ChatRoom"]["type"]; expiresAt: Date },
|
|
trx?: Transaction<DB>,
|
|
) {
|
|
const executor = trx ?? db;
|
|
|
|
return executor
|
|
.insertInto("ChatRoom")
|
|
.values({
|
|
type: args.type,
|
|
expiresAt: dateToDatabaseTimestamp(args.expiresAt),
|
|
})
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
}
|
|
|
|
/** Inserts `count` chat rooms of one type, returning their ids in insertion order. Called in the owning entity's insert transaction. */
|
|
export async function insertRooms(
|
|
args: {
|
|
type: Tables["ChatRoom"]["type"];
|
|
expiresAt: Date;
|
|
count: number;
|
|
},
|
|
trx?: Transaction<DB>,
|
|
): Promise<number[]> {
|
|
if (args.count === 0) return [];
|
|
|
|
const executor = trx ?? db;
|
|
|
|
const inserted = await executor
|
|
.insertInto("ChatRoom")
|
|
.values(
|
|
R.range(0, args.count).map(() => ({
|
|
type: args.type,
|
|
expiresAt: dateToDatabaseTimestamp(args.expiresAt),
|
|
})),
|
|
)
|
|
.returning("id")
|
|
.execute();
|
|
|
|
// the rows are identical, so sorting the ids is enough to line them up with
|
|
// the callers' insertion order (RETURNING makes no ordering promise)
|
|
return inserted.map((room) => room.id).sort((a, b) => a - b);
|
|
}
|
|
|
|
/** Extends a room's lifetime, e.g. when a successor group carries its chat over. */
|
|
export async function updateRoomExpiresAt(
|
|
args: { roomId: number; expiresAt: Date },
|
|
trx?: Transaction<DB>,
|
|
) {
|
|
const executor = trx ?? db;
|
|
|
|
await executor
|
|
.updateTable("ChatRoom")
|
|
.set({ expiresAt: dateToDatabaseTimestamp(args.expiresAt) })
|
|
.where("ChatRoom.id", "=", args.roomId)
|
|
.execute();
|
|
}
|
|
|
|
/** Marks rooms' owner activity as concluded, or active again (a reopened tournament match). */
|
|
export async function updateRoomsInactive(
|
|
roomIds: Array<number | null>,
|
|
inactive: boolean,
|
|
trx?: Transaction<DB>,
|
|
) {
|
|
const idsToUpdate = roomIds.filter((id) => id !== null);
|
|
if (idsToUpdate.length === 0) return;
|
|
|
|
const executor = trx ?? db;
|
|
|
|
await executor
|
|
.updateTable("ChatRoom")
|
|
.set({ inactive: toDBBoolean(inactive) })
|
|
.where("ChatRoom.id", "in", idsToUpdate)
|
|
.execute();
|
|
}
|
|
|
|
/** Deletes rooms and their messages. Called in the owning entity's delete transaction. */
|
|
export async function deleteRoomsByIds(
|
|
roomIds: Array<number | null>,
|
|
trx?: Transaction<DB>,
|
|
) {
|
|
const idsToDelete = roomIds.filter((id) => id !== null);
|
|
if (idsToDelete.length === 0) return;
|
|
|
|
const executor = trx ?? db;
|
|
|
|
await executor
|
|
.deleteFrom("ChatRoom")
|
|
.where("ChatRoom.id", "in", idsToDelete)
|
|
.execute();
|
|
}
|
|
|
|
type InsertMessageArgs = Pick<
|
|
TablesInsertable["ChatMessage"],
|
|
"roomId" | "publicId"
|
|
> & {
|
|
authorUserId: number;
|
|
contents: string;
|
|
};
|
|
|
|
/** Inserts a user message. A `publicId` conflict (retried send) returns the existing row instead. */
|
|
export async function insertMessage(args: InsertMessageArgs) {
|
|
const inserted = await db
|
|
.insertInto("ChatMessage")
|
|
.values(args)
|
|
.onConflict((oc) => oc.column("publicId").doNothing())
|
|
.returningAll()
|
|
.executeTakeFirst();
|
|
|
|
if (inserted) return inserted;
|
|
|
|
return db
|
|
.selectFrom("ChatMessage")
|
|
.selectAll()
|
|
.where("ChatMessage.publicId", "=", args.publicId)
|
|
.executeTakeFirstOrThrow();
|
|
}
|
|
|
|
/** Inserts a system message rendered client-side from its `type`; `authorUserId` is the actor it describes. */
|
|
export function insertSystemMessage(
|
|
args: {
|
|
roomId: number;
|
|
type: PersistedSystemMessageType;
|
|
authorUserId: number;
|
|
},
|
|
trx?: Transaction<DB>,
|
|
) {
|
|
const executor = trx ?? db;
|
|
|
|
return executor
|
|
.insertInto("ChatMessage")
|
|
.values({ ...args, publicId: shortNanoid() })
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow();
|
|
}
|
|
|
|
/** Marks the newest message a user has seen in a room. Never regresses: upserts keep the MAX. */
|
|
export async function upsertReadIndicator(
|
|
args: TablesInsertable["ChatMessageReadIndicator"],
|
|
) {
|
|
await db
|
|
.insertInto("ChatMessageReadIndicator")
|
|
.values(args)
|
|
.onConflict((oc) =>
|
|
oc.columns(["userId", "roomId"]).doUpdateSet({
|
|
lastSeenMessageId: sql`max("ChatMessageReadIndicator"."lastSeenMessageId", "excluded"."lastSeenMessageId")`,
|
|
}),
|
|
)
|
|
.execute();
|
|
}
|
|
|
|
/** Closes rooms whose expiry is before `expiredBefore`, returning how many. Messages are kept; access narrows. */
|
|
export async function closeExpiredRooms(expiredBefore: Date) {
|
|
const result = await db
|
|
.updateTable("ChatRoom")
|
|
.set({ closedAt: databaseTimestampNow() })
|
|
.where("ChatRoom.expiresAt", "<", dateToDatabaseTimestamp(expiredBefore))
|
|
.where("ChatRoom.closedAt", "is", null)
|
|
.executeTakeFirst();
|
|
|
|
return Number(result.numUpdatedRows);
|
|
}
|
|
|
|
/** Deletes rooms no owner row points at any more (backstop for owner deletes that missed their room), returning how many. A room's type names its one owner table. */
|
|
export async function deleteOrphanedRooms() {
|
|
const result = await db
|
|
.deleteFrom("ChatRoom")
|
|
.where((eb) =>
|
|
eb.or(
|
|
R.entries(OWNER_TABLE_BY_ROOM_TYPE).map(([type, table]) =>
|
|
eb.and([eb("ChatRoom.type", "=", type), noOwner(eb, table)]),
|
|
),
|
|
),
|
|
)
|
|
.executeTakeFirst();
|
|
|
|
return Number(result.numDeletedRows);
|
|
}
|
|
|
|
type ChatRoomOwnerTable =
|
|
| "Group"
|
|
| "GroupMatch"
|
|
| "TournamentMatch"
|
|
| "TournamentTeam"
|
|
| "ScrimPost";
|
|
|
|
/** The one table that can own a room of each type. */
|
|
const OWNER_TABLE_BY_ROOM_TYPE = {
|
|
SQ_GROUP: "Group",
|
|
SQ_MATCH: "GroupMatch",
|
|
TOURNAMENT_MATCH: "TournamentMatch",
|
|
TOURNAMENT_TEAM: "TournamentTeam",
|
|
SCRIM: "ScrimPost",
|
|
} as const satisfies Record<ChatRoomType, ChatRoomOwnerTable>;
|
|
|
|
function noOwner(
|
|
eb: ExpressionBuilder<DB, "ChatRoom">,
|
|
table: ChatRoomOwnerTable,
|
|
) {
|
|
return eb.not(
|
|
eb.exists(
|
|
eb
|
|
.selectFrom(table)
|
|
.select(sql<number>`1`.as("one"))
|
|
.whereRef(`${table}.chatRoomId`, "=", "ChatRoom.id"),
|
|
),
|
|
);
|
|
}
|
|
|
|
/** Whether the owner row's room is one the user can still be in: unexpired and unclosed. */
|
|
function isOpenRoom(chatRoomIdColumn: string, now: number) {
|
|
return sql<SqlBool>`exists (
|
|
select 1 from "ChatRoom"
|
|
where "ChatRoom"."id" = ${sql.ref(chatRoomIdColumn)}
|
|
and "ChatRoom"."expiresAt" > ${now}
|
|
and "ChatRoom"."closedAt" is null
|
|
)`;
|
|
}
|
|
|
|
function opponentTeamId(column: "opponentOne" | "opponentTwo") {
|
|
return sql<number>`${sql.ref(`TournamentMatch.${column}`)} ->> '$.id'`;
|
|
}
|
|
|
|
function messageWithAuthorSelect(eb: ExpressionBuilder<DB, "ChatMessage">) {
|
|
return [
|
|
"ChatMessage.id",
|
|
"ChatMessage.roomId",
|
|
"ChatMessage.authorUserId",
|
|
"ChatMessage.type",
|
|
"ChatMessage.contents",
|
|
"ChatMessage.publicId",
|
|
"ChatMessage.createdAt",
|
|
jsonObjectFrom(
|
|
eb
|
|
.selectFrom("User")
|
|
.select((userEb) => [
|
|
...commonUserSelect(userEb),
|
|
"User.pronouns",
|
|
userChatNameHue,
|
|
])
|
|
.whereRef("User.id", "=", "ChatMessage.authorUserId"),
|
|
).as("author"),
|
|
] as const;
|
|
}
|