Files
fluxer/fluxer_api/src/user/repositories/UserChannelRepository.ts
Hampus Kraft 2f557eda8c initial commit
2026-01-01 21:05:54 +00:00

370 lines
10 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
import {ChannelTypes} from '~/Constants';
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchManyInChunks, fetchOne, upsertOne} from '~/database/Cassandra';
import type {ChannelRow, DmStateRow, PrivateChannelRow} from '~/database/CassandraTypes';
import {Channel} from '~/Models';
import {Channels, DmStates, PinnedDms, PrivateChannels, ReadStates} from '~/Tables';
import type {IUserChannelRepository, PrivateChannelSummary} from './IUserChannelRepository';
interface PinnedDmRow {
user_id: UserID;
channel_id: ChannelID;
sort_order: number;
}
const CHECK_PRIVATE_CHANNEL_CQL = PrivateChannels.selectCql({
columns: ['channel_id'],
where: [PrivateChannels.where.eq('user_id'), PrivateChannels.where.eq('channel_id')],
});
const FETCH_CHANNEL_CQL = Channels.selectCql({
columns: [
'channel_id',
'guild_id',
'type',
'name',
'topic',
'icon_hash',
'url',
'parent_id',
'position',
'owner_id',
'recipient_ids',
'nsfw',
'rate_limit_per_user',
'bitrate',
'user_limit',
'rtc_region',
'last_message_id',
'last_pin_timestamp',
'permission_overwrites',
'nicks',
'soft_deleted',
],
where: [Channels.where.eq('channel_id'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
limit: 1,
});
const FETCH_DM_STATE_CQL = DmStates.selectCql({
where: [DmStates.where.eq('hi_user_id'), DmStates.where.eq('lo_user_id')],
limit: 1,
});
const FETCH_PINNED_DMS_CQL = PinnedDms.selectCql({
where: PinnedDms.where.eq('user_id'),
});
const FETCH_PRIVATE_CHANNELS_CQL = PrivateChannels.selectCql({
where: PrivateChannels.where.eq('user_id'),
});
const FETCH_CHANNEL_METADATA_CQL = Channels.selectCql({
columns: ['channel_id', 'type', 'last_message_id', 'soft_deleted'],
where: [Channels.where.in('channel_id', 'channel_ids'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
});
const FETCH_CHANNELS_IN_CQL = Channels.selectCql({
where: [Channels.where.in('channel_id', 'channel_ids'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
});
const sortBySortOrder = (a: PinnedDmRow, b: PinnedDmRow): number => a.sort_order - b.sort_order;
async function fetchPinnedDms(userId: UserID): Promise<Array<PinnedDmRow>> {
return fetchMany<PinnedDmRow>(FETCH_PINNED_DMS_CQL, {user_id: userId});
}
export class UserChannelRepository implements IUserChannelRepository {
async addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
const pinnedDms = await fetchPinnedDms(userId);
const existingDm = pinnedDms.find((dm) => dm.channel_id === channelId);
if (existingDm) {
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
}
const highestSortOrder = pinnedDms.length > 0 ? Math.max(...pinnedDms.map((dm) => dm.sort_order)) : -1;
await upsertOne(
PinnedDms.upsertAll({
user_id: userId,
channel_id: channelId,
sort_order: highestSortOrder + 1,
}),
);
const allPinnedDms: Array<PinnedDmRow> = [
...pinnedDms,
{
user_id: userId,
channel_id: channelId,
sort_order: highestSortOrder + 1,
},
];
return allPinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
}
async closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void> {
await deleteOneOrMany(
PrivateChannels.deleteByPk({
user_id: userId,
channel_id: channelId,
}),
);
}
async createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel> {
const hiUserId = user1Id > user2Id ? user1Id : user2Id;
const loUserId = user1Id > user2Id ? user2Id : user1Id;
const batch = new BatchBuilder();
const channelRow: ChannelRow = {
channel_id: channelId,
guild_id: null,
type: ChannelTypes.DM,
name: null,
topic: null,
icon_hash: null,
url: null,
parent_id: null,
position: null,
owner_id: null,
recipient_ids: new Set([user1Id, user2Id]),
nsfw: null,
rate_limit_per_user: null,
bitrate: null,
user_limit: null,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
};
batch.addPrepared(Channels.upsertAll(channelRow));
batch.addPrepared(
DmStates.upsertAll({
hi_user_id: hiUserId,
lo_user_id: loUserId,
channel_id: channelId,
}),
);
batch.addPrepared(
PrivateChannels.upsertAll({
user_id: user1Id,
channel_id: channelId,
is_gdm: false,
}),
);
await batch.execute();
return new Channel(channelRow);
}
async deleteAllPrivateChannels(userId: UserID): Promise<void> {
await deleteOneOrMany(
PrivateChannels.deleteCql({
where: PrivateChannels.where.eq('user_id'),
}),
{user_id: userId},
);
}
async deleteAllReadStates(userId: UserID): Promise<void> {
await deleteOneOrMany(
ReadStates.deleteCql({
where: ReadStates.where.eq('user_id'),
}),
{user_id: userId},
);
}
async findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null> {
const hiUserId = user1Id > user2Id ? user1Id : user2Id;
const loUserId = user1Id > user2Id ? user2Id : user1Id;
const dmState = await fetchOne<DmStateRow>(FETCH_DM_STATE_CQL, {
hi_user_id: hiUserId,
lo_user_id: loUserId,
});
if (!dmState) {
return null;
}
const channel = await fetchOne<ChannelRow>(FETCH_CHANNEL_CQL, {
channel_id: dmState.channel_id,
soft_deleted: false,
});
return channel ? new Channel(channel) : null;
}
async getPinnedDms(userId: UserID): Promise<Array<ChannelID>> {
const pinnedDms = await fetchPinnedDms(userId);
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
}
async getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>> {
const pinnedDms = await fetchPinnedDms(userId);
return pinnedDms.sort(sortBySortOrder);
}
async isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean> {
const result = await fetchOne<{channel_id: bigint}>(CHECK_PRIVATE_CHANNEL_CQL, {
user_id: userId,
channel_id: channelId,
});
return result != null;
}
async listPrivateChannels(userId: UserID): Promise<Array<Channel>> {
const rows = await fetchMany<PrivateChannelRow>(FETCH_PRIVATE_CHANNELS_CQL, {
user_id: userId,
});
if (rows.length === 0) {
return [];
}
const channelIds = rows.map((row) => row.channel_id);
const channelRows = await fetchManyInChunks<ChannelRow>(FETCH_CHANNELS_IN_CQL, channelIds, (chunk) => ({
channel_ids: chunk,
soft_deleted: false,
}));
return channelRows.map((row) => new Channel(row));
}
async listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>> {
const rows = await fetchMany<PrivateChannelRow>(FETCH_PRIVATE_CHANNELS_CQL, {
user_id: userId,
});
if (rows.length === 0) {
return [];
}
const channelIds = rows.map((row) => row.channel_id);
const fetchMetadataForSoftDeleted = async (
ids: Array<ChannelID>,
softDeleted: boolean,
): Promise<
Array<{
channel_id: ChannelID;
type: number;
last_message_id: MessageID | null;
soft_deleted: boolean;
}>
> => {
return fetchManyInChunks(FETCH_CHANNEL_METADATA_CQL, ids, (chunk) => ({
channel_ids: chunk,
soft_deleted: softDeleted,
}));
};
const channelMap = new Map<
ChannelID,
{
channel_id: ChannelID;
type: number;
last_message_id: MessageID | null;
soft_deleted: boolean;
}
>();
const openChannelRows = await fetchMetadataForSoftDeleted(channelIds, false);
for (const row of openChannelRows) {
channelMap.set(row.channel_id, row);
}
const missingChannelIds = channelIds.filter((id) => !channelMap.has(id));
if (missingChannelIds.length > 0) {
const deletedChannelRows = await fetchMetadataForSoftDeleted(missingChannelIds, true);
for (const row of deletedChannelRows) {
if (!channelMap.has(row.channel_id)) {
channelMap.set(row.channel_id, row);
}
}
}
return rows.map((row) => {
const channelRow = channelMap.get(row.channel_id);
return {
channelId: row.channel_id,
isGroupDm: row.is_gdm ?? false,
channelType: channelRow ? channelRow.type : null,
lastMessageId: channelRow ? channelRow.last_message_id : null,
open: Boolean(channelRow && !channelRow.soft_deleted),
};
});
}
async openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void> {
let resolvedIsGroupDm: boolean;
if (isGroupDm !== undefined) {
resolvedIsGroupDm = isGroupDm;
} else {
const channelRow = await fetchOne<ChannelRow>(FETCH_CHANNEL_CQL, {
channel_id: channelId,
soft_deleted: false,
});
resolvedIsGroupDm = channelRow?.type === ChannelTypes.GROUP_DM;
}
await upsertOne(
PrivateChannels.upsertAll({
user_id: userId,
channel_id: channelId,
is_gdm: resolvedIsGroupDm,
}),
);
}
async removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
await deleteOneOrMany(
PinnedDms.deleteByPk({
user_id: userId,
channel_id: channelId,
}),
);
const pinnedDms = await fetchPinnedDms(userId);
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
}
async deletePinnedDmsByUserId(userId: UserID): Promise<void> {
await deleteOneOrMany(
PinnedDms.deleteCql({
where: PinnedDms.where.eq('user_id'),
}),
{user_id: userId},
);
}
}