initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
/*
* 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 {createBetaCode, type UserID} from '~/BrandedTypes';
import {BatchBuilder, Db, fetchMany, fetchOne} from '~/database/Cassandra';
import type {BetaCodeByCodeRow, BetaCodeRow} from '~/database/CassandraTypes';
import {BetaCode} from '~/Models';
import {BetaCodes, BetaCodesByCode} from '~/Tables';
const FETCH_BETA_CODES_BY_CREATOR_QUERY = BetaCodes.selectCql({
where: BetaCodes.where.eq('creator_id'),
});
const FETCH_BETA_CODE_BY_CREATOR_AND_CODE_QUERY = BetaCodes.selectCql({
where: [BetaCodes.where.eq('creator_id'), BetaCodes.where.eq('code')],
limit: 1,
});
const FETCH_BETA_CODE_BY_CODE_QUERY = BetaCodesByCode.selectCql({
where: BetaCodesByCode.where.eq('code'),
});
const FETCH_BETA_CODES_BY_CREATOR_FOR_DELETE_QUERY = BetaCodes.selectCql({
columns: ['code'],
where: BetaCodes.where.eq('creator_id', 'user_id'),
});
export class BetaCodeRepository {
async listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>> {
const betaCodes = await fetchMany<BetaCodeRow>(FETCH_BETA_CODES_BY_CREATOR_QUERY, {
creator_id: creatorId,
});
return betaCodes.map((betaCode) => new BetaCode(betaCode));
}
async getBetaCode(code: string): Promise<BetaCode | null> {
const betaCodeByCode = await fetchOne<BetaCodeByCodeRow>(FETCH_BETA_CODE_BY_CODE_QUERY, {code});
if (!betaCodeByCode) {
return null;
}
const betaCode = await fetchOne<BetaCodeRow>(FETCH_BETA_CODE_BY_CREATOR_AND_CODE_QUERY, {
creator_id: betaCodeByCode.creator_id,
code: betaCodeByCode.code,
});
return betaCode ? new BetaCode(betaCode) : null;
}
async upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode> {
const batch = new BatchBuilder();
batch.addPrepared(BetaCodes.upsertAll(betaCode));
batch.addPrepared(
BetaCodesByCode.upsertAll({
code: betaCode.code,
creator_id: betaCode.creator_id,
}),
);
await batch.execute();
return new BetaCode(betaCode);
}
async updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void> {
const betaCodeByCode = await fetchOne<BetaCodeByCodeRow>(FETCH_BETA_CODE_BY_CODE_QUERY, {code});
if (!betaCodeByCode) {
return;
}
const batch = new BatchBuilder();
batch.addPrepared(
BetaCodes.patchByPk(
{
creator_id: betaCodeByCode.creator_id,
code: betaCodeByCode.code,
},
{
redeemer_id: Db.set(redeemerId),
redeemed_at: Db.set(redeemedAt),
},
),
);
await batch.execute();
}
async deleteBetaCode(code: string, creatorId: UserID): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(BetaCodes.deleteByPk({creator_id: creatorId, code: createBetaCode(code)}));
batch.addPrepared(BetaCodesByCode.deleteByPk({code: createBetaCode(code), creator_id: creatorId}));
await batch.execute();
}
async deleteAllBetaCodes(userId: UserID): Promise<void> {
const codes = await fetchMany<{code: string}>(FETCH_BETA_CODES_BY_CREATOR_FOR_DELETE_QUERY, {
user_id: userId,
});
const batch = new BatchBuilder();
for (const betaCode of codes) {
batch.addPrepared(BetaCodes.deleteByPk({creator_id: userId, code: createBetaCode(betaCode.code)}));
batch.addPrepared(BetaCodesByCode.deleteByPk({code: createBetaCode(betaCode.code), creator_id: userId}));
}
await batch.execute();
}
}

View File

@@ -0,0 +1,160 @@
/*
* 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 {UserID} from '~/BrandedTypes';
import {BatchBuilder, Db, executeConditional, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {GiftCodeRow} from '~/database/CassandraTypes';
import {GiftCode} from '~/Models';
import {GiftCodes, GiftCodesByCreator, GiftCodesByPaymentIntent, GiftCodesByRedeemer} from '~/Tables';
const FETCH_GIFT_CODES_BY_CREATOR_QUERY = GiftCodesByCreator.selectCql({
columns: ['code'],
where: GiftCodesByCreator.where.eq('created_by_user_id'),
});
const FETCH_GIFT_CODE_BY_PAYMENT_INTENT_QUERY = GiftCodesByPaymentIntent.selectCql({
columns: ['code'],
where: GiftCodesByPaymentIntent.where.eq('stripe_payment_intent_id'),
limit: 1,
});
const FETCH_GIFT_CODE_QUERY = GiftCodes.selectCql({
where: GiftCodes.where.eq('code'),
limit: 1,
});
export class GiftCodeRepository {
async createGiftCode(data: GiftCodeRow): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(GiftCodes.upsertAll(data));
batch.addPrepared(
GiftCodesByCreator.upsertAll({
created_by_user_id: data.created_by_user_id,
code: data.code,
}),
);
if (data.stripe_payment_intent_id) {
batch.addPrepared(
GiftCodesByPaymentIntent.upsertAll({
stripe_payment_intent_id: data.stripe_payment_intent_id,
code: data.code,
}),
);
}
await batch.execute();
}
async findGiftCode(code: string): Promise<GiftCode | null> {
const row = await fetchOne<GiftCodeRow>(FETCH_GIFT_CODE_QUERY, {code});
if (!row) {
return null;
}
return new GiftCode(row);
}
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
const row = await fetchOne<{code: string}>(FETCH_GIFT_CODE_BY_PAYMENT_INTENT_QUERY, {
stripe_payment_intent_id: paymentIntentId,
});
if (!row) {
return null;
}
return this.findGiftCode(row.code);
}
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
const rows = await fetchMany<{code: string}>(FETCH_GIFT_CODES_BY_CREATOR_QUERY, {
created_by_user_id: userId,
});
const giftCodes = await Promise.all(rows.map((row) => this.findGiftCode(row.code)));
return giftCodes.filter((gc) => gc !== null) as Array<GiftCode>;
}
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
const redeemedAt = new Date();
const q = GiftCodes.patchByPkIf(
{code},
{
redeemed_by_user_id: Db.set(userId),
redeemed_at: Db.set(redeemedAt),
},
{col: 'redeemed_by_user_id', expectedParam: 'expected_redeemer', expectedValue: null},
);
const result = await executeConditional(q);
if (result.applied) {
await upsertOne(
GiftCodesByRedeemer.upsertAll({
redeemed_by_user_id: userId,
code,
}),
);
}
return result;
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
const batch = new BatchBuilder();
const patch: Record<string, ReturnType<typeof Db.set>> = {};
if (data.redeemed_at !== undefined) {
patch.redeemed_at = Db.set(data.redeemed_at);
}
if (data.redeemed_by_user_id !== undefined) {
patch.redeemed_by_user_id = Db.set(data.redeemed_by_user_id);
}
if (Object.keys(patch).length > 0) {
batch.addPrepared(GiftCodes.patchByPk({code}, patch));
}
if (data.redeemed_by_user_id) {
batch.addPrepared(
GiftCodesByRedeemer.upsertAll({
redeemed_by_user_id: data.redeemed_by_user_id,
code,
}),
);
}
await batch.execute();
}
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
await upsertOne(
GiftCodes.patchByPk(
{code},
{
checkout_session_id: Db.set(checkoutSessionId),
},
),
);
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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 {GuildID, UserID} from '~/BrandedTypes';
import type {UserRow} from '~/database/CassandraTypes';
import type {User} from '~/Models';
export interface IUserAccountRepository {
create(data: UserRow): Promise<User>;
upsert(data: UserRow, oldData?: UserRow | null): Promise<User>;
patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User | null>;
findUnique(userId: UserID): Promise<User | null>;
findUniqueAssert(userId: UserID): Promise<User>;
findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null>;
findDiscriminatorsByUsername(username: string): Promise<Set<number>>;
findByEmail(email: string): Promise<User | null>;
findByPhone(phone: string): Promise<User | null>;
findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null>;
findByStripeCustomerId(stripeCustomerId: string): Promise<User | null>;
listUsers(userIds: Array<UserID>): Promise<Array<User>>;
listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>>;
getUserGuildIds(userId: UserID): Promise<Array<GuildID>>;
addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void>;
removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void>;
findUsersPendingDeletion(now: Date): Promise<Array<User>>;
findUsersPendingDeletionByDate(deletionDate: string): Promise<Array<{user_id: bigint; deletion_reason_code: number}>>;
isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean>;
scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void>;
deleteUserSecondaryIndices(userId: UserID): Promise<void>;
removeFromAllGuilds(userId: UserID): Promise<void>;
updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void>;
}

View File

@@ -0,0 +1,93 @@
/*
* 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 {PhoneVerificationToken, UserID} from '~/BrandedTypes';
import type {
AuthSessionRow,
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '~/database/CassandraTypes';
import type {
AuthSession,
EmailRevertToken,
EmailVerificationToken,
MfaBackupCode,
PasswordResetToken,
WebAuthnCredential,
} from '~/Models';
export interface IUserAuthRepository {
listAuthSessions(userId: UserID): Promise<Array<AuthSession>>;
getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null>;
createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession>;
updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void>;
deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void>;
revokeAuthSession(sessionIdHash: Buffer): Promise<void>;
deleteAllAuthSessions(userId: UserID): Promise<void>;
listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>>;
createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>>;
clearMfaBackupCodes(userId: UserID): Promise<void>;
consumeMfaBackupCode(userId: UserID, code: string): Promise<void>;
deleteAllMfaBackupCodes(userId: UserID): Promise<void>;
getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null>;
createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken>;
deleteEmailVerificationToken(token: string): Promise<void>;
getPasswordResetToken(token: string): Promise<PasswordResetToken | null>;
createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken>;
deletePasswordResetToken(token: string): Promise<void>;
getEmailRevertToken(token: string): Promise<EmailRevertToken | null>;
createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken>;
deleteEmailRevertToken(token: string): Promise<void>;
createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void>;
getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null>;
deletePhoneToken(token: PhoneVerificationToken): Promise<void>;
updateUserActivity(userId: UserID, clientIp: string): Promise<void>;
checkIpAuthorized(userId: UserID, ip: string): Promise<boolean>;
createAuthorizedIp(userId: UserID, ip: string): Promise<void>;
createIpAuthorizationToken(userId: UserID, token: string): Promise<void>;
authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null>;
deleteAllAuthorizedIps(userId: UserID): Promise<void>;
listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>>;
getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null>;
createWebAuthnCredential(
userId: UserID,
credentialId: string,
publicKey: Buffer,
counter: bigint,
transports: Set<string> | null,
name: string,
): Promise<void>;
updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void>;
updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void>;
updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void>;
deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void>;
getUserIdByCredentialId(credentialId: string): Promise<UserID | null>;
deleteAllWebAuthnCredentials(userId: UserID): Promise<void>;
createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void>;
deletePendingVerification(userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,49 @@
/*
* 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 type {Channel} from '~/Models';
export interface PrivateChannelSummary {
channelId: ChannelID;
isGroupDm: boolean;
channelType: number | null;
lastMessageId: MessageID | null;
open: boolean;
}
export interface IUserChannelRepository {
listPrivateChannels(userId: UserID): Promise<Array<Channel>>;
deleteAllPrivateChannels(userId: UserID): Promise<void>;
listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>>;
findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null>;
createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel>;
isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean>;
openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void>;
closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void>;
getPinnedDms(userId: UserID): Promise<Array<ChannelID>>;
getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>>;
addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>>;
removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>>;
deletePinnedDmsByUserId(userId: UserID): Promise<void>;
deleteAllReadStates(userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,91 @@
/*
* 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 type {
BetaCodeRow,
GiftCodeRow,
PaymentBySubscriptionRow,
PaymentRow,
PushSubscriptionRow,
RecentMentionRow,
} from '~/database/CassandraTypes';
import type {BetaCode, GiftCode, Payment, PushSubscription, RecentMention, SavedMessage, VisionarySlot} from '~/Models';
export interface IUserContentRepository {
getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null>;
listRecentMentions(
userId: UserID,
includeEveryone: boolean,
includeRole: boolean,
includeGuilds: boolean,
limit: number,
before?: MessageID,
): Promise<Array<RecentMention>>;
createRecentMention(mention: RecentMentionRow): Promise<RecentMention>;
createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void>;
deleteRecentMention(mention: RecentMention): Promise<void>;
deleteAllRecentMentions(userId: UserID): Promise<void>;
listSavedMessages(userId: UserID, limit?: number, before?: MessageID): Promise<Array<SavedMessage>>;
createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage>;
deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void>;
deleteAllSavedMessages(userId: UserID): Promise<void>;
listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>>;
getBetaCode(code: string): Promise<BetaCode | null>;
upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode>;
updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void>;
deleteBetaCode(code: string, creatorId: UserID): Promise<void>;
deleteAllBetaCodes(userId: UserID): Promise<void>;
createGiftCode(data: GiftCodeRow): Promise<void>;
findGiftCode(code: string): Promise<GiftCode | null>;
findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null>;
findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>>;
redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}>;
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription>;
deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void>;
getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>>;
deleteAllPushSubscriptions(userId: UserID): Promise<void>;
createPayment(data: {
checkout_session_id: string;
user_id: UserID;
price_id: string;
product_type: string;
status: string;
is_gift: boolean;
created_at: Date;
}): Promise<void>;
updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}>;
getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null>;
getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null>;
getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null>;
listVisionarySlots(): Promise<Array<VisionarySlot>>;
expandVisionarySlots(byCount: number): Promise<void>;
shrinkVisionarySlots(toCount: number): Promise<void>;
reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void>;
unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,36 @@
/*
* 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 {UserID} from '~/BrandedTypes';
import type {RelationshipRow} from '~/database/CassandraTypes';
import type {Relationship, UserNote} from '~/Models';
export interface IUserRelationshipRepository {
listRelationships(sourceUserId: UserID): Promise<Array<Relationship>>;
getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null>;
upsertRelationship(relationship: RelationshipRow): Promise<Relationship>;
deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void>;
deleteAllRelationships(userId: UserID): Promise<void>;
getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null>;
getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>>;
upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote>;
clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void>;
deleteAllNotes(userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,33 @@
/*
* 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 {IUserAccountRepository} from './IUserAccountRepository';
import type {IUserAuthRepository} from './IUserAuthRepository';
import type {IUserChannelRepository} from './IUserChannelRepository';
import type {IUserContentRepository} from './IUserContentRepository';
import type {IUserRelationshipRepository} from './IUserRelationshipRepository';
import type {IUserSettingsRepository} from './IUserSettingsRepository';
export interface IUserRepositoryAggregate
extends IUserAccountRepository,
IUserAuthRepository,
IUserSettingsRepository,
IUserRelationshipRepository,
IUserChannelRepository,
IUserContentRepository {}

View File

@@ -0,0 +1,34 @@
/*
* 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 {GuildID, UserID} from '~/BrandedTypes';
import type {UserGuildSettingsRow, UserSettingsRow} from '~/database/CassandraTypes';
import type {UserGuildSettings, UserSettings} from '~/Models';
export interface IUserSettingsRepository {
findSettings(userId: UserID): Promise<UserSettings | null>;
upsertSettings(settings: UserSettingsRow): Promise<UserSettings>;
deleteUserSettings(userId: UserID): Promise<void>;
findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null>;
findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>>;
upsertGuildSettings(settings: UserGuildSettingsRow): Promise<UserGuildSettings>;
deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void>;
deleteAllUserGuildSettings(userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,205 @@
/*
* 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 {UserID} from '~/BrandedTypes';
import {BatchBuilder, Db, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import type {PaymentBySubscriptionRow, PaymentRow} from '~/database/CassandraTypes';
import {Payment} from '~/Models';
import {Payments, PaymentsByPaymentIntent, PaymentsBySubscription, PaymentsByUser} from '~/Tables';
const FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY = Payments.selectCql({
where: Payments.where.eq('checkout_session_id'),
limit: 1,
});
const FETCH_PAYMENT_BY_PAYMENT_INTENT_QUERY = PaymentsByPaymentIntent.selectCql({
columns: ['checkout_session_id'],
where: PaymentsByPaymentIntent.where.eq('payment_intent_id'),
});
const FETCH_PAYMENT_BY_SUBSCRIPTION_QUERY = PaymentsBySubscription.selectCql({
where: PaymentsBySubscription.where.eq('subscription_id'),
});
const FETCH_PAYMENTS_BY_USER_QUERY = PaymentsByUser.selectCql({
columns: ['checkout_session_id'],
where: PaymentsByUser.where.eq('user_id'),
});
const FETCH_PAYMENTS_BY_IDS_QUERY = Payments.selectCql({
where: Payments.where.in('checkout_session_id', 'checkout_session_ids'),
});
export class PaymentRepository {
async createPayment(data: {
checkout_session_id: string;
user_id: UserID;
price_id: string;
product_type: string;
status: string;
is_gift: boolean;
created_at: Date;
}): Promise<void> {
const batch = new BatchBuilder();
const paymentRow: PaymentRow = {
checkout_session_id: data.checkout_session_id,
user_id: data.user_id,
price_id: data.price_id,
product_type: data.product_type,
status: data.status,
is_gift: data.is_gift,
created_at: data.created_at,
stripe_customer_id: null,
payment_intent_id: null,
subscription_id: null,
invoice_id: null,
amount_cents: 0,
currency: '',
gift_code: null,
completed_at: null,
version: 1,
};
batch.addPrepared(Payments.upsertAll(paymentRow));
batch.addPrepared(
PaymentsByUser.upsertAll({
user_id: data.user_id,
created_at: data.created_at,
checkout_session_id: data.checkout_session_id,
}),
);
await batch.execute();
}
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
const checkoutSessionId = data.checkout_session_id;
const result = await executeVersionedUpdate(
() =>
fetchOne<PaymentRow>(FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY, {
checkout_session_id: checkoutSessionId,
}),
(current) => {
type PatchOp = ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>;
const patch: Record<string, PatchOp> = {};
const addField = <K extends keyof PaymentRow>(key: K) => {
const newVal = data[key];
const oldVal = current?.[key];
if (newVal === null) {
if (current && oldVal !== null && oldVal !== undefined) {
patch[key] = Db.clear();
}
} else if (newVal !== undefined) {
patch[key] = Db.set(newVal);
}
};
addField('stripe_customer_id');
addField('payment_intent_id');
addField('subscription_id');
addField('invoice_id');
addField('amount_cents');
addField('currency');
addField('status');
addField('gift_code');
addField('completed_at');
return {
pk: {checkout_session_id: checkoutSessionId},
patch,
};
},
Payments,
{onFailure: 'throw'},
);
if (result.applied) {
await this.updatePaymentIndexes(data);
}
return result;
}
private async updatePaymentIndexes(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<void> {
const batch = new BatchBuilder();
if (data.payment_intent_id) {
batch.addPrepared(
PaymentsByPaymentIntent.upsertAll({
payment_intent_id: data.payment_intent_id,
checkout_session_id: data.checkout_session_id,
}),
);
}
if (data.subscription_id) {
const payment = await this.getPaymentByCheckoutSession(data.checkout_session_id);
if (payment?.priceId && payment.productType) {
batch.addPrepared(
PaymentsBySubscription.upsertAll({
subscription_id: data.subscription_id,
checkout_session_id: data.checkout_session_id,
user_id: payment.userId,
price_id: payment.priceId,
product_type: payment.productType,
}),
);
}
}
await batch.execute();
}
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
const result = await fetchOne<PaymentRow>(FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY, {
checkout_session_id: checkoutSessionId,
});
return result ? new Payment(result) : null;
}
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
const mapping = await fetchOne<{checkout_session_id: string}>(FETCH_PAYMENT_BY_PAYMENT_INTENT_QUERY, {
payment_intent_id: paymentIntentId,
});
if (!mapping) return null;
return this.getPaymentByCheckoutSession(mapping.checkout_session_id);
}
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
const result = await fetchOne<PaymentBySubscriptionRow>(FETCH_PAYMENT_BY_SUBSCRIPTION_QUERY, {
subscription_id: subscriptionId,
});
return result ?? null;
}
async findPaymentsByUserId(userId: UserID): Promise<Array<Payment>> {
const paymentRefs = await fetchMany<{checkout_session_id: string}>(FETCH_PAYMENTS_BY_USER_QUERY, {
user_id: userId,
});
if (paymentRefs.length === 0) return [];
const rows = await fetchMany<PaymentRow>(FETCH_PAYMENTS_BY_IDS_QUERY, {
checkout_session_ids: paymentRefs.map((r) => r.checkout_session_id),
});
return rows.map((r) => new Payment(r));
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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 {UserID} from '~/BrandedTypes';
import {deleteOneOrMany, fetchMany, prepared, upsertOne} from '~/database/Cassandra';
import type {PushSubscriptionRow} from '~/database/CassandraTypes';
import {PushSubscription} from '~/Models';
import {PushSubscriptions} from '~/Tables';
const FETCH_PUSH_SUBSCRIPTIONS_CQL = PushSubscriptions.selectCql({
where: PushSubscriptions.where.eq('user_id'),
});
const FETCH_BULK_PUSH_SUBSCRIPTIONS_CQL = PushSubscriptions.selectCql({
where: PushSubscriptions.where.in('user_id', 'user_ids'),
});
export class PushSubscriptionRepository {
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
const rows = await fetchMany<PushSubscriptionRow>(FETCH_PUSH_SUBSCRIPTIONS_CQL, {user_id: userId});
return rows.map((row) => new PushSubscription(row));
}
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
await upsertOne(PushSubscriptions.upsertAll(data));
return new PushSubscription(data);
}
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
await deleteOneOrMany(PushSubscriptions.deleteByPk({user_id: userId, subscription_id: subscriptionId}));
}
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
if (userIds.length === 0) return new Map();
const rows = await fetchMany<PushSubscriptionRow>(FETCH_BULK_PUSH_SUBSCRIPTIONS_CQL, {user_ids: userIds});
const map = new Map<UserID, Array<PushSubscription>>();
for (const row of rows) {
const sub = new PushSubscription(row);
const existing = map.get(row.user_id) ?? [];
existing.push(sub);
map.set(row.user_id, existing);
}
return map;
}
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
await deleteOneOrMany(
prepared(PushSubscriptions.deleteCql({where: PushSubscriptions.where.eq('user_id')}), {user_id: userId}),
);
}
}

View File

@@ -0,0 +1,153 @@
/*
* 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 {createMessageID, type MessageID, type UserID} from '~/BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, prepared} from '~/database/Cassandra';
import type {RecentMentionRow} from '~/database/CassandraTypes';
import {RecentMention} from '~/Models';
import {RecentMentions, RecentMentionsByGuild} from '~/Tables';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
const FETCH_RECENT_MENTION_CQL = RecentMentions.selectCql({
where: [RecentMentions.where.eq('user_id'), RecentMentions.where.eq('message_id')],
limit: 1,
});
const createFetchRecentMentionsQuery = (limit: number) =>
RecentMentions.selectCql({
where: [RecentMentions.where.eq('user_id'), RecentMentions.where.lt('message_id', 'before_message_id')],
limit,
});
export class RecentMentionRepository {
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
const mention = await fetchOne<RecentMentionRow>(FETCH_RECENT_MENTION_CQL, {
user_id: userId,
message_id: messageId,
});
return mention ? new RecentMention(mention) : null;
}
async listRecentMentions(
userId: UserID,
includeEveryone: boolean = true,
includeRole: boolean = true,
includeGuilds: boolean = true,
limit: number = 25,
before?: MessageID,
): Promise<Array<RecentMention>> {
const fetchLimit = Math.max(limit * 2, 50);
const query = createFetchRecentMentionsQuery(fetchLimit);
const params: {user_id: UserID; before_message_id: MessageID} = {
user_id: userId,
before_message_id: before || createMessageID(SnowflakeUtils.getSnowflake()),
};
const allMentions = await fetchMany<RecentMentionRow>(query, params);
const filteredMentions = allMentions.filter((mention) => {
if (!includeEveryone && mention.is_everyone) return false;
if (!includeRole && mention.is_role) return false;
if (!includeGuilds && mention.guild_id != null) return false;
return true;
});
return filteredMentions.slice(0, limit).map((mention) => new RecentMention(mention));
}
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
const batch = new BatchBuilder();
batch.addPrepared(RecentMentions.upsertAll(mention));
batch.addPrepared(
RecentMentionsByGuild.insert({
user_id: mention.user_id,
guild_id: mention.guild_id,
message_id: mention.message_id,
channel_id: mention.channel_id,
is_everyone: mention.is_everyone,
is_role: mention.is_role,
}),
);
await batch.execute();
return new RecentMention(mention);
}
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
if (mentions.length === 0) {
return;
}
const batch = new BatchBuilder();
for (const mention of mentions) {
batch.addPrepared(RecentMentions.upsertAll(mention));
batch.addPrepared(
RecentMentionsByGuild.insert({
user_id: mention.user_id,
guild_id: mention.guild_id,
message_id: mention.message_id,
channel_id: mention.channel_id,
is_everyone: mention.is_everyone,
is_role: mention.is_role,
}),
);
}
await batch.execute();
}
async deleteRecentMention(mention: RecentMention): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(RecentMentions.deleteByPk({user_id: mention.userId, message_id: mention.messageId}));
batch.addPrepared(
RecentMentionsByGuild.deleteByPk({
user_id: mention.userId,
guild_id: mention.guildId,
message_id: mention.messageId,
}),
);
await batch.execute();
}
async deleteAllRecentMentions(userId: UserID): Promise<void> {
const mentions = await fetchMany<{guild_id: bigint; message_id: bigint}>(
RecentMentions.selectCql({
columns: ['guild_id', 'message_id'],
where: RecentMentions.where.eq('user_id'),
}),
{
user_id: userId,
},
);
const batch = new BatchBuilder();
batch.addPrepared(
prepared(RecentMentions.deleteCql({where: RecentMentions.where.eq('user_id')}), {user_id: userId}),
);
for (const mention of mentions) {
batch.addPrepared(
RecentMentionsByGuild.deleteByPk({
guild_id: mention.guild_id,
user_id: userId,
message_id: mention.message_id,
}),
);
}
if (batch) {
await batch.execute();
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* 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, createMessageID, type MessageID, type UserID} from '~/BrandedTypes';
import {deleteOneOrMany, fetchMany, prepared, upsertOne} from '~/database/Cassandra';
import type {SavedMessageRow} from '~/database/CassandraTypes';
import {SavedMessage} from '~/Models';
import {SavedMessages} from '~/Tables';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
const createFetchSavedMessagesQuery = (limit: number) =>
SavedMessages.selectCql({
where: [SavedMessages.where.eq('user_id'), SavedMessages.where.lt('message_id', 'before_message_id')],
limit,
});
export class SavedMessageRepository {
async listSavedMessages(
userId: UserID,
limit: number = 25,
before: MessageID = createMessageID(SnowflakeUtils.getSnowflake()),
): Promise<Array<SavedMessage>> {
const fetchLimit = Math.max(limit * 2, 50);
const savedMessageRows = await fetchMany<SavedMessageRow>(createFetchSavedMessagesQuery(fetchLimit), {
user_id: userId,
before_message_id: before,
});
const savedMessages: Array<SavedMessage> = [];
for (const savedMessageRow of savedMessageRows) {
if (savedMessages.length >= limit) break;
savedMessages.push(new SavedMessage(savedMessageRow));
}
return savedMessages;
}
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
const savedMessageRow: SavedMessageRow = {
user_id: userId,
channel_id: channelId,
message_id: messageId,
saved_at: new Date(),
};
await upsertOne(SavedMessages.upsertAll(savedMessageRow));
return new SavedMessage(savedMessageRow);
}
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
await deleteOneOrMany(SavedMessages.deleteByPk({user_id: userId, message_id: messageId}));
}
async deleteAllSavedMessages(userId: UserID): Promise<void> {
await deleteOneOrMany(
prepared(SavedMessages.deleteCql({where: SavedMessages.where.eq('user_id')}), {user_id: userId}),
);
}
}

View File

@@ -0,0 +1,80 @@
/*
* 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 {MessageID, UserID} from '~/BrandedTypes';
import {Db, deleteOneOrMany, fetchMany, fetchOne} from '~/database/Cassandra';
import type {ScheduledMessageRow} from '~/database/CassandraTypes';
import {ScheduledMessage} from '~/models/ScheduledMessage';
import {ScheduledMessages} from '~/Tables';
export class ScheduledMessageRepository {
private readonly fetchCql = ScheduledMessages.selectCql({
where: [ScheduledMessages.where.eq('user_id')],
});
async listScheduledMessages(userId: UserID, limit: number = 25): Promise<Array<ScheduledMessage>> {
const rows = await fetchMany<ScheduledMessageRow>(this.fetchCql, {
user_id: userId,
});
const messages = rows.map((row) => ScheduledMessage.fromRow(row));
return messages.sort((a, b) => (b.id > a.id ? 1 : a.id > b.id ? -1 : 0)).slice(0, limit);
}
async getScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<ScheduledMessage | null> {
const row = await fetchOne<ScheduledMessageRow>(
ScheduledMessages.selectCql({
where: [ScheduledMessages.where.eq('user_id'), ScheduledMessages.where.eq('scheduled_message_id')],
}),
{
user_id: userId,
scheduled_message_id: scheduledMessageId,
},
);
return row ? ScheduledMessage.fromRow(row) : null;
}
async upsertScheduledMessage(message: ScheduledMessage, ttlSeconds: number): Promise<void> {
await ScheduledMessages.upsertAllWithTtl(message.toRow(), ttlSeconds);
}
async deleteScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<void> {
await deleteOneOrMany(
ScheduledMessages.deleteByPk({
user_id: userId,
scheduled_message_id: scheduledMessageId,
}),
);
}
async markInvalid(userId: UserID, scheduledMessageId: MessageID, reason: string, ttlSeconds: number): Promise<void> {
await ScheduledMessages.patchByPkWithTtl(
{
user_id: userId,
scheduled_message_id: scheduledMessageId,
},
{
status: Db.set('invalid'),
status_reason: Db.set(reason),
invalidated_at: Db.set(new Date()),
},
ttlSeconds,
);
}
}

View File

@@ -0,0 +1,143 @@
/*
* 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 {GuildID, UserID} from '~/BrandedTypes';
import type {UserRow} from '~/database/CassandraTypes';
import type {User} from '~/Models';
import {UserAccountRepository as UserAccountCrudRepository} from './account/UserAccountRepository';
import {UserDeletionRepository} from './account/UserDeletionRepository';
import {UserGuildRepository} from './account/UserGuildRepository';
import {UserLookupRepository} from './account/UserLookupRepository';
import type {IUserAccountRepository} from './IUserAccountRepository';
export class UserAccountRepository implements IUserAccountRepository {
private accountRepo: UserAccountCrudRepository;
private lookupRepo: UserLookupRepository;
private deletionRepo: UserDeletionRepository;
private guildRepo: UserGuildRepository;
constructor() {
this.accountRepo = new UserAccountCrudRepository();
this.lookupRepo = new UserLookupRepository(this.accountRepo.findUnique.bind(this.accountRepo));
this.deletionRepo = new UserDeletionRepository(this.accountRepo.findUnique.bind(this.accountRepo));
this.guildRepo = new UserGuildRepository();
}
async create(data: UserRow): Promise<User> {
return this.accountRepo.create(data);
}
async findUnique(userId: UserID): Promise<User | null> {
return this.accountRepo.findUnique(userId);
}
async findUniqueAssert(userId: UserID): Promise<User> {
return this.accountRepo.findUniqueAssert(userId);
}
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
return this.accountRepo.listAllUsersPaginated(limit, lastUserId);
}
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
return this.accountRepo.listUsers(userIds);
}
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
return this.accountRepo.upsert(data, oldData);
}
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User | null> {
return this.accountRepo.patchUpsert(userId, patchData, oldData);
}
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
return this.accountRepo.deleteUserSecondaryIndices(userId);
}
async findByEmail(email: string): Promise<User | null> {
return this.lookupRepo.findByEmail(email);
}
async findByPhone(phone: string): Promise<User | null> {
return this.lookupRepo.findByPhone(phone);
}
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
return this.lookupRepo.findByStripeCustomerId(stripeCustomerId);
}
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
return this.lookupRepo.findByStripeSubscriptionId(stripeSubscriptionId);
}
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
return this.lookupRepo.findByUsernameDiscriminator(username, discriminator);
}
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
return this.lookupRepo.findDiscriminatorsByUsername(username);
}
async getActivityTracking(userId: UserID): Promise<{last_active_at: Date | null; last_active_ip: string | null}> {
const user = await this.findUnique(userId);
return {
last_active_at: user?.lastActiveAt ?? null,
last_active_ip: user?.lastActiveIp ?? null,
};
}
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
return this.deletionRepo.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
}
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
return this.deletionRepo.findUsersPendingDeletion(now);
}
async findUsersPendingDeletionByDate(
deletionDate: string,
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
return this.deletionRepo.findUsersPendingDeletionByDate(deletionDate);
}
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
return this.deletionRepo.isUserPendingDeletion(userId, deletionDate);
}
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
return this.deletionRepo.removePendingDeletion(userId, pendingDeletionAt);
}
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
return this.deletionRepo.scheduleDeletion(userId, pendingDeletionAt, deletionReasonCode);
}
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
return this.guildRepo.getUserGuildIds(userId);
}
async removeFromAllGuilds(userId: UserID): Promise<void> {
return this.guildRepo.removeFromAllGuilds(userId);
}
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
return this.accountRepo.updateLastActiveAt(params);
}
}

View File

@@ -0,0 +1,240 @@
/*
* 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 {PhoneVerificationToken, UserID} from '~/BrandedTypes';
import type {
AuthSessionRow,
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '~/database/CassandraTypes';
import type {
AuthSession,
EmailRevertToken,
EmailVerificationToken,
MfaBackupCode,
PasswordResetToken,
WebAuthnCredential,
} from '~/Models';
import {AuthSessionRepository} from './auth/AuthSessionRepository';
import {IpAuthorizationRepository} from './auth/IpAuthorizationRepository';
import {MfaBackupCodeRepository} from './auth/MfaBackupCodeRepository';
import {PendingVerificationRepository} from './auth/PendingVerificationRepository';
import {TokenRepository} from './auth/TokenRepository';
import {WebAuthnRepository} from './auth/WebAuthnRepository';
import type {IUserAccountRepository} from './IUserAccountRepository';
import type {IUserAuthRepository} from './IUserAuthRepository';
export class UserAuthRepository implements IUserAuthRepository {
private authSessionRepository: AuthSessionRepository;
private mfaBackupCodeRepository: MfaBackupCodeRepository;
private tokenRepository: TokenRepository;
private ipAuthorizationRepository: IpAuthorizationRepository;
private webAuthnRepository: WebAuthnRepository;
private pendingVerificationRepository: PendingVerificationRepository;
constructor(userAccountRepository: IUserAccountRepository) {
this.authSessionRepository = new AuthSessionRepository();
this.mfaBackupCodeRepository = new MfaBackupCodeRepository();
this.tokenRepository = new TokenRepository();
this.ipAuthorizationRepository = new IpAuthorizationRepository(userAccountRepository);
this.webAuthnRepository = new WebAuthnRepository();
this.pendingVerificationRepository = new PendingVerificationRepository();
}
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
return this.authSessionRepository.listAuthSessions(userId);
}
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
return this.authSessionRepository.getAuthSessionByToken(sessionIdHash);
}
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
return this.authSessionRepository.createAuthSession(sessionData);
}
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
const session = await this.getAuthSessionByToken(sessionIdHash);
if (!session) return;
await this.authSessionRepository.updateAuthSessionLastUsed(sessionIdHash);
}
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
return this.authSessionRepository.deleteAuthSessions(userId, sessionIdHashes);
}
async revokeAuthSession(sessionIdHash: Buffer): Promise<void> {
const session = await this.getAuthSessionByToken(sessionIdHash);
if (!session) return;
await this.deleteAuthSessions(session.userId, [sessionIdHash]);
}
async deleteAllAuthSessions(userId: UserID): Promise<void> {
return this.authSessionRepository.deleteAllAuthSessions(userId);
}
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
return this.mfaBackupCodeRepository.listMfaBackupCodes(userId);
}
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
return this.mfaBackupCodeRepository.createMfaBackupCodes(userId, codes);
}
async clearMfaBackupCodes(userId: UserID): Promise<void> {
return this.mfaBackupCodeRepository.clearMfaBackupCodes(userId);
}
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
return this.mfaBackupCodeRepository.consumeMfaBackupCode(userId, code);
}
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
return this.mfaBackupCodeRepository.deleteAllMfaBackupCodes(userId);
}
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
return this.tokenRepository.getEmailVerificationToken(token);
}
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
return this.tokenRepository.createEmailVerificationToken(tokenData);
}
async deleteEmailVerificationToken(token: string): Promise<void> {
return this.tokenRepository.deleteEmailVerificationToken(token);
}
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
return this.tokenRepository.getPasswordResetToken(token);
}
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
return this.tokenRepository.createPasswordResetToken(tokenData);
}
async deletePasswordResetToken(token: string): Promise<void> {
return this.tokenRepository.deletePasswordResetToken(token);
}
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
return this.tokenRepository.getEmailRevertToken(token);
}
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
return this.tokenRepository.createEmailRevertToken(tokenData);
}
async deleteEmailRevertToken(token: string): Promise<void> {
return this.tokenRepository.deleteEmailRevertToken(token);
}
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
return this.tokenRepository.createPhoneToken(token, phone, userId);
}
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
return this.tokenRepository.getPhoneToken(token);
}
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
return this.tokenRepository.deletePhoneToken(token);
}
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
return this.ipAuthorizationRepository.updateUserActivity(userId, clientIp);
}
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
return this.ipAuthorizationRepository.checkIpAuthorized(userId, ip);
}
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
return this.ipAuthorizationRepository.createAuthorizedIp(userId, ip);
}
async createIpAuthorizationToken(userId: UserID, token: string): Promise<void> {
return this.ipAuthorizationRepository.createIpAuthorizationToken(userId, token);
}
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
return this.ipAuthorizationRepository.authorizeIpByToken(token);
}
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
return this.ipAuthorizationRepository.getAuthorizedIps(userId);
}
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
return this.ipAuthorizationRepository.deleteAllAuthorizedIps(userId);
}
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
return this.webAuthnRepository.listWebAuthnCredentials(userId);
}
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
return this.webAuthnRepository.getWebAuthnCredential(userId, credentialId);
}
async createWebAuthnCredential(
userId: UserID,
credentialId: string,
publicKey: Buffer,
counter: bigint,
transports: Set<string> | null,
name: string,
): Promise<void> {
return this.webAuthnRepository.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
}
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
return this.webAuthnRepository.updateWebAuthnCredentialCounter(userId, credentialId, counter);
}
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
return this.webAuthnRepository.updateWebAuthnCredentialLastUsed(userId, credentialId);
}
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
return this.webAuthnRepository.updateWebAuthnCredentialName(userId, credentialId, name);
}
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
return this.webAuthnRepository.deleteWebAuthnCredential(userId, credentialId);
}
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
return this.webAuthnRepository.getUserIdByCredentialId(credentialId);
}
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
return this.webAuthnRepository.deleteAllWebAuthnCredentials(userId);
}
async createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void> {
return this.pendingVerificationRepository.createPendingVerification(userId, createdAt, metadata);
}
async deletePendingVerification(userId: UserID): Promise<void> {
return this.pendingVerificationRepository.deletePendingVerification(userId);
}
}

View File

@@ -0,0 +1,369 @@
/*
* 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},
);
}
}

View File

@@ -0,0 +1,90 @@
/*
* 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 {createUserID, type UserID} from '~/BrandedTypes';
import {fetchMany, upsertOne} from '~/database/Cassandra';
import type {UserContactChangeLogRow} from '~/database/CassandraTypes';
import {UserContactChangeLogs} from '~/Tables';
const createListLogsCql = (limit: number, includeCursor: boolean) =>
UserContactChangeLogs.selectCql({
where: includeCursor
? [UserContactChangeLogs.where.eq('user_id'), UserContactChangeLogs.where.lt('event_id', 'before_event_id')]
: UserContactChangeLogs.where.eq('user_id'),
orderBy: {col: 'event_id', direction: 'DESC'},
limit,
});
export interface ContactChangeLogListParams {
userId: UserID;
limit: number;
beforeEventId?: string;
}
export interface ContactChangeLogInsertParams {
userId: UserID;
field: string;
oldValue: string | null;
newValue: string | null;
reason: string;
actorUserId: UserID | null;
eventAt?: Date;
}
export class UserContactChangeLogRepository {
async insertLog(params: ContactChangeLogInsertParams): Promise<void> {
const eventAt = params.eventAt ?? new Date();
await upsertOne(
UserContactChangeLogs.insertWithNow(
{
user_id: params.userId,
field: params.field,
old_value: params.oldValue,
new_value: params.newValue,
reason: params.reason,
actor_user_id: params.actorUserId,
event_at: eventAt,
},
'event_id',
),
);
}
async listLogs(params: ContactChangeLogListParams): Promise<Array<UserContactChangeLogRow>> {
const {userId, limit, beforeEventId} = params;
const query = createListLogsCql(limit, !!beforeEventId);
const queryParams: {user_id: UserID; before_event_id?: string} = {
user_id: userId,
};
if (beforeEventId) {
queryParams.before_event_id = beforeEventId;
}
const rows = await fetchMany<
Omit<UserContactChangeLogRow, 'user_id' | 'actor_user_id'> & {
user_id: bigint;
actor_user_id: bigint | null;
}
>(query, queryParams);
return rows.map((row) => ({
...row,
user_id: createUserID(row.user_id),
actor_user_id: row.actor_user_id != null ? createUserID(row.actor_user_id) : null,
}));
}
}

View File

@@ -0,0 +1,231 @@
/*
* 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 type {
BetaCodeRow,
GiftCodeRow,
PaymentBySubscriptionRow,
PaymentRow,
PushSubscriptionRow,
RecentMentionRow,
} from '~/database/CassandraTypes';
import type {BetaCode, GiftCode, Payment, PushSubscription, RecentMention, SavedMessage, VisionarySlot} from '~/Models';
import {BetaCodeRepository} from './BetaCodeRepository';
import {GiftCodeRepository} from './GiftCodeRepository';
import type {IUserContentRepository} from './IUserContentRepository';
import {PaymentRepository} from './PaymentRepository';
import {PushSubscriptionRepository} from './PushSubscriptionRepository';
import {RecentMentionRepository} from './RecentMentionRepository';
import {SavedMessageRepository} from './SavedMessageRepository';
import {VisionarySlotRepository} from './VisionarySlotRepository';
export class UserContentRepository implements IUserContentRepository {
private betaCodeRepository: BetaCodeRepository;
private giftCodeRepository: GiftCodeRepository;
private paymentRepository: PaymentRepository;
private pushSubscriptionRepository: PushSubscriptionRepository;
private recentMentionRepository: RecentMentionRepository;
private savedMessageRepository: SavedMessageRepository;
private visionarySlotRepository: VisionarySlotRepository;
constructor() {
this.betaCodeRepository = new BetaCodeRepository();
this.giftCodeRepository = new GiftCodeRepository();
this.paymentRepository = new PaymentRepository();
this.pushSubscriptionRepository = new PushSubscriptionRepository();
this.recentMentionRepository = new RecentMentionRepository();
this.savedMessageRepository = new SavedMessageRepository();
this.visionarySlotRepository = new VisionarySlotRepository();
}
async listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>> {
return this.betaCodeRepository.listBetaCodes(creatorId);
}
async getBetaCode(code: string): Promise<BetaCode | null> {
return this.betaCodeRepository.getBetaCode(code);
}
async upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode> {
return this.betaCodeRepository.upsertBetaCode(betaCode);
}
async updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void> {
return this.betaCodeRepository.updateBetaCodeRedeemed(code, redeemerId, redeemedAt);
}
async deleteBetaCode(code: string, creatorId: UserID): Promise<void> {
return this.betaCodeRepository.deleteBetaCode(code, creatorId);
}
async deleteAllBetaCodes(userId: UserID): Promise<void> {
return this.betaCodeRepository.deleteAllBetaCodes(userId);
}
async createGiftCode(data: GiftCodeRow): Promise<void> {
return this.giftCodeRepository.createGiftCode(data);
}
async findGiftCode(code: string): Promise<GiftCode | null> {
return this.giftCodeRepository.findGiftCode(code);
}
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
return this.giftCodeRepository.findGiftCodeByPaymentIntent(paymentIntentId);
}
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
return this.giftCodeRepository.findGiftCodesByCreator(userId);
}
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
return this.giftCodeRepository.redeemGiftCode(code, userId);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.giftCodeRepository.updateGiftCode(code, data);
}
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
return this.giftCodeRepository.linkGiftCodeToCheckoutSession(code, checkoutSessionId);
}
async createPayment(data: {
checkout_session_id: string;
user_id: UserID;
price_id: string;
product_type: string;
status: string;
is_gift: boolean;
created_at: Date;
}): Promise<void> {
return this.paymentRepository.createPayment(data);
}
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
return this.paymentRepository.updatePayment(data);
}
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
return this.paymentRepository.getPaymentByCheckoutSession(checkoutSessionId);
}
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
return this.paymentRepository.getPaymentByPaymentIntent(paymentIntentId);
}
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
return this.paymentRepository.getSubscriptionInfo(subscriptionId);
}
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
return this.pushSubscriptionRepository.listPushSubscriptions(userId);
}
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
return this.pushSubscriptionRepository.createPushSubscription(data);
}
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
return this.pushSubscriptionRepository.deletePushSubscription(userId, subscriptionId);
}
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
return this.pushSubscriptionRepository.getBulkPushSubscriptions(userIds);
}
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
return this.pushSubscriptionRepository.deleteAllPushSubscriptions(userId);
}
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
return this.recentMentionRepository.getRecentMention(userId, messageId);
}
async listRecentMentions(
userId: UserID,
includeEveryone: boolean = true,
includeRole: boolean = true,
includeGuilds: boolean = true,
limit: number = 25,
before?: MessageID,
): Promise<Array<RecentMention>> {
return this.recentMentionRepository.listRecentMentions(
userId,
includeEveryone,
includeRole,
includeGuilds,
limit,
before,
);
}
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
return this.recentMentionRepository.createRecentMention(mention);
}
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
return this.recentMentionRepository.createRecentMentions(mentions);
}
async deleteRecentMention(mention: RecentMention): Promise<void> {
return this.recentMentionRepository.deleteRecentMention(mention);
}
async deleteAllRecentMentions(userId: UserID): Promise<void> {
return this.recentMentionRepository.deleteAllRecentMentions(userId);
}
async listSavedMessages(userId: UserID, limit: number = 25, before?: MessageID): Promise<Array<SavedMessage>> {
return this.savedMessageRepository.listSavedMessages(userId, limit, before);
}
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
return this.savedMessageRepository.createSavedMessage(userId, channelId, messageId);
}
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
return this.savedMessageRepository.deleteSavedMessage(userId, messageId);
}
async deleteAllSavedMessages(userId: UserID): Promise<void> {
return this.savedMessageRepository.deleteAllSavedMessages(userId);
}
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
return this.visionarySlotRepository.listVisionarySlots();
}
async expandVisionarySlots(byCount: number): Promise<void> {
return this.visionarySlotRepository.expandVisionarySlots(byCount);
}
async shrinkVisionarySlots(toCount: number): Promise<void> {
return this.visionarySlotRepository.shrinkVisionarySlots(toCount);
}
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
return this.visionarySlotRepository.reserveVisionarySlot(slotIndex, userId);
}
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
return this.visionarySlotRepository.unreserveVisionarySlot(slotIndex, userId);
}
}

View File

@@ -0,0 +1,215 @@
/*
* 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 {createUserID, type UserID} from '~/BrandedTypes';
import {BatchBuilder, Db, deleteOneOrMany, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import type {NoteRow, RelationshipRow} from '~/database/CassandraTypes';
import {Relationship, UserNote} from '~/Models';
import {Notes, Relationships} from '~/Tables';
import type {IUserRelationshipRepository} from './IUserRelationshipRepository';
const FETCH_ALL_NOTES_CQL = Notes.selectCql({
where: Notes.where.eq('source_user_id'),
});
const FETCH_NOTE_CQL = Notes.selectCql({
where: [Notes.where.eq('source_user_id'), Notes.where.eq('target_user_id')],
limit: 1,
});
const FETCH_RELATIONSHIPS_CQL = Relationships.selectCql({
where: Relationships.where.eq('source_user_id'),
});
const FETCH_RELATIONSHIP_CQL = Relationships.selectCql({
where: [
Relationships.where.eq('source_user_id'),
Relationships.where.eq('target_user_id'),
Relationships.where.eq('type'),
],
limit: 1,
});
const FETCH_ALL_NOTES_FOR_DELETE_QUERY = Notes.selectCql({
columns: ['source_user_id', 'target_user_id'],
limit: 10000,
});
const FETCH_RELATIONSHIPS_FOR_DELETE_CQL = Relationships.selectCql({
columns: ['source_user_id', 'target_user_id', 'type'],
where: Relationships.where.eq('source_user_id', 'user_id'),
});
export class UserRelationshipRepository implements IUserRelationshipRepository {
async clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void> {
await deleteOneOrMany(
Notes.deleteByPk({
source_user_id: sourceUserId,
target_user_id: targetUserId,
}),
);
}
async deleteAllNotes(userId: UserID): Promise<void> {
await deleteOneOrMany(
Notes.deleteCql({
where: Notes.where.eq('source_user_id', 'user_id'),
}),
{user_id: userId},
);
const allNotes = await fetchMany<{source_user_id: bigint; target_user_id: bigint}>(
FETCH_ALL_NOTES_FOR_DELETE_QUERY,
{},
);
const batch = new BatchBuilder();
for (const note of allNotes) {
if (note.target_user_id === BigInt(userId)) {
batch.addPrepared(
Notes.deleteByPk({
source_user_id: createUserID(note.source_user_id),
target_user_id: createUserID(note.target_user_id),
}),
);
}
}
if (batch) {
await batch.execute();
}
}
async deleteAllRelationships(userId: UserID): Promise<void> {
await deleteOneOrMany(
Relationships.deleteCql({
where: Relationships.where.eq('source_user_id', 'user_id'),
}),
{user_id: userId},
);
const relationships = await fetchMany<{source_user_id: bigint; target_user_id: bigint; type: number}>(
FETCH_RELATIONSHIPS_FOR_DELETE_CQL,
{user_id: userId},
);
const batch = new BatchBuilder();
for (const rel of relationships) {
if (rel.target_user_id === BigInt(userId)) {
batch.addPrepared(
Relationships.deleteByPk({
source_user_id: createUserID(rel.source_user_id),
target_user_id: createUserID(rel.target_user_id),
type: rel.type,
}),
);
}
}
await batch.execute();
}
async deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void> {
await deleteOneOrMany(
Relationships.deleteByPk({
source_user_id: sourceUserId,
target_user_id: targetUserId,
type,
}),
);
}
async getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null> {
const relationship = await fetchOne<RelationshipRow>(FETCH_RELATIONSHIP_CQL, {
source_user_id: sourceUserId,
target_user_id: targetUserId,
type,
});
return relationship ? new Relationship(relationship) : null;
}
async getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null> {
const note = await fetchOne<NoteRow>(FETCH_NOTE_CQL, {
source_user_id: sourceUserId,
target_user_id: targetUserId,
});
return note ? new UserNote(note) : null;
}
async getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>> {
const notes = await fetchMany<NoteRow>(FETCH_ALL_NOTES_CQL, {source_user_id: sourceUserId});
const noteMap = new Map<UserID, string>();
for (const note of notes) {
noteMap.set(note.target_user_id, note.note);
}
return noteMap;
}
async listRelationships(sourceUserId: UserID): Promise<Array<Relationship>> {
const relationships = await fetchMany<RelationshipRow>(FETCH_RELATIONSHIPS_CQL, {
source_user_id: sourceUserId,
});
return relationships.map((rel) => new Relationship(rel));
}
async upsertRelationship(relationship: RelationshipRow): Promise<Relationship> {
const result = await executeVersionedUpdate<RelationshipRow, 'source_user_id' | 'target_user_id' | 'type'>(
() =>
fetchOne(FETCH_RELATIONSHIP_CQL, {
source_user_id: relationship.source_user_id,
target_user_id: relationship.target_user_id,
type: relationship.type,
}),
(current) => ({
pk: {
source_user_id: relationship.source_user_id,
target_user_id: relationship.target_user_id,
type: relationship.type,
},
patch: {
nickname: Db.set(relationship.nickname),
since: Db.set(relationship.since),
version: Db.set((current?.version ?? 0) + 1),
},
}),
Relationships,
{onFailure: 'log'},
);
return new Relationship({
...relationship,
version: result.finalVersion ?? 1,
});
}
async upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote> {
const result = await executeVersionedUpdate<NoteRow, 'source_user_id' | 'target_user_id'>(
() => fetchOne(FETCH_NOTE_CQL, {source_user_id: sourceUserId, target_user_id: targetUserId}),
(current) => ({
pk: {source_user_id: sourceUserId, target_user_id: targetUserId},
patch: {note: Db.set(note), version: Db.set((current?.version ?? 0) + 1)},
}),
Notes,
{onFailure: 'log'},
);
return new UserNote({
source_user_id: sourceUserId,
target_user_id: targetUserId,
note,
version: result.finalVersion ?? 0,
});
}
}

View File

@@ -0,0 +1,660 @@
/*
* 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, GuildID, MessageID, PhoneVerificationToken, UserID} from '~/BrandedTypes';
import type {
AuthSessionRow,
BetaCodeRow,
EmailRevertTokenRow,
EmailVerificationTokenRow,
GiftCodeRow,
PasswordResetTokenRow,
PaymentBySubscriptionRow,
PaymentRow,
PhoneTokenRow,
PushSubscriptionRow,
RecentMentionRow,
RelationshipRow,
UserGuildSettingsRow,
UserRow,
UserSettingsRow,
} from '~/database/CassandraTypes';
import type {
AuthSession,
BetaCode,
Channel,
EmailRevertToken,
EmailVerificationToken,
GiftCode,
MfaBackupCode,
PasswordResetToken,
Payment,
PushSubscription,
ReadState,
RecentMention,
Relationship,
SavedMessage,
User,
UserGuildSettings,
UserNote,
UserSettings,
VisionarySlot,
WebAuthnCredential,
} from '~/Models';
import {ReadStateRepository} from '~/read_state/ReadStateRepository';
import type {PrivateChannelSummary} from './IUserChannelRepository';
import type {IUserRepositoryAggregate} from './IUserRepositoryAggregate';
import {UserAccountRepository} from './UserAccountRepository';
import {UserAuthRepository} from './UserAuthRepository';
import {UserChannelRepository} from './UserChannelRepository';
import {UserContentRepository} from './UserContentRepository';
import {UserRelationshipRepository} from './UserRelationshipRepository';
import {UserSettingsRepository} from './UserSettingsRepository';
export class UserRepository implements IUserRepositoryAggregate {
private accountRepo: UserAccountRepository;
private settingsRepo: UserSettingsRepository;
private authRepo: UserAuthRepository;
private relationshipRepo: UserRelationshipRepository;
private channelRepo: UserChannelRepository;
private contentRepo: UserContentRepository;
private readStateRepo: ReadStateRepository;
constructor() {
this.accountRepo = new UserAccountRepository();
this.settingsRepo = new UserSettingsRepository();
this.authRepo = new UserAuthRepository(this.accountRepo);
this.relationshipRepo = new UserRelationshipRepository();
this.channelRepo = new UserChannelRepository();
this.contentRepo = new UserContentRepository();
this.readStateRepo = new ReadStateRepository();
}
async create(data: UserRow): Promise<User> {
return this.accountRepo.create(data);
}
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
return this.accountRepo.upsert(data, oldData);
}
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User | null> {
return this.accountRepo.patchUpsert(userId, patchData, oldData);
}
async findUnique(userId: UserID): Promise<User | null> {
return this.accountRepo.findUnique(userId);
}
async findUniqueAssert(userId: UserID): Promise<User> {
return this.accountRepo.findUniqueAssert(userId);
}
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
return this.accountRepo.findByUsernameDiscriminator(username, discriminator);
}
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
return this.accountRepo.findDiscriminatorsByUsername(username);
}
async findByEmail(email: string): Promise<User | null> {
return this.accountRepo.findByEmail(email);
}
async findByPhone(phone: string): Promise<User | null> {
return this.accountRepo.findByPhone(phone);
}
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
return this.accountRepo.findByStripeSubscriptionId(stripeSubscriptionId);
}
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
return this.accountRepo.findByStripeCustomerId(stripeCustomerId);
}
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
return this.accountRepo.listUsers(userIds);
}
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
return this.accountRepo.listAllUsersPaginated(limit, lastUserId);
}
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
return this.accountRepo.getUserGuildIds(userId);
}
async getActivityTracking(userId: UserID): Promise<{last_active_at: Date | null; last_active_ip: string | null}> {
return this.accountRepo.getActivityTracking(userId);
}
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
return this.accountRepo.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
}
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
return this.accountRepo.removePendingDeletion(userId, pendingDeletionAt);
}
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
return this.accountRepo.findUsersPendingDeletion(now);
}
async findUsersPendingDeletionByDate(
deletionDate: string,
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
return this.accountRepo.findUsersPendingDeletionByDate(deletionDate);
}
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
return this.accountRepo.isUserPendingDeletion(userId, deletionDate);
}
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
return this.accountRepo.scheduleDeletion(userId, pendingDeletionAt, deletionReasonCode);
}
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
return this.accountRepo.deleteUserSecondaryIndices(userId);
}
async removeFromAllGuilds(userId: UserID): Promise<void> {
return this.accountRepo.removeFromAllGuilds(userId);
}
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
return this.accountRepo.updateLastActiveAt(params);
}
async findSettings(userId: UserID): Promise<UserSettings | null> {
return this.settingsRepo.findSettings(userId);
}
async upsertSettings(settings: UserSettingsRow): Promise<UserSettings> {
return this.settingsRepo.upsertSettings(settings);
}
async deleteUserSettings(userId: UserID): Promise<void> {
return this.settingsRepo.deleteUserSettings(userId);
}
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
return this.settingsRepo.findGuildSettings(userId, guildId);
}
async findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>> {
return this.settingsRepo.findAllGuildSettings(userId);
}
async upsertGuildSettings(settings: UserGuildSettingsRow): Promise<UserGuildSettings> {
return this.settingsRepo.upsertGuildSettings(settings);
}
async deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void> {
return this.settingsRepo.deleteGuildSettings(userId, guildId);
}
async deleteAllUserGuildSettings(userId: UserID): Promise<void> {
return this.settingsRepo.deleteAllUserGuildSettings(userId);
}
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
return this.authRepo.listAuthSessions(userId);
}
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
return this.authRepo.getAuthSessionByToken(sessionIdHash);
}
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
return this.authRepo.createAuthSession(sessionData);
}
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
return this.authRepo.updateAuthSessionLastUsed(sessionIdHash);
}
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
return this.authRepo.deleteAuthSessions(userId, sessionIdHashes);
}
async revokeAuthSession(sessionIdHash: Buffer): Promise<void> {
return this.authRepo.revokeAuthSession(sessionIdHash);
}
async deleteAllAuthSessions(userId: UserID): Promise<void> {
return this.authRepo.deleteAllAuthSessions(userId);
}
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
return this.authRepo.listMfaBackupCodes(userId);
}
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
return this.authRepo.createMfaBackupCodes(userId, codes);
}
async clearMfaBackupCodes(userId: UserID): Promise<void> {
return this.authRepo.clearMfaBackupCodes(userId);
}
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
return this.authRepo.consumeMfaBackupCode(userId, code);
}
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
return this.authRepo.deleteAllMfaBackupCodes(userId);
}
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
return this.authRepo.getEmailVerificationToken(token);
}
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
return this.authRepo.createEmailVerificationToken(tokenData);
}
async deleteEmailVerificationToken(token: string): Promise<void> {
return this.authRepo.deleteEmailVerificationToken(token);
}
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
return this.authRepo.getPasswordResetToken(token);
}
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
return this.authRepo.createPasswordResetToken(tokenData);
}
async deletePasswordResetToken(token: string): Promise<void> {
return this.authRepo.deletePasswordResetToken(token);
}
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
return this.authRepo.getEmailRevertToken(token);
}
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
return this.authRepo.createEmailRevertToken(tokenData);
}
async deleteEmailRevertToken(token: string): Promise<void> {
return this.authRepo.deleteEmailRevertToken(token);
}
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
return this.authRepo.createPhoneToken(token, phone, userId);
}
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
return this.authRepo.getPhoneToken(token);
}
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
return this.authRepo.deletePhoneToken(token);
}
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
return this.authRepo.updateUserActivity(userId, clientIp);
}
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
return this.authRepo.checkIpAuthorized(userId, ip);
}
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
return this.authRepo.createAuthorizedIp(userId, ip);
}
async createIpAuthorizationToken(userId: UserID, token: string): Promise<void> {
return this.authRepo.createIpAuthorizationToken(userId, token);
}
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
return this.authRepo.authorizeIpByToken(token);
}
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
return this.authRepo.getAuthorizedIps(userId);
}
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
return this.authRepo.deleteAllAuthorizedIps(userId);
}
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
return this.authRepo.listWebAuthnCredentials(userId);
}
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
return this.authRepo.getWebAuthnCredential(userId, credentialId);
}
async createWebAuthnCredential(
userId: UserID,
credentialId: string,
publicKey: Buffer,
counter: bigint,
transports: Set<string> | null,
name: string,
): Promise<void> {
return this.authRepo.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
}
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
return this.authRepo.updateWebAuthnCredentialCounter(userId, credentialId, counter);
}
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
return this.authRepo.updateWebAuthnCredentialLastUsed(userId, credentialId);
}
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
return this.authRepo.updateWebAuthnCredentialName(userId, credentialId, name);
}
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
return this.authRepo.deleteWebAuthnCredential(userId, credentialId);
}
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
return this.authRepo.getUserIdByCredentialId(credentialId);
}
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
return this.authRepo.deleteAllWebAuthnCredentials(userId);
}
async createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void> {
return this.authRepo.createPendingVerification(userId, createdAt, metadata);
}
async deletePendingVerification(userId: UserID): Promise<void> {
return this.authRepo.deletePendingVerification(userId);
}
async listRelationships(sourceUserId: UserID): Promise<Array<Relationship>> {
return this.relationshipRepo.listRelationships(sourceUserId);
}
async getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null> {
return this.relationshipRepo.getRelationship(sourceUserId, targetUserId, type);
}
async upsertRelationship(relationship: RelationshipRow): Promise<Relationship> {
return this.relationshipRepo.upsertRelationship(relationship);
}
async deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void> {
return this.relationshipRepo.deleteRelationship(sourceUserId, targetUserId, type);
}
async deleteAllRelationships(userId: UserID): Promise<void> {
return this.relationshipRepo.deleteAllRelationships(userId);
}
async getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null> {
return this.relationshipRepo.getUserNote(sourceUserId, targetUserId);
}
async getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>> {
return this.relationshipRepo.getUserNotes(sourceUserId);
}
async upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote> {
return this.relationshipRepo.upsertUserNote(sourceUserId, targetUserId, note);
}
async clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void> {
return this.relationshipRepo.clearUserNote(sourceUserId, targetUserId);
}
async deleteAllNotes(userId: UserID): Promise<void> {
return this.relationshipRepo.deleteAllNotes(userId);
}
async listPrivateChannels(userId: UserID): Promise<Array<Channel>> {
return this.channelRepo.listPrivateChannels(userId);
}
async listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>> {
return this.channelRepo.listPrivateChannelSummaries(userId);
}
async deleteAllPrivateChannels(userId: UserID): Promise<void> {
return this.channelRepo.deleteAllPrivateChannels(userId);
}
async findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null> {
return this.channelRepo.findExistingDmState(user1Id, user2Id);
}
async createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel> {
return this.channelRepo.createDmChannelAndState(user1Id, user2Id, channelId);
}
async isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean> {
return this.channelRepo.isDmChannelOpen(userId, channelId);
}
async openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void> {
return this.channelRepo.openDmForUser(userId, channelId, isGroupDm);
}
async closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void> {
return this.channelRepo.closeDmForUser(userId, channelId);
}
async getPinnedDms(userId: UserID): Promise<Array<ChannelID>> {
return this.channelRepo.getPinnedDms(userId);
}
async getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>> {
return this.channelRepo.getPinnedDmsWithDetails(userId);
}
async addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
return this.channelRepo.addPinnedDm(userId, channelId);
}
async removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
return this.channelRepo.removePinnedDm(userId, channelId);
}
async deletePinnedDmsByUserId(userId: UserID): Promise<void> {
return this.channelRepo.deletePinnedDmsByUserId(userId);
}
async deleteAllReadStates(userId: UserID): Promise<void> {
return this.channelRepo.deleteAllReadStates(userId);
}
async getReadStates(userId: UserID): Promise<Array<ReadState>> {
return this.readStateRepo.listReadStates(userId);
}
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
return this.contentRepo.getRecentMention(userId, messageId);
}
async listRecentMentions(
userId: UserID,
includeEveryone: boolean,
includeRole: boolean,
includeGuilds: boolean,
limit: number,
before?: MessageID,
): Promise<Array<RecentMention>> {
return this.contentRepo.listRecentMentions(userId, includeEveryone, includeRole, includeGuilds, limit, before);
}
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
return this.contentRepo.createRecentMention(mention);
}
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
return this.contentRepo.createRecentMentions(mentions);
}
async deleteRecentMention(mention: RecentMention): Promise<void> {
return this.contentRepo.deleteRecentMention(mention);
}
async deleteAllRecentMentions(userId: UserID): Promise<void> {
return this.contentRepo.deleteAllRecentMentions(userId);
}
async listSavedMessages(userId: UserID, limit?: number, before?: MessageID): Promise<Array<SavedMessage>> {
return this.contentRepo.listSavedMessages(userId, limit, before);
}
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
return this.contentRepo.createSavedMessage(userId, channelId, messageId);
}
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
return this.contentRepo.deleteSavedMessage(userId, messageId);
}
async deleteAllSavedMessages(userId: UserID): Promise<void> {
return this.contentRepo.deleteAllSavedMessages(userId);
}
async listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>> {
return this.contentRepo.listBetaCodes(creatorId);
}
async getBetaCode(code: string): Promise<BetaCode | null> {
return this.contentRepo.getBetaCode(code);
}
async upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode> {
return this.contentRepo.upsertBetaCode(betaCode);
}
async updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void> {
return this.contentRepo.updateBetaCodeRedeemed(code, redeemerId, redeemedAt);
}
async deleteBetaCode(code: string, creatorId: UserID): Promise<void> {
return this.contentRepo.deleteBetaCode(code, creatorId);
}
async deleteAllBetaCodes(userId: UserID): Promise<void> {
return this.contentRepo.deleteAllBetaCodes(userId);
}
async createGiftCode(data: GiftCodeRow): Promise<void> {
return this.contentRepo.createGiftCode(data);
}
async findGiftCode(code: string): Promise<GiftCode | null> {
return this.contentRepo.findGiftCode(code);
}
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
return this.contentRepo.findGiftCodeByPaymentIntent(paymentIntentId);
}
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
return this.contentRepo.findGiftCodesByCreator(userId);
}
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
return this.contentRepo.redeemGiftCode(code, userId);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.contentRepo.updateGiftCode(code, data);
}
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
return this.contentRepo.linkGiftCodeToCheckoutSession(code, checkoutSessionId);
}
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
return this.contentRepo.listPushSubscriptions(userId);
}
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
return this.contentRepo.createPushSubscription(data);
}
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
return this.contentRepo.deletePushSubscription(userId, subscriptionId);
}
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
return this.contentRepo.getBulkPushSubscriptions(userIds);
}
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
return this.contentRepo.deleteAllPushSubscriptions(userId);
}
async createPayment(data: {
checkout_session_id: string;
user_id: UserID;
price_id: string;
product_type: string;
status: string;
is_gift: boolean;
created_at: Date;
}): Promise<void> {
return this.contentRepo.createPayment(data);
}
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
return this.contentRepo.updatePayment(data);
}
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
return this.contentRepo.getPaymentByCheckoutSession(checkoutSessionId);
}
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
return this.contentRepo.getPaymentByPaymentIntent(paymentIntentId);
}
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
return this.contentRepo.getSubscriptionInfo(subscriptionId);
}
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
return this.contentRepo.listVisionarySlots();
}
async expandVisionarySlots(byCount: number): Promise<void> {
return this.contentRepo.expandVisionarySlots(byCount);
}
async shrinkVisionarySlots(toCount: number): Promise<void> {
return this.contentRepo.shrinkVisionarySlots(toCount);
}
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
return this.contentRepo.reserveVisionarySlot(slotIndex, userId);
}
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
return this.contentRepo.unreserveVisionarySlot(slotIndex, userId);
}
}

View File

@@ -0,0 +1,117 @@
/*
* 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 {GuildID, UserID} from '~/BrandedTypes';
import {buildPatchFromData, deleteOneOrMany, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import type {UserGuildSettingsRow, UserSettingsRow} from '~/database/CassandraTypes';
import {USER_GUILD_SETTINGS_COLUMNS, USER_SETTINGS_COLUMNS} from '~/database/types/UserTypes';
import {UserGuildSettings, UserSettings} from '~/Models';
import {UserGuildSettings as UserGuildSettingsTable, UserSettings as UserSettingsTable} from '~/Tables';
import type {IUserSettingsRepository} from './IUserSettingsRepository';
const FETCH_USER_SETTINGS_CQL = UserSettingsTable.selectCql({
where: UserSettingsTable.where.eq('user_id'),
limit: 1,
});
const FETCH_USER_GUILD_SETTINGS_CQL = UserGuildSettingsTable.selectCql({
where: [UserGuildSettingsTable.where.eq('user_id'), UserGuildSettingsTable.where.eq('guild_id')],
limit: 1,
});
const FETCH_ALL_USER_GUILD_SETTINGS_CQL = UserGuildSettingsTable.selectCql({
where: UserGuildSettingsTable.where.eq('user_id'),
});
export class UserSettingsRepository implements IUserSettingsRepository {
async deleteAllUserGuildSettings(userId: UserID): Promise<void> {
await deleteOneOrMany(
UserGuildSettingsTable.deleteCql({
where: UserGuildSettingsTable.where.eq('user_id'),
}),
{user_id: userId},
);
}
async deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void> {
await deleteOneOrMany(
UserGuildSettingsTable.deleteByPk({
user_id: userId,
guild_id: guildId,
}),
);
}
async deleteUserSettings(userId: UserID): Promise<void> {
await deleteOneOrMany(UserSettingsTable.deleteByPk({user_id: userId}));
}
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
const settings = await fetchOne<UserGuildSettingsRow>(FETCH_USER_GUILD_SETTINGS_CQL, {
user_id: userId,
guild_id: guildId ? guildId : 0n,
});
return settings ? new UserGuildSettings(settings) : null;
}
async findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>> {
const rows = await fetchMany<UserGuildSettingsRow>(FETCH_ALL_USER_GUILD_SETTINGS_CQL, {
user_id: userId,
});
return rows.map((row) => new UserGuildSettings(row));
}
async findSettings(userId: UserID): Promise<UserSettings | null> {
const settings = await fetchOne<UserSettingsRow>(FETCH_USER_SETTINGS_CQL, {user_id: userId});
return settings ? new UserSettings(settings) : null;
}
async upsertGuildSettings(settings: UserGuildSettingsRow): Promise<UserGuildSettings> {
const userId = settings.user_id;
const guildId = settings.guild_id;
const result = await executeVersionedUpdate<UserGuildSettingsRow, 'user_id' | 'guild_id'>(
() => this.findGuildSettings(userId, guildId).then((s) => s?.toRow() ?? null),
(current) => ({
pk: {user_id: userId, guild_id: guildId},
patch: buildPatchFromData(settings, current, USER_GUILD_SETTINGS_COLUMNS, ['user_id', 'guild_id']),
}),
UserGuildSettingsTable,
{onFailure: 'log'},
);
return new UserGuildSettings({...settings, version: result.finalVersion ?? 1});
}
async upsertSettings(settings: UserSettingsRow): Promise<UserSettings> {
const userId = settings.user_id;
const result = await executeVersionedUpdate<UserSettingsRow, 'user_id'>(
() => this.findSettings(userId).then((s) => s?.toRow() ?? null),
(current) => ({
pk: {user_id: userId},
patch: buildPatchFromData(settings, current, USER_SETTINGS_COLUMNS, ['user_id']),
}),
UserSettingsTable,
{onFailure: 'log'},
);
return new UserSettings({...settings, version: result.finalVersion ?? 1});
}
}

View File

@@ -0,0 +1,114 @@
/*
* 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 {UserID} from '~/BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {VisionarySlotRow} from '~/database/CassandraTypes';
import {CannotShrinkReservedSlotsError} from '~/Errors';
import {VisionarySlot} from '~/Models';
import {VisionarySlots} from '~/Tables';
const FETCH_ALL_VISIONARY_SLOTS_QUERY = VisionarySlots.selectCql();
const FETCH_VISIONARY_SLOT_QUERY = VisionarySlots.selectCql({
where: VisionarySlots.where.eq('slot_index'),
limit: 1,
});
export class VisionarySlotRepository {
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
const slots = await fetchMany<VisionarySlotRow>(FETCH_ALL_VISIONARY_SLOTS_QUERY, {});
return slots.map((slot) => new VisionarySlot(slot));
}
async expandVisionarySlots(byCount: number): Promise<void> {
const existingSlots = await this.listVisionarySlots();
const maxSlotIndex = existingSlots.length > 0 ? Math.max(...existingSlots.map((s) => s.slotIndex)) : -1;
const batch = new BatchBuilder();
for (let i = 1; i <= byCount; i++) {
const newSlotIndex = maxSlotIndex + i;
batch.addPrepared(
VisionarySlots.upsertAll({
slot_index: newSlotIndex,
user_id: null,
}),
);
}
await batch.execute();
}
async shrinkVisionarySlots(toCount: number): Promise<void> {
const existingSlots = await this.listVisionarySlots();
if (existingSlots.length <= toCount) return;
const sortedSlots = existingSlots.sort((a, b) => b.slotIndex - a.slotIndex);
const slotsToRemove = sortedSlots.slice(0, existingSlots.length - toCount);
const reservedSlots = slotsToRemove.filter((slot) => slot.userId !== null);
if (reservedSlots.length > 0) {
throw new CannotShrinkReservedSlotsError(reservedSlots.map((s) => s.slotIndex));
}
const batch = new BatchBuilder();
for (const slot of slotsToRemove) {
batch.addPrepared(VisionarySlots.deleteByPk({slot_index: slot.slotIndex}));
}
await batch.execute();
}
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
const existingSlot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
slot_index: slotIndex,
});
if (!existingSlot) {
await upsertOne(
VisionarySlots.upsertAll({
slot_index: slotIndex,
user_id: userId,
}),
);
} else {
await upsertOne(
VisionarySlots.upsertAll({
slot_index: slotIndex,
user_id: userId,
}),
);
}
}
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
const existingSlot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
slot_index: slotIndex,
});
if (!existingSlot || existingSlot.user_id !== userId) {
return;
}
await upsertOne(
VisionarySlots.upsertAll({
slot_index: slotIndex,
user_id: null,
}),
);
}
}

View File

@@ -0,0 +1,129 @@
/*
* 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 {UserID} from '~/BrandedTypes';
import {Db} from '~/database/Cassandra';
import type {UserRow} from '~/database/CassandraTypes';
import {User} from '~/Models';
import {UserDataRepository} from './crud/UserDataRepository';
import {UserIndexRepository} from './crud/UserIndexRepository';
import {UserSearchRepository} from './crud/UserSearchRepository';
export class UserAccountRepository {
private dataRepo: UserDataRepository;
private indexRepo: UserIndexRepository;
private searchRepo: UserSearchRepository;
constructor() {
this.dataRepo = new UserDataRepository();
this.indexRepo = new UserIndexRepository();
this.searchRepo = new UserSearchRepository();
}
async create(data: UserRow): Promise<User> {
return this.upsert(data);
}
async findUnique(userId: UserID): Promise<User | null> {
return this.dataRepo.findUnique(userId);
}
async findUniqueAssert(userId: UserID): Promise<User> {
return this.dataRepo.findUniqueAssert(userId);
}
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
return this.dataRepo.listAllUsersPaginated(limit, lastUserId);
}
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
return this.dataRepo.listUsers(userIds);
}
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
const result = await this.dataRepo.upsertUserRow(data, oldData);
if (result.finalVersion === null) {
throw new Error(`Failed to update user ${data.user_id} after max retries due to concurrent updates`);
}
await this.indexRepo.syncIndices(data, oldData);
const user = new User(data);
await this.searchRepo.indexUser(user);
return user;
}
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User | null> {
if (!oldData) {
const existingUser = await this.findUnique(userId);
if (!existingUser) return null;
oldData = existingUser.toRow();
}
const newData: UserRow = {...oldData, ...patchData, user_id: userId};
const userPatch: Record<string, ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>> = {};
for (const [key, value] of Object.entries(patchData)) {
if (key === 'user_id') continue;
if (value === undefined) continue;
const userRowKey = key as keyof UserRow;
if (value === null) {
const oldVal = oldData?.[userRowKey];
if (oldVal !== null && oldVal !== undefined) {
userPatch[key] = Db.clear();
}
} else {
userPatch[key] = Db.set(value);
}
}
const result = await this.dataRepo.patchUser(userId, userPatch);
if (result.finalVersion === null) {
throw new Error(`Failed to patch user ${userId} after max retries due to concurrent updates`);
}
await this.indexRepo.syncIndices(newData, oldData);
const user = new User(newData);
await this.searchRepo.indexUser(user);
return user;
}
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
const user = await this.findUnique(userId);
if (!user) return;
await this.indexRepo.deleteIndices(
userId,
user.username,
user.discriminator,
user.email,
user.phone,
user.stripeSubscriptionId,
);
}
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
await this.dataRepo.updateLastActiveAt(params);
}
}

View File

@@ -0,0 +1,125 @@
/*
* 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 {createUserID, type UserID} from '~/BrandedTypes';
import {deleteOneOrMany, fetchMany, upsertOne} from '~/database/Cassandra';
import type {User} from '~/Models';
import {UsersPendingDeletion} from '~/Tables';
const FETCH_USERS_PENDING_DELETION_BY_DATE_CQL = UsersPendingDeletion.selectCql({
columns: ['user_id', 'pending_deletion_at'],
where: [UsersPendingDeletion.where.eq('deletion_date'), UsersPendingDeletion.where.lte('pending_deletion_at', 'now')],
});
const FETCH_USERS_PENDING_DELETION_BY_DATE_ALL_CQL = UsersPendingDeletion.selectCql({
columns: ['user_id', 'deletion_reason_code'],
where: UsersPendingDeletion.where.eq('deletion_date'),
});
const FETCH_USER_PENDING_DELETION_CHECK_CQL = UsersPendingDeletion.selectCql({
columns: ['user_id'],
where: [UsersPendingDeletion.where.eq('deletion_date'), UsersPendingDeletion.where.eq('user_id')],
});
export class UserDeletionRepository {
constructor(private findUniqueUser: (userId: UserID) => Promise<User | null>) {}
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
const deletionDate = pendingDeletionAt.toISOString().split('T')[0];
await upsertOne(
UsersPendingDeletion.upsertAll({
deletion_date: deletionDate,
pending_deletion_at: pendingDeletionAt,
user_id: userId,
deletion_reason_code: deletionReasonCode,
}),
);
}
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
const userIds = new Set<bigint>();
const startDate = new Date(now);
startDate.setDate(startDate.getDate() - 30);
const currentDate = new Date(startDate);
while (currentDate <= now) {
const deletionDate = currentDate.toISOString().split('T')[0];
const rows = await fetchMany<{user_id: bigint; pending_deletion_at: Date}>(
FETCH_USERS_PENDING_DELETION_BY_DATE_CQL,
{
deletion_date: deletionDate,
now,
},
);
for (const row of rows) {
userIds.add(row.user_id);
}
currentDate.setDate(currentDate.getDate() + 1);
}
const users: Array<User> = [];
for (const userId of userIds) {
const user = await this.findUniqueUser(createUserID(userId));
if (user?.pendingDeletionAt && user.pendingDeletionAt <= now) {
users.push(user);
}
}
return users;
}
async findUsersPendingDeletionByDate(
deletionDate: string,
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
const rows = await fetchMany<{user_id: bigint; deletion_reason_code: number}>(
FETCH_USERS_PENDING_DELETION_BY_DATE_ALL_CQL,
{deletion_date: deletionDate},
);
return rows;
}
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
const rows = await fetchMany<{user_id: bigint}>(FETCH_USER_PENDING_DELETION_CHECK_CQL, {
deletion_date: deletionDate,
user_id: userId,
});
return rows.length > 0;
}
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
const deletionDate = pendingDeletionAt.toISOString().split('T')[0];
await deleteOneOrMany(
UsersPendingDeletion.deleteByPk({
deletion_date: deletionDate,
pending_deletion_at: pendingDeletionAt,
user_id: userId,
}),
);
}
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
return this.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
}
}

View File

@@ -0,0 +1,60 @@
/*
* 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 {GuildID, UserID} from '~/BrandedTypes';
import {BatchBuilder, fetchMany} from '~/database/Cassandra';
import type {GuildMemberByUserIdRow} from '~/database/CassandraTypes';
import {GuildMembers, GuildMembersByUserId} from '~/Tables';
const FETCH_GUILD_MEMBERS_BY_USER_CQL = GuildMembersByUserId.selectCql({
where: GuildMembersByUserId.where.eq('user_id'),
});
export class UserGuildRepository {
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
const guilds = await fetchMany<GuildMemberByUserIdRow>(FETCH_GUILD_MEMBERS_BY_USER_CQL, {
user_id: userId,
});
return guilds.map((g) => g.guild_id);
}
async removeFromAllGuilds(userId: UserID): Promise<void> {
const guilds = await fetchMany<GuildMemberByUserIdRow>(FETCH_GUILD_MEMBERS_BY_USER_CQL, {
user_id: userId,
});
const batch = new BatchBuilder();
for (const guild of guilds) {
batch.addPrepared(
GuildMembers.deleteByPk({
guild_id: guild.guild_id,
user_id: userId,
}),
);
batch.addPrepared(
GuildMembersByUserId.deleteByPk({
user_id: userId,
guild_id: guild.guild_id,
}),
);
}
await batch.execute();
}
}

View File

@@ -0,0 +1,117 @@
/*
* 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 {UserID} from '~/BrandedTypes';
import {fetchMany, fetchOne} from '~/database/Cassandra';
import type {
UserByEmailRow,
UserByPhoneRow,
UserByStripeCustomerIdRow,
UserByStripeSubscriptionIdRow,
UserByUsernameRow,
} from '~/database/CassandraTypes';
import type {User} from '~/Models';
import {UserByEmail, UserByPhone, UserByStripeCustomerId, UserByStripeSubscriptionId, UserByUsername} from '~/Tables';
const FETCH_DISCRIMINATORS_BY_USERNAME_QUERY = UserByUsername.select({
columns: ['discriminator', 'user_id'],
where: UserByUsername.where.eq('username'),
});
const FETCH_USER_ID_BY_EMAIL_QUERY = UserByEmail.select({
columns: ['user_id'],
where: UserByEmail.where.eq('email_lower'),
limit: 1,
});
const FETCH_USER_ID_BY_PHONE_QUERY = UserByPhone.select({
columns: ['user_id'],
where: UserByPhone.where.eq('phone'),
limit: 1,
});
const FETCH_USER_ID_BY_STRIPE_CUSTOMER_ID_QUERY = UserByStripeCustomerId.select({
columns: ['user_id'],
where: UserByStripeCustomerId.where.eq('stripe_customer_id'),
limit: 1,
});
const FETCH_USER_ID_BY_STRIPE_SUBSCRIPTION_ID_QUERY = UserByStripeSubscriptionId.select({
columns: ['user_id'],
where: UserByStripeSubscriptionId.where.eq('stripe_subscription_id'),
limit: 1,
});
const FETCH_USER_ID_BY_USERNAME_DISCRIMINATOR_QUERY = UserByUsername.select({
columns: ['user_id'],
where: [UserByUsername.where.eq('username'), UserByUsername.where.eq('discriminator')],
limit: 1,
});
export class UserLookupRepository {
constructor(private findUniqueUser: (userId: UserID) => Promise<User | null>) {}
async findByEmail(email: string): Promise<User | null> {
const emailLower = email.toLowerCase();
const result = await fetchOne<Pick<UserByEmailRow, 'user_id'>>(
FETCH_USER_ID_BY_EMAIL_QUERY.bind({email_lower: emailLower}),
);
if (!result) return null;
return await this.findUniqueUser(result.user_id);
}
async findByPhone(phone: string): Promise<User | null> {
const result = await fetchOne<Pick<UserByPhoneRow, 'user_id'>>(FETCH_USER_ID_BY_PHONE_QUERY.bind({phone}));
if (!result) return null;
return await this.findUniqueUser(result.user_id);
}
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
const result = await fetchOne<Pick<UserByStripeCustomerIdRow, 'user_id'>>(
FETCH_USER_ID_BY_STRIPE_CUSTOMER_ID_QUERY.bind({stripe_customer_id: stripeCustomerId}),
);
if (!result) return null;
return await this.findUniqueUser(result.user_id);
}
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
const result = await fetchOne<Pick<UserByStripeSubscriptionIdRow, 'user_id'>>(
FETCH_USER_ID_BY_STRIPE_SUBSCRIPTION_ID_QUERY.bind({stripe_subscription_id: stripeSubscriptionId}),
);
if (!result) return null;
return await this.findUniqueUser(result.user_id);
}
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
const usernameLower = username.toLowerCase();
const result = await fetchOne<Pick<UserByUsernameRow, 'user_id'>>(
FETCH_USER_ID_BY_USERNAME_DISCRIMINATOR_QUERY.bind({username: usernameLower, discriminator}),
);
if (!result) return null;
return await this.findUniqueUser(result.user_id);
}
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
const usernameLower = username.toLowerCase();
const result = await fetchMany<Pick<UserByUsernameRow, 'discriminator'>>(
FETCH_DISCRIMINATORS_BY_USERNAME_QUERY.bind({username: usernameLower}),
);
return new Set(result.map((r) => r.discriminator));
}
}

View File

@@ -0,0 +1,151 @@
/*
* 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 {createUserID, type UserID} from '~/BrandedTypes';
import {buildPatchFromData, Db, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
import {EMPTY_USER_ROW, USER_COLUMNS, type UserRow} from '~/database/CassandraTypes';
import {User} from '~/Models';
import {Users} from '~/Tables';
const FETCH_USERS_BY_IDS_CQL = Users.selectCql({
where: Users.where.in('user_id', 'user_ids'),
});
const FETCH_USER_BY_ID_CQL = Users.selectCql({
where: Users.where.eq('user_id'),
limit: 1,
});
const createFetchAllUsersFirstPageCql = (limit: number) => Users.selectCql({limit});
const createFetchAllUsersPaginatedCql = (limit: number) =>
Users.selectCql({
where: Users.where.tokenGt('user_id', 'last_user_id'),
limit,
});
type UserPatch = Partial<{
[K in Exclude<keyof UserRow, 'user_id'> & string]: import('~/database/Cassandra').DbOp<UserRow[K]>;
}>;
export class UserDataRepository {
async findUnique(userId: UserID): Promise<User | null> {
if (userId === 0n) {
return new User({
...EMPTY_USER_ROW,
user_id: createUserID(0n),
username: 'Fluxer',
discriminator: 0,
bot: true,
system: true,
});
}
if (userId === 1n) {
return new User({
...EMPTY_USER_ROW,
user_id: createUserID(1n),
username: 'Deleted User',
discriminator: 0,
bot: false,
system: true,
});
}
const user = await fetchOne<UserRow>(FETCH_USER_BY_ID_CQL, {user_id: userId});
return user ? new User(user) : null;
}
async findUniqueAssert(userId: UserID): Promise<User> {
return (await this.findUnique(userId))!;
}
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
let users: Array<UserRow>;
if (lastUserId) {
const cql = createFetchAllUsersPaginatedCql(limit);
users = await fetchMany<UserRow>(cql, {last_user_id: lastUserId});
} else {
const cql = createFetchAllUsersFirstPageCql(limit);
users = await fetchMany<UserRow>(cql, {});
}
return users.map((user) => new User(user));
}
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
if (userIds.length === 0) return [];
const users = await fetchMany<UserRow>(FETCH_USERS_BY_IDS_CQL, {user_ids: userIds});
return users.map((user) => new User(user));
}
async upsertUserRow(data: UserRow, oldData?: UserRow | null): Promise<{finalVersion: number | null}> {
const userId = data.user_id;
let isFirstAttempt = true;
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
async () => {
if (isFirstAttempt && oldData !== undefined) {
isFirstAttempt = false;
return oldData;
}
isFirstAttempt = false;
const user = await this.findUnique(userId);
return user?.toRow() ?? null;
},
(current) => ({
pk: {user_id: userId},
patch: buildPatchFromData(data, current, USER_COLUMNS, ['user_id']),
}),
Users,
{onFailure: 'log'},
);
return {finalVersion: result.finalVersion};
}
async patchUser(userId: UserID, patch: UserPatch): Promise<{finalVersion: number | null}> {
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
async () => {
const user = await this.findUnique(userId);
return user?.toRow() ?? null;
},
(_current) => ({
pk: {user_id: userId},
patch,
}),
Users,
{onFailure: 'log'},
);
return {finalVersion: result.finalVersion};
}
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
const {userId, lastActiveAt, lastActiveIp} = params;
const patch: UserPatch = {
last_active_at: Db.set(lastActiveAt),
...(lastActiveIp !== undefined ? {last_active_ip: Db.set(lastActiveIp)} : {}),
};
await this.patchUser(userId, patch);
}
}

View File

@@ -0,0 +1,171 @@
/*
* 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 {UserID} from '~/BrandedTypes';
import {BatchBuilder} from '~/database/Cassandra';
import type {UserRow} from '~/database/CassandraTypes';
import {UserByEmail, UserByPhone, UserByStripeCustomerId, UserByStripeSubscriptionId, UserByUsername} from '~/Tables';
export class UserIndexRepository {
async syncIndices(data: UserRow, oldData?: UserRow | null): Promise<void> {
const batch = new BatchBuilder();
if (!!data.username && data.discriminator != null && data.discriminator !== undefined) {
batch.addPrepared(
UserByUsername.upsertAll({
username: data.username.toLowerCase(),
discriminator: data.discriminator,
user_id: data.user_id,
}),
);
}
if (oldData?.username && oldData.discriminator != null && oldData.discriminator !== undefined) {
if (
oldData.username.toLowerCase() !== data.username?.toLowerCase() ||
oldData.discriminator !== data.discriminator
) {
batch.addPrepared(
UserByUsername.deleteByPk({
username: oldData.username.toLowerCase(),
discriminator: oldData.discriminator,
user_id: oldData.user_id,
}),
);
}
}
if (data.email) {
batch.addPrepared(
UserByEmail.upsertAll({
email_lower: data.email.toLowerCase(),
user_id: data.user_id,
}),
);
}
if (oldData?.email && oldData.email.toLowerCase() !== data.email?.toLowerCase()) {
batch.addPrepared(
UserByEmail.deleteByPk({
email_lower: oldData.email.toLowerCase(),
user_id: oldData.user_id,
}),
);
}
if (data.phone) {
batch.addPrepared(
UserByPhone.upsertAll({
phone: data.phone,
user_id: data.user_id,
}),
);
}
if (oldData?.phone && oldData.phone !== data.phone) {
batch.addPrepared(
UserByPhone.deleteByPk({
phone: oldData.phone,
user_id: oldData.user_id,
}),
);
}
if (data.stripe_subscription_id) {
batch.addPrepared(
UserByStripeSubscriptionId.upsertAll({
stripe_subscription_id: data.stripe_subscription_id,
user_id: data.user_id,
}),
);
}
if (oldData?.stripe_subscription_id && oldData.stripe_subscription_id !== data.stripe_subscription_id) {
batch.addPrepared(
UserByStripeSubscriptionId.deleteByPk({
stripe_subscription_id: oldData.stripe_subscription_id,
user_id: oldData.user_id,
}),
);
}
if (data.stripe_customer_id) {
batch.addPrepared(
UserByStripeCustomerId.upsertAll({
stripe_customer_id: data.stripe_customer_id,
user_id: data.user_id,
}),
);
}
if (oldData?.stripe_customer_id && oldData.stripe_customer_id !== data.stripe_customer_id) {
batch.addPrepared(
UserByStripeCustomerId.deleteByPk({
stripe_customer_id: oldData.stripe_customer_id,
user_id: oldData.user_id,
}),
);
}
await batch.execute();
}
async deleteIndices(
userId: UserID,
username: string,
discriminator: number,
email?: string | null,
phone?: string | null,
stripeSubscriptionId?: string | null,
): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(
UserByUsername.deleteByPk({
username: username.toLowerCase(),
discriminator: discriminator,
user_id: userId,
}),
);
if (email) {
batch.addPrepared(
UserByEmail.deleteByPk({
email_lower: email.toLowerCase(),
user_id: userId,
}),
);
}
if (phone) {
batch.addPrepared(
UserByPhone.deleteByPk({
phone: phone,
user_id: userId,
}),
);
}
if (stripeSubscriptionId) {
batch.addPrepared(
UserByStripeSubscriptionId.deleteByPk({
stripe_subscription_id: stripeSubscriptionId,
user_id: userId,
}),
);
}
await batch.execute();
}
}

View File

@@ -0,0 +1,33 @@
/*
* 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 {Logger} from '~/Logger';
import {getUserSearchService} from '~/Meilisearch';
import type {User} from '~/Models';
export class UserSearchRepository {
async indexUser(user: User): Promise<void> {
const userSearchService = getUserSearchService();
if (userSearchService) {
await userSearchService.indexUser(user).catch((error) => {
Logger.error({userId: user.id, error}, 'Failed to index user in search');
});
}
}
}

View File

@@ -0,0 +1,135 @@
/*
* 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 {UserID} from '~/BrandedTypes';
import {BatchBuilder, Db, executeConditional, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {AuthSessionRow} from '~/database/CassandraTypes';
import {Logger} from '~/Logger';
import {AuthSession} from '~/Models';
import {AuthSessions, AuthSessionsByUserId} from '~/Tables';
const FETCH_AUTH_SESSIONS_CQL = AuthSessions.selectCql({
where: AuthSessions.where.in('session_id_hash', 'session_id_hashes'),
});
const FETCH_AUTH_SESSION_BY_TOKEN_CQL = AuthSessions.selectCql({
where: AuthSessions.where.eq('session_id_hash'),
limit: 1,
});
const FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL = AuthSessionsByUserId.selectCql({
columns: ['session_id_hash'],
where: AuthSessionsByUserId.where.eq('user_id'),
});
export class AuthSessionRepository {
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
const sessionResult = await executeConditional(AuthSessions.insertIfNotExists(sessionData));
if (!sessionResult.applied) {
const existingSession = await this.getAuthSessionByToken(sessionData.session_id_hash);
if (!existingSession) {
Logger.error(
{sessionIdHash: sessionData.session_id_hash},
'Failed to create or retrieve existing auth session',
);
throw new Error('Failed to create or retrieve existing auth session');
}
Logger.debug(
{sessionIdHash: sessionData.session_id_hash},
'Auth session already exists, returning existing session',
);
return existingSession;
}
try {
await upsertOne(
AuthSessionsByUserId.insert({
user_id: sessionData.user_id,
session_id_hash: sessionData.session_id_hash,
}),
);
} catch (error) {
Logger.error({sessionIdHash: sessionData.session_id_hash, error}, 'Failed to create AuthSessionsByUserId entry');
}
return new AuthSession(sessionData);
}
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
const session = await fetchOne<AuthSessionRow>(FETCH_AUTH_SESSION_BY_TOKEN_CQL, {session_id_hash: sessionIdHash});
return session ? new AuthSession(session) : null;
}
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
const sessionHashes = await fetchMany<{session_id_hash: Buffer}>(FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL, {
user_id: userId,
});
if (sessionHashes.length === 0) return [];
const sessions = await fetchMany<AuthSessionRow>(FETCH_AUTH_SESSIONS_CQL, {
session_id_hashes: sessionHashes.map((s) => s.session_id_hash),
});
return sessions.map((session) => new AuthSession(session));
}
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
await upsertOne(
AuthSessions.patchByPk(
{session_id_hash: sessionIdHash},
{
approx_last_used_at: Db.set(new Date()),
},
),
);
}
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
const batch = new BatchBuilder();
for (const sessionIdHash of sessionIdHashes) {
batch.addPrepared(AuthSessions.deleteByPk({session_id_hash: sessionIdHash}));
batch.addPrepared(AuthSessionsByUserId.deleteByPk({user_id: userId, session_id_hash: sessionIdHash}));
}
await batch.execute();
}
async deleteAllAuthSessions(userId: UserID): Promise<void> {
const sessions = await fetchMany<{session_id_hash: Buffer}>(FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL, {
user_id: userId,
});
const batch = new BatchBuilder();
for (const session of sessions) {
batch.addPrepared(
AuthSessions.deleteByPk({
session_id_hash: session.session_id_hash,
}),
);
batch.addPrepared(
AuthSessionsByUserId.deleteByPk({
user_id: userId,
session_id_hash: session.session_id_hash,
}),
);
}
if (batch) {
await batch.execute();
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* 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 {deleteOneOrMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {EmailChangeTicketRow, EmailChangeTokenRow} from '~/database/CassandraTypes';
import {EmailChangeTickets, EmailChangeTokens} from '~/Tables';
const FETCH_TICKET_CQL = EmailChangeTickets.selectCql({
where: EmailChangeTickets.where.eq('ticket'),
limit: 1,
});
const FETCH_TOKEN_CQL = EmailChangeTokens.selectCql({
where: EmailChangeTokens.where.eq('token_'),
limit: 1,
});
export class EmailChangeRepository {
async createTicket(row: EmailChangeTicketRow): Promise<void> {
await upsertOne(EmailChangeTickets.insert(row));
}
async updateTicket(row: EmailChangeTicketRow): Promise<void> {
await upsertOne(EmailChangeTickets.upsertAll(row));
}
async findTicket(ticket: string): Promise<EmailChangeTicketRow | null> {
return await fetchOne<EmailChangeTicketRow>(FETCH_TICKET_CQL, {ticket});
}
async deleteTicket(ticket: string): Promise<void> {
await deleteOneOrMany(EmailChangeTickets.deleteByPk({ticket}));
}
async createToken(row: EmailChangeTokenRow): Promise<void> {
await upsertOne(EmailChangeTokens.insert(row));
}
async findToken(token: string): Promise<EmailChangeTokenRow | null> {
return await fetchOne<EmailChangeTokenRow>(FETCH_TOKEN_CQL, {token_: token});
}
async deleteToken(token: string): Promise<void> {
await deleteOneOrMany(EmailChangeTokens.deleteByPk({token_: token}));
}
}

View File

@@ -0,0 +1,120 @@
/*
* 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 {createIpAuthorizationToken, type UserID} from '~/BrandedTypes';
import {UserFlags} from '~/Constants';
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {AuthorizedIpRow, IpAuthorizationTokenRow} from '~/database/CassandraTypes';
import {AuthorizedIps, IpAuthorizationTokens, Users} from '~/Tables';
import type {IUserAccountRepository} from '../IUserAccountRepository';
export {IpAuthorizationTokens};
const AUTHORIZE_IP_BY_TOKEN_CQL = IpAuthorizationTokens.selectCql({
where: IpAuthorizationTokens.where.eq('token_'),
limit: 1,
});
const CHECK_IP_AUTHORIZED_CQL = AuthorizedIps.selectCql({
where: [AuthorizedIps.where.eq('user_id'), AuthorizedIps.where.eq('ip')],
limit: 1,
});
const GET_AUTHORIZED_IPS_CQL = AuthorizedIps.selectCql({
where: AuthorizedIps.where.eq('user_id'),
});
export class IpAuthorizationRepository {
constructor(private userAccountRepository: IUserAccountRepository) {}
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
const result = await fetchOne<AuthorizedIpRow>(CHECK_IP_AUTHORIZED_CQL, {
user_id: userId,
ip,
});
return !!result;
}
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
await upsertOne(AuthorizedIps.insert({user_id: userId, ip}));
}
async createIpAuthorizationToken(userId: UserID, token: string): Promise<void> {
const user = await this.userAccountRepository.findUnique(userId);
await upsertOne(
IpAuthorizationTokens.insert({
token_: createIpAuthorizationToken(token),
user_id: userId,
email: user!.email!,
}),
);
}
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
const result = await fetchOne<IpAuthorizationTokenRow>(AUTHORIZE_IP_BY_TOKEN_CQL, {token_: token});
if (!result) {
return null;
}
await deleteOneOrMany(
IpAuthorizationTokens.deleteByPk({
token_: createIpAuthorizationToken(token),
user_id: result.user_id,
}),
);
const user = await this.userAccountRepository.findUnique(result.user_id);
if (!user || user.flags & UserFlags.DELETED) {
return null;
}
return {userId: result.user_id, email: result.email};
}
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
const now = new Date();
await upsertOne(
Users.patchByPk(
{user_id: userId},
{
last_active_at: Db.set(now),
last_active_ip: Db.set(clientIp),
},
),
);
}
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
const ips = await fetchMany<AuthorizedIpRow>(GET_AUTHORIZED_IPS_CQL, {user_id: userId});
return ips.map((row) => ({ip: row.ip}));
}
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
const ips = await fetchMany<AuthorizedIpRow>(GET_AUTHORIZED_IPS_CQL, {user_id: userId});
for (const row of ips) {
await deleteOneOrMany(
AuthorizedIps.deleteByPk({
user_id: userId,
ip: row.ip,
}),
);
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* 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 {createMfaBackupCode, type UserID} from '~/BrandedTypes';
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, upsertOne} from '~/database/Cassandra';
import type {MfaBackupCodeRow} from '~/database/CassandraTypes';
import {MfaBackupCode} from '~/Models';
import {MfaBackupCodes} from '~/Tables';
const FETCH_MFA_BACKUP_CODES_CQL = MfaBackupCodes.selectCql({
where: MfaBackupCodes.where.eq('user_id'),
});
export class MfaBackupCodeRepository {
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
const codes = await fetchMany<MfaBackupCodeRow>(FETCH_MFA_BACKUP_CODES_CQL, {user_id: userId});
return codes.map((code) => new MfaBackupCode(code));
}
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
const batch = new BatchBuilder();
const backupCodes: Array<MfaBackupCode> = [];
for (const code of codes) {
const codeRow: MfaBackupCodeRow = {user_id: userId, code: createMfaBackupCode(code), consumed: false};
batch.addPrepared(MfaBackupCodes.insert(codeRow));
backupCodes.push(new MfaBackupCode(codeRow));
}
await batch.execute();
return backupCodes;
}
async clearMfaBackupCodes(userId: UserID): Promise<void> {
const codes = await this.listMfaBackupCodes(userId);
if (codes.length === 0) return;
const batch = new BatchBuilder();
for (const code of codes) {
batch.addPrepared(MfaBackupCodes.deleteByPk({user_id: userId, code: createMfaBackupCode(code.code)}));
}
await batch.execute();
}
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
await upsertOne(
MfaBackupCodes.patchByPk(
{user_id: userId, code: createMfaBackupCode(code)},
{
consumed: Db.set(true),
},
),
);
}
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
await deleteOneOrMany(MfaBackupCodes.deleteCql({where: MfaBackupCodes.where.eq('user_id')}), {user_id: userId});
}
}

View File

@@ -0,0 +1,68 @@
/*
* 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 {UserID} from '~/BrandedTypes';
import {BatchBuilder, executeConditional, fetchOne, upsertOne} from '~/database/Cassandra';
import {PendingVerifications, PendingVerificationsByTime} from '~/Tables';
const FETCH_PENDING_VERIFICATION_CQL = PendingVerifications.selectCql({
where: PendingVerifications.where.eq('user_id'),
limit: 1,
});
export class PendingVerificationRepository {
async createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void> {
const verificationResult = await executeConditional(
PendingVerifications.insertIfNotExists({
user_id: userId,
created_at: createdAt,
version: 1,
metadata,
}),
);
if (!verificationResult.applied) {
return;
}
await upsertOne(
PendingVerificationsByTime.insert({
created_at: createdAt,
user_id: userId,
}),
);
}
async deletePendingVerification(userId: UserID): Promise<void> {
const pending = await fetchOne<{user_id: bigint; created_at: Date}>(FETCH_PENDING_VERIFICATION_CQL, {
user_id: userId,
});
if (!pending) return;
const batch = new BatchBuilder();
batch.addPrepared(PendingVerifications.deleteByPk({user_id: userId}));
batch.addPrepared(
PendingVerificationsByTime.deleteByPk({
created_at: pending.created_at,
user_id: userId,
}),
);
await batch.execute();
}
}

View File

@@ -0,0 +1,141 @@
/*
* 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 {
createEmailRevertToken,
createEmailVerificationToken,
createPasswordResetToken,
type PhoneVerificationToken,
type UserID,
} from '~/BrandedTypes';
import {deleteOneOrMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '~/database/CassandraTypes';
import {EmailRevertToken, EmailVerificationToken, PasswordResetToken} from '~/Models';
import {EmailRevertTokens, EmailVerificationTokens, PasswordResetTokens, PhoneTokens} from '~/Tables';
const FETCH_EMAIL_VERIFICATION_TOKEN_CQL = EmailVerificationTokens.selectCql({
where: EmailVerificationTokens.where.eq('token_'),
limit: 1,
});
const FETCH_PASSWORD_RESET_TOKEN_CQL = PasswordResetTokens.selectCql({
where: PasswordResetTokens.where.eq('token_'),
limit: 1,
});
const FETCH_EMAIL_REVERT_TOKEN_CQL = EmailRevertTokens.selectCql({
where: EmailRevertTokens.where.eq('token_'),
limit: 1,
});
const FETCH_PHONE_TOKEN_CQL = PhoneTokens.selectCql({
where: PhoneTokens.where.eq('token_'),
limit: 1,
});
export class TokenRepository {
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
const tokenRow = await fetchOne<EmailVerificationTokenRow>(FETCH_EMAIL_VERIFICATION_TOKEN_CQL, {token_: token});
return tokenRow ? new EmailVerificationToken(tokenRow) : null;
}
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
await upsertOne(EmailVerificationTokens.insert(tokenData));
return new EmailVerificationToken(tokenData);
}
async deleteEmailVerificationToken(token: string): Promise<void> {
await deleteOneOrMany(
EmailVerificationTokens.deleteCql({
where: EmailVerificationTokens.where.eq('token_'),
}),
{token_: createEmailVerificationToken(token)},
);
}
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
const tokenRow = await fetchOne<PasswordResetTokenRow>(FETCH_PASSWORD_RESET_TOKEN_CQL, {token_: token});
return tokenRow ? new PasswordResetToken(tokenRow) : null;
}
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
await upsertOne(PasswordResetTokens.insert(tokenData));
return new PasswordResetToken(tokenData);
}
async deletePasswordResetToken(token: string): Promise<void> {
await deleteOneOrMany(
PasswordResetTokens.deleteCql({
where: PasswordResetTokens.where.eq('token_'),
}),
{token_: createPasswordResetToken(token)},
);
}
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
const tokenRow = await fetchOne<EmailRevertTokenRow>(FETCH_EMAIL_REVERT_TOKEN_CQL, {token_: token});
return tokenRow ? new EmailRevertToken(tokenRow) : null;
}
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
await upsertOne(EmailRevertTokens.insert(tokenData));
return new EmailRevertToken(tokenData);
}
async deleteEmailRevertToken(token: string): Promise<void> {
await deleteOneOrMany(
EmailRevertTokens.deleteCql({
where: EmailRevertTokens.where.eq('token_'),
}),
{token_: createEmailRevertToken(token)},
);
}
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
const TTL = 900;
await upsertOne(
PhoneTokens.insertWithTtl(
{
token_: token,
phone,
user_id: userId,
},
TTL,
),
);
}
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
return await fetchOne<PhoneTokenRow>(FETCH_PHONE_TOKEN_CQL, {token_: token});
}
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
await deleteOneOrMany(
PhoneTokens.deleteCql({
where: PhoneTokens.where.eq('token_'),
}),
{token_: token},
);
}
}

View File

@@ -0,0 +1,171 @@
/*
* 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 {UserID} from '~/BrandedTypes';
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {WebAuthnCredentialRow} from '~/database/CassandraTypes';
import {WebAuthnCredential} from '~/Models';
import {WebAuthnCredentialLookup, WebAuthnCredentials} from '~/Tables';
const FETCH_USER_ID_BY_CREDENTIAL_ID_CQL = WebAuthnCredentialLookup.selectCql({
where: WebAuthnCredentialLookup.where.eq('credential_id'),
limit: 1,
});
const FETCH_WEBAUTHN_CREDENTIALS_CQL = WebAuthnCredentials.selectCql({
where: WebAuthnCredentials.where.eq('user_id'),
});
const FETCH_WEBAUTHN_CREDENTIAL_CQL = WebAuthnCredentials.selectCql({
where: [WebAuthnCredentials.where.eq('user_id'), WebAuthnCredentials.where.eq('credential_id')],
limit: 1,
});
const FETCH_WEBAUTHN_CREDENTIALS_FOR_USER_CQL = WebAuthnCredentials.selectCql({
columns: ['credential_id'],
where: WebAuthnCredentials.where.eq('user_id'),
});
export class WebAuthnRepository {
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
const credentials = await fetchMany<WebAuthnCredentialRow>(FETCH_WEBAUTHN_CREDENTIALS_CQL, {user_id: userId});
return credentials.map((cred) => new WebAuthnCredential(cred));
}
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
const cred = await fetchOne<WebAuthnCredentialRow>(FETCH_WEBAUTHN_CREDENTIAL_CQL, {
user_id: userId,
credential_id: credentialId,
});
if (!cred) {
return null;
}
return new WebAuthnCredential(cred);
}
async createWebAuthnCredential(
userId: UserID,
credentialId: string,
publicKey: Buffer,
counter: bigint,
transports: Set<string> | null,
name: string,
): Promise<void> {
const credentialData = {
user_id: userId,
credential_id: credentialId,
public_key: publicKey,
counter: counter,
transports: transports,
name: name,
created_at: new Date(),
last_used_at: null,
version: 1 as const,
};
await upsertOne(WebAuthnCredentials.insert(credentialData));
await upsertOne(
WebAuthnCredentialLookup.insert({
credential_id: credentialId,
user_id: userId,
}),
);
}
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
await upsertOne(
WebAuthnCredentials.patchByPk(
{user_id: userId, credential_id: credentialId},
{
counter: Db.set(counter),
},
),
);
}
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
await upsertOne(
WebAuthnCredentials.patchByPk(
{user_id: userId, credential_id: credentialId},
{
last_used_at: Db.set(new Date()),
},
),
);
}
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
await upsertOne(
WebAuthnCredentials.patchByPk(
{user_id: userId, credential_id: credentialId},
{
name: Db.set(name),
},
),
);
}
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
await deleteOneOrMany(
WebAuthnCredentials.deleteByPk({
user_id: userId,
credential_id: credentialId,
}),
);
await deleteOneOrMany(
WebAuthnCredentialLookup.deleteByPk({
credential_id: credentialId,
}),
);
}
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
const row = await fetchOne<{credential_id: string; user_id: UserID}>(FETCH_USER_ID_BY_CREDENTIAL_ID_CQL, {
credential_id: credentialId,
});
return row?.user_id ?? null;
}
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
const credentials = await fetchMany<{credential_id: string}>(FETCH_WEBAUTHN_CREDENTIALS_FOR_USER_CQL, {
user_id: userId,
});
const batch = new BatchBuilder();
for (const cred of credentials) {
batch.addPrepared(
WebAuthnCredentials.deleteByPk({
user_id: userId,
credential_id: cred.credential_id,
}),
);
batch.addPrepared(
WebAuthnCredentialLookup.deleteByPk({
credential_id: cred.credential_id,
}),
);
}
await batch.execute();
}
}