refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,169 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, Db, executeConditional, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {GiftCodeRow} from '@fluxer/api/src/database/types/PaymentTypes';
import {GiftCode} from '@fluxer/api/src/models/GiftCode';
import {GiftCodes, GiftCodesByCreator, GiftCodesByPaymentIntent, GiftCodesByRedeemer} from '@fluxer/api/src/Tables';
const FETCH_GIFT_CODES_BY_CREATOR_QUERY = GiftCodesByCreator.selectCql({
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 codes = await fetchMany<{code: string}>(FETCH_GIFT_CODES_BY_CREATOR_QUERY, {
created_by_user_id: userId,
});
if (codes.length === 0) {
return [];
}
const gifts: Array<GiftCode> = [];
for (const {code} of codes) {
const gift = await this.findGiftCode(code);
if (gift) {
gifts.push(gift);
}
}
return gifts;
}
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,56 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import type {User} from '@fluxer/api/src/models/User';
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>;
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>;
updateSubscriptionStatus(
userId: UserID,
updates: {premiumWillCancel: boolean; computedPremiumUntil: Date | null},
): Promise<{finalVersion: number | null}>;
}

View File

@@ -0,0 +1,88 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import type {
AuthSessionRow,
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '@fluxer/api/src/database/types/AuthTypes';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {EmailRevertToken} from '@fluxer/api/src/models/EmailRevertToken';
import type {EmailVerificationToken} from '@fluxer/api/src/models/EmailVerificationToken';
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
import type {PasswordResetToken} from '@fluxer/api/src/models/PasswordResetToken';
import type {WebAuthnCredential} from '@fluxer/api/src/models/WebAuthnCredential';
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, email: 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>;
}

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 {ChannelID, MessageID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {Channel} from '@fluxer/api/src/models/Channel';
export interface PrivateChannelSummary {
channelId: ChannelID;
isGroupDm: boolean;
channelType: number | null;
lastMessageId: MessageID | null;
open: boolean;
}
export interface ListHistoricalDmChannelOptions {
limit: number;
beforeChannelId?: ChannelID;
afterChannelId?: ChannelID;
}
export interface HistoricalDmChannelSummary {
channelId: ChannelID;
channelType: number | null;
recipientIds: Array<UserID>;
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>>;
listHistoricalDmChannelIds(userId: UserID): Promise<Array<ChannelID>>;
listHistoricalDmChannelsPaginated(
userId: UserID,
options: ListHistoricalDmChannelOptions,
): Promise<Array<HistoricalDmChannelSummary>>;
recordHistoricalDmChannel(userId: UserID, channelId: ChannelID, isGroupDm: boolean): Promise<void>;
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,84 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import type {ExactRow} from '@fluxer/api/src/database/types/DatabaseRowTypes';
import type {GiftCodeRow, PaymentBySubscriptionRow, PaymentRow} from '@fluxer/api/src/database/types/PaymentTypes';
import type {PushSubscriptionRow, RecentMentionRow} from '@fluxer/api/src/database/types/UserTypes';
import type {GiftCode} from '@fluxer/api/src/models/GiftCode';
import type {Payment} from '@fluxer/api/src/models/Payment';
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
import type {RecentMention} from '@fluxer/api/src/models/RecentMention';
import type {SavedMessage} from '@fluxer/api/src/models/SavedMessage';
import type {VisionarySlot} from '@fluxer/api/src/models/VisionarySlot';
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: ExactRow<RecentMentionRow>): Promise<RecentMention>;
createRecentMentions(mentions: Array<ExactRow<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>;
createGiftCode(data: ExactRow<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: ExactRow<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,39 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import type {RelationshipRow} from '@fluxer/api/src/database/types/UserTypes';
import type {Relationship} from '@fluxer/api/src/models/Relationship';
import type {UserNote} from '@fluxer/api/src/models/UserNote';
export interface IUserRelationshipRepository {
listRelationships(sourceUserId: UserID): Promise<Array<Relationship>>;
hasReachedRelationshipLimit(sourceUserId: UserID, limit: number): Promise<boolean>;
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>;
backfillRelationshipsIndex(userId: UserID, relationships: Array<Relationship>): 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 '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserAuthRepository} from '@fluxer/api/src/user/repositories/IUserAuthRepository';
import type {IUserChannelRepository} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import type {IUserContentRepository} from '@fluxer/api/src/user/repositories/IUserContentRepository';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/IUserRelationshipRepository';
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/IUserSettingsRepository';
export interface IUserRepositoryAggregate
extends IUserAccountRepository,
IUserAuthRepository,
IUserSettingsRepository,
IUserRelationshipRepository,
IUserChannelRepository,
IUserContentRepository {}

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 {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {ExactRow} from '@fluxer/api/src/database/types/DatabaseRowTypes';
import type {UserGuildSettingsRow, UserSettingsRow} from '@fluxer/api/src/database/types/UserTypes';
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
export interface IUserSettingsRepository {
findSettings(userId: UserID): Promise<UserSettings | null>;
upsertSettings(settings: ExactRow<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: ExactRow<UserGuildSettingsRow>): Promise<UserGuildSettings>;
deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void>;
deleteAllUserGuildSettings(userId: UserID): Promise<void>;
}

View File

@@ -0,0 +1,204 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, Db, executeVersionedUpdate, fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra';
import type {PaymentBySubscriptionRow, PaymentRow} from '@fluxer/api/src/database/types/PaymentTypes';
import {Payment} from '@fluxer/api/src/models/Payment';
import {Payments, PaymentsByPaymentIntent, PaymentsBySubscription, PaymentsByUser} from '@fluxer/api/src/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,
);
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 '@fluxer/api/src/BrandedTypes';
import {deleteOneOrMany, fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {PushSubscriptionRow} from '@fluxer/api/src/database/types/UserTypes';
import {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
import {PushSubscriptions} from '@fluxer/api/src/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(
PushSubscriptions.delete({where: PushSubscriptions.where.eq('user_id')}).bind({user_id: userId}),
);
}
}

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 {createMessageID, type MessageID, type UserID} from '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra';
import type {RecentMentionRow} from '@fluxer/api/src/database/types/UserTypes';
import {RecentMention} from '@fluxer/api/src/models/RecentMention';
import {RecentMentions, RecentMentionsByGuild} from '@fluxer/api/src/Tables';
import {generateSnowflake} from '@fluxer/snowflake/src/Snowflake';
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(generateSnowflake()),
};
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(RecentMentions.delete({where: RecentMentions.where.eq('user_id')}).bind({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,70 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {deleteOneOrMany, fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {SavedMessageRow} from '@fluxer/api/src/database/types/UserTypes';
import {SavedMessage} from '@fluxer/api/src/models/SavedMessage';
import {SavedMessages} from '@fluxer/api/src/Tables';
import {generateSnowflake} from '@fluxer/snowflake/src/Snowflake';
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(generateSnowflake()),
): 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(SavedMessages.delete({where: SavedMessages.where.eq('user_id')}).bind({user_id: userId}));
}
}

View File

@@ -0,0 +1,82 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {ScheduledMessageRow} from '@fluxer/api/src/database/types/UserTypes';
import {ScheduledMessage} from '@fluxer/api/src/models/ScheduledMessage';
import {ScheduledMessages} from '@fluxer/api/src/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 upsertOne(ScheduledMessages.upsertAll(message.toRow()));
}
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 upsertOne(
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,147 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import type {User} from '@fluxer/api/src/models/User';
import {UserAccountRepository as UserAccountCrudRepository} from '@fluxer/api/src/user/repositories/account/UserAccountRepository';
import {UserDeletionRepository} from '@fluxer/api/src/user/repositories/account/UserDeletionRepository';
import {UserGuildRepository} from '@fluxer/api/src/user/repositories/account/UserGuildRepository';
import {UserLookupRepository} from '@fluxer/api/src/user/repositories/account/UserLookupRepository';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/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> {
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 result = await this.accountRepo.getActivityTracking(userId);
return result ?? {last_active_at: null, last_active_ip: 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);
}
async updateSubscriptionStatus(
userId: UserID,
updates: {premiumWillCancel: boolean; computedPremiumUntil: Date | null},
): Promise<{finalVersion: number | null}> {
return this.accountRepo.updateSubscriptionStatus(userId, updates);
}
}

View File

@@ -0,0 +1,227 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import type {
AuthSessionRow,
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '@fluxer/api/src/database/types/AuthTypes';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {EmailRevertToken} from '@fluxer/api/src/models/EmailRevertToken';
import type {EmailVerificationToken} from '@fluxer/api/src/models/EmailVerificationToken';
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
import type {PasswordResetToken} from '@fluxer/api/src/models/PasswordResetToken';
import type {WebAuthnCredential} from '@fluxer/api/src/models/WebAuthnCredential';
import {AuthSessionRepository} from '@fluxer/api/src/user/repositories/auth/AuthSessionRepository';
import {IpAuthorizationRepository} from '@fluxer/api/src/user/repositories/auth/IpAuthorizationRepository';
import {MfaBackupCodeRepository} from '@fluxer/api/src/user/repositories/auth/MfaBackupCodeRepository';
import {TokenRepository} from '@fluxer/api/src/user/repositories/auth/TokenRepository';
import {WebAuthnRepository} from '@fluxer/api/src/user/repositories/auth/WebAuthnRepository';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import type {IUserAuthRepository} from '@fluxer/api/src/user/repositories/IUserAuthRepository';
export class UserAuthRepository implements IUserAuthRepository {
private authSessionRepository: AuthSessionRepository;
private mfaBackupCodeRepository: MfaBackupCodeRepository;
private tokenRepository: TokenRepository;
private ipAuthorizationRepository: IpAuthorizationRepository;
private webAuthnRepository: WebAuthnRepository;
constructor(userAccountRepository: IUserAccountRepository) {
this.authSessionRepository = new AuthSessionRepository();
this.mfaBackupCodeRepository = new MfaBackupCodeRepository();
this.tokenRepository = new TokenRepository();
this.ipAuthorizationRepository = new IpAuthorizationRepository(userAccountRepository);
this.webAuthnRepository = new WebAuthnRepository();
}
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, email: string): Promise<void> {
return this.ipAuthorizationRepository.createIpAuthorizationToken(userId, token, email);
}
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);
}
}

View File

@@ -0,0 +1,536 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {
BatchBuilder,
deleteOneOrMany,
fetchMany,
fetchManyInChunks,
fetchOne,
upsertOne,
} from '@fluxer/api/src/database/Cassandra';
import type {ChannelRow, DmStateRow, PrivateChannelRow} from '@fluxer/api/src/database/types/ChannelTypes';
import {Channel} from '@fluxer/api/src/models/Channel';
import {Channels, DmStates, PinnedDms, PrivateChannels, ReadStates, UserDmHistory} from '@fluxer/api/src/Tables';
import type {
HistoricalDmChannelSummary,
IUserChannelRepository,
ListHistoricalDmChannelOptions,
PrivateChannelSummary,
} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
interface PinnedDmRow {
user_id: UserID;
channel_id: ChannelID;
sort_order: number;
}
interface ChannelDetailsRow {
channel_id: ChannelID;
type: number;
recipient_ids: Set<UserID> | null;
last_message_id: MessageID | null;
soft_deleted: boolean;
}
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_OPEN_PRIVATE_CHANNELS_BY_IDS_CQL = PrivateChannels.selectCql({
columns: ['channel_id'],
where: [PrivateChannels.where.eq('user_id'), PrivateChannels.where.in('channel_id', 'channel_ids')],
});
const HISTORICAL_DM_CHANNELS_CQL = UserDmHistory.selectCql({
columns: ['channel_id'],
where: UserDmHistory.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_CHANNEL_DETAILS_CQL = Channels.selectCql({
columns: ['channel_id', 'type', 'recipient_ids', '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'}],
});
function sortBySortOrder(a: PinnedDmRow, b: PinnedDmRow): number {
return 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);
}
let highestSortOrder = -1;
for (const dm of pinnedDms) {
if (dm.sort_order > highestSortOrder) {
highestSortOrder = dm.sort_order;
}
}
const newSortOrder = highestSortOrder + 1;
await upsertOne(
PinnedDms.upsertAll({
user_id: userId,
channel_id: channelId,
sort_order: newSortOrder,
}),
);
pinnedDms.push({
user_id: userId,
channel_id: channelId,
sort_order: newSortOrder,
});
pinnedDms.sort(sortBySortOrder);
return pinnedDms.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 listHistoricalDmChannelIds(userId: UserID): Promise<Array<ChannelID>> {
const rows = await fetchMany<{channel_id: ChannelID}>(HISTORICAL_DM_CHANNELS_CQL, {
user_id: userId,
});
return rows.map((row) => row.channel_id);
}
async listHistoricalDmChannelsPaginated(
userId: UserID,
options: ListHistoricalDmChannelOptions,
): Promise<Array<HistoricalDmChannelSummary>> {
if (options.beforeChannelId !== undefined && options.afterChannelId !== undefined) {
throw new Error('Cannot paginate with both beforeChannelId and afterChannelId');
}
let rows: Array<{channel_id: ChannelID}>;
if (options.afterChannelId !== undefined) {
const query = UserDmHistory.select({
columns: ['channel_id'],
where: [UserDmHistory.where.eq('user_id'), UserDmHistory.where.gt('channel_id', 'after_channel_id')],
orderBy: {col: 'channel_id', direction: 'ASC'},
limit: options.limit,
});
rows = await fetchMany<{channel_id: ChannelID}>(
query.bind({
user_id: userId,
after_channel_id: options.afterChannelId,
}),
);
rows.reverse();
} else if (options.beforeChannelId !== undefined) {
const query = UserDmHistory.select({
columns: ['channel_id'],
where: [UserDmHistory.where.eq('user_id'), UserDmHistory.where.lt('channel_id', 'before_channel_id')],
orderBy: {col: 'channel_id', direction: 'DESC'},
limit: options.limit,
});
rows = await fetchMany<{channel_id: ChannelID}>(
query.bind({
user_id: userId,
before_channel_id: options.beforeChannelId,
}),
);
} else {
const query = UserDmHistory.select({
columns: ['channel_id'],
where: UserDmHistory.where.eq('user_id'),
orderBy: {col: 'channel_id', direction: 'DESC'},
limit: options.limit,
});
rows = await fetchMany<{channel_id: ChannelID}>(
query.bind({
user_id: userId,
}),
);
}
const channelIds = rows.map((row) => row.channel_id);
if (channelIds.length === 0) {
return [];
}
const openChannelRows = await fetchManyInChunks<{channel_id: ChannelID}>(
FETCH_OPEN_PRIVATE_CHANNELS_BY_IDS_CQL,
channelIds,
(chunk) => ({
user_id: userId,
channel_ids: chunk,
}),
);
const openChannelIds = new Set(openChannelRows.map((row) => row.channel_id));
const fetchChannelDetails = async (
ids: Array<ChannelID>,
softDeleted: boolean,
): Promise<Array<ChannelDetailsRow>> => {
return fetchManyInChunks<ChannelDetailsRow>(FETCH_CHANNEL_DETAILS_CQL, ids, (chunk) => ({
channel_ids: chunk,
soft_deleted: softDeleted,
}));
};
const channelMap = new Map<ChannelID, ChannelDetailsRow>();
const nonDeletedChannels = await fetchChannelDetails(channelIds, false);
for (const channel of nonDeletedChannels) {
channelMap.set(channel.channel_id, channel);
}
const missingChannelIds = channelIds.filter((channelId) => !channelMap.has(channelId));
if (missingChannelIds.length > 0) {
const deletedChannels = await fetchChannelDetails(missingChannelIds, true);
for (const channel of deletedChannels) {
if (!channelMap.has(channel.channel_id)) {
channelMap.set(channel.channel_id, channel);
}
}
}
return channelIds.map((channelId) => {
const channel = channelMap.get(channelId);
return {
channelId,
channelType: channel?.type ?? null,
recipientIds: channel?.recipient_ids ? Array.from(channel.recipient_ids) : [],
lastMessageId: channel?.last_message_id ?? null,
open: openChannelIds.has(channelId),
};
});
}
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 this.recordHistoricalDmChannel(userId, channelId, resolvedIsGroupDm);
await upsertOne(
PrivateChannels.upsertAll({
user_id: userId,
channel_id: channelId,
is_gdm: resolvedIsGroupDm,
}),
);
}
async recordHistoricalDmChannel(userId: UserID, channelId: ChannelID, isGroupDm: boolean): Promise<void> {
if (isGroupDm) {
return;
}
await upsertOne(
UserDmHistory.upsertAll({
user_id: userId,
channel_id: channelId,
}),
);
}
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 '@fluxer/api/src/BrandedTypes';
import {fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {UserContactChangeLogRow} from '@fluxer/api/src/database/types/UserTypes';
import {UserContactChangeLogs} from '@fluxer/api/src/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,203 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import type {GiftCodeRow, PaymentBySubscriptionRow, PaymentRow} from '@fluxer/api/src/database/types/PaymentTypes';
import type {PushSubscriptionRow, RecentMentionRow} from '@fluxer/api/src/database/types/UserTypes';
import type {GiftCode} from '@fluxer/api/src/models/GiftCode';
import type {Payment} from '@fluxer/api/src/models/Payment';
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
import type {RecentMention} from '@fluxer/api/src/models/RecentMention';
import type {SavedMessage} from '@fluxer/api/src/models/SavedMessage';
import type {VisionarySlot} from '@fluxer/api/src/models/VisionarySlot';
import {GiftCodeRepository} from '@fluxer/api/src/user/repositories/GiftCodeRepository';
import type {IUserContentRepository} from '@fluxer/api/src/user/repositories/IUserContentRepository';
import {PaymentRepository} from '@fluxer/api/src/user/repositories/PaymentRepository';
import {PushSubscriptionRepository} from '@fluxer/api/src/user/repositories/PushSubscriptionRepository';
import {RecentMentionRepository} from '@fluxer/api/src/user/repositories/RecentMentionRepository';
import {SavedMessageRepository} from '@fluxer/api/src/user/repositories/SavedMessageRepository';
import {VisionarySlotRepository} from '@fluxer/api/src/user/repositories/VisionarySlotRepository';
export class UserContentRepository implements IUserContentRepository {
private giftCodeRepository: GiftCodeRepository;
private paymentRepository: PaymentRepository;
private pushSubscriptionRepository: PushSubscriptionRepository;
private recentMentionRepository: RecentMentionRepository;
private savedMessageRepository: SavedMessageRepository;
private visionarySlotRepository: VisionarySlotRepository;
constructor() {
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 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,304 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {
BatchBuilder,
Db,
deleteOneOrMany,
executeVersionedUpdate,
fetchMany,
fetchOne,
nextVersion,
} from '@fluxer/api/src/database/Cassandra';
import type {NoteRow, RelationshipRow} from '@fluxer/api/src/database/types/UserTypes';
import {Relationship} from '@fluxer/api/src/models/Relationship';
import {UserNote} from '@fluxer/api/src/models/UserNote';
import {Notes, Relationships, RelationshipsByTarget} from '@fluxer/api/src/Tables';
import type {IUserRelationshipRepository} from '@fluxer/api/src/user/repositories/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,
});
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> {
const FETCH_RELATIONSHIPS_BY_TARGET_CQL = RelationshipsByTarget.selectCql({
where: RelationshipsByTarget.where.eq('target_user_id'),
});
const relationshipsPointingToUser = await fetchMany<RelationshipRow>(FETCH_RELATIONSHIPS_BY_TARGET_CQL, {
target_user_id: userId,
});
if (relationshipsPointingToUser.length > 0) {
const batch = new BatchBuilder();
for (const rel of relationshipsPointingToUser) {
batch.addPrepared(
Relationships.deleteByPk({
source_user_id: rel.source_user_id,
target_user_id: rel.target_user_id,
type: rel.type,
}),
);
}
await batch.execute();
}
await deleteOneOrMany(
RelationshipsByTarget.deleteCql({
where: RelationshipsByTarget.where.eq('target_user_id', 'user_id'),
}),
{user_id: userId},
);
await deleteOneOrMany(
Relationships.deleteCql({
where: Relationships.where.eq('source_user_id', 'user_id'),
}),
{user_id: userId},
);
}
async deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void> {
await Promise.all([
deleteOneOrMany(
Relationships.deleteByPk({
source_user_id: sourceUserId,
target_user_id: targetUserId,
type,
}),
),
deleteOneOrMany(
RelationshipsByTarget.deleteByPk({
target_user_id: targetUserId,
source_user_id: sourceUserId,
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 hasReachedRelationshipLimit(sourceUserId: UserID, limit: number): Promise<boolean> {
const relationships = await fetchMany<RelationshipRow>(
Relationships.selectCql({
where: Relationships.where.eq('source_user_id'),
limit: limit + 1,
}),
{source_user_id: sourceUserId},
);
return relationships.length >= limit;
}
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(nextVersion(current?.version)),
},
}),
Relationships,
);
const finalRelationship: RelationshipRow = {
...relationship,
version: result.finalVersion ?? 1,
};
await executeVersionedUpdate<RelationshipRow, 'target_user_id' | 'source_user_id' | 'type'>(
() =>
fetchOne(
RelationshipsByTarget.selectCql({
where: [
RelationshipsByTarget.where.eq('target_user_id'),
RelationshipsByTarget.where.eq('source_user_id'),
RelationshipsByTarget.where.eq('type'),
],
limit: 1,
}),
{
target_user_id: relationship.target_user_id,
source_user_id: relationship.source_user_id,
type: relationship.type,
},
),
(current) => ({
pk: {
target_user_id: relationship.target_user_id,
source_user_id: relationship.source_user_id,
type: relationship.type,
},
patch: {
nickname: Db.set(relationship.nickname),
since: Db.set(relationship.since),
version: Db.set(nextVersion(current?.version)),
},
}),
RelationshipsByTarget,
);
return new Relationship(finalRelationship);
}
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(nextVersion(current?.version))},
}),
Notes,
);
return new UserNote({
source_user_id: sourceUserId,
target_user_id: targetUserId,
note,
version: result.finalVersion ?? 1,
});
}
async backfillRelationshipsIndex(_userId: UserID, relationships: Array<Relationship>): Promise<void> {
if (relationships.length === 0) {
return;
}
const batch = new BatchBuilder();
for (const rel of relationships) {
const row = rel.toRow();
batch.addPrepared(
RelationshipsByTarget.upsertAll({
target_user_id: row.target_user_id,
source_user_id: row.source_user_id,
type: row.type,
nickname: row.nickname,
since: row.since,
version: row.version,
}),
);
}
await batch.execute();
}
}

View File

@@ -0,0 +1,658 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import type {
AuthSessionRow,
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '@fluxer/api/src/database/types/AuthTypes';
import type {GiftCodeRow, PaymentBySubscriptionRow, PaymentRow} from '@fluxer/api/src/database/types/PaymentTypes';
import type {
PushSubscriptionRow,
RecentMentionRow,
RelationshipRow,
UserGuildSettingsRow,
UserRow,
UserSettingsRow,
} from '@fluxer/api/src/database/types/UserTypes';
import type {AuthSession} from '@fluxer/api/src/models/AuthSession';
import type {Channel} from '@fluxer/api/src/models/Channel';
import type {EmailRevertToken} from '@fluxer/api/src/models/EmailRevertToken';
import type {EmailVerificationToken} from '@fluxer/api/src/models/EmailVerificationToken';
import type {GiftCode} from '@fluxer/api/src/models/GiftCode';
import type {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
import type {PasswordResetToken} from '@fluxer/api/src/models/PasswordResetToken';
import type {Payment} from '@fluxer/api/src/models/Payment';
import type {PushSubscription} from '@fluxer/api/src/models/PushSubscription';
import type {ReadState} from '@fluxer/api/src/models/ReadState';
import type {RecentMention} from '@fluxer/api/src/models/RecentMention';
import type {Relationship} from '@fluxer/api/src/models/Relationship';
import type {SavedMessage} from '@fluxer/api/src/models/SavedMessage';
import type {User} from '@fluxer/api/src/models/User';
import type {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import type {UserNote} from '@fluxer/api/src/models/UserNote';
import type {UserSettings} from '@fluxer/api/src/models/UserSettings';
import type {VisionarySlot} from '@fluxer/api/src/models/VisionarySlot';
import type {WebAuthnCredential} from '@fluxer/api/src/models/WebAuthnCredential';
import {ReadStateRepository} from '@fluxer/api/src/read_state/ReadStateRepository';
import type {
HistoricalDmChannelSummary,
ListHistoricalDmChannelOptions,
PrivateChannelSummary,
} from '@fluxer/api/src/user/repositories/IUserChannelRepository';
import type {IUserRepositoryAggregate} from '@fluxer/api/src/user/repositories/IUserRepositoryAggregate';
import {UserAccountRepository} from '@fluxer/api/src/user/repositories/UserAccountRepository';
import {UserAuthRepository} from '@fluxer/api/src/user/repositories/UserAuthRepository';
import {UserChannelRepository} from '@fluxer/api/src/user/repositories/UserChannelRepository';
import {UserContentRepository} from '@fluxer/api/src/user/repositories/UserContentRepository';
import {UserRelationshipRepository} from '@fluxer/api/src/user/repositories/UserRelationshipRepository';
import {UserSettingsRepository} from '@fluxer/api/src/user/repositories/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> {
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 updateSubscriptionStatus(
userId: UserID,
updates: {premiumWillCancel: boolean; computedPremiumUntil: Date | null},
): Promise<{finalVersion: number | null}> {
return this.accountRepo.updateSubscriptionStatus(userId, updates);
}
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, email: string): Promise<void> {
return this.authRepo.createIpAuthorizationToken(userId, token, email);
}
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 listRelationships(sourceUserId: UserID): Promise<Array<Relationship>> {
return this.relationshipRepo.listRelationships(sourceUserId);
}
async hasReachedRelationshipLimit(sourceUserId: UserID, limit: number): Promise<boolean> {
return this.relationshipRepo.hasReachedRelationshipLimit(sourceUserId, limit);
}
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 backfillRelationshipsIndex(userId: UserID, relationships: Array<Relationship>): Promise<void> {
return this.relationshipRepo.backfillRelationshipsIndex(userId, relationships);
}
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 listHistoricalDmChannelIds(userId: UserID): Promise<Array<ChannelID>> {
return this.channelRepo.listHistoricalDmChannelIds(userId);
}
async listHistoricalDmChannelsPaginated(
userId: UserID,
options: ListHistoricalDmChannelOptions,
): Promise<Array<HistoricalDmChannelSummary>> {
return this.channelRepo.listHistoricalDmChannelsPaginated(userId, options);
}
async recordHistoricalDmChannel(userId: UserID, channelId: ChannelID, isGroupDm: boolean): Promise<void> {
return this.channelRepo.recordHistoricalDmChannel(userId, channelId, isGroupDm);
}
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 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,139 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {
buildPatchFromData,
deleteOneOrMany,
executeVersionedUpdate,
fetchMany,
fetchOne,
} from '@fluxer/api/src/database/Cassandra';
import type {ExactRow} from '@fluxer/api/src/database/types/DatabaseRowTypes';
import type {UserGuildSettingsRow, UserSettingsRow} from '@fluxer/api/src/database/types/UserTypes';
import {USER_GUILD_SETTINGS_COLUMNS, USER_SETTINGS_COLUMNS} from '@fluxer/api/src/database/types/UserTypes';
import {Logger} from '@fluxer/api/src/Logger';
import {UserGuildSettings} from '@fluxer/api/src/models/UserGuildSettings';
import {UserSettings} from '@fluxer/api/src/models/UserSettings';
import {UserGuildSettings as UserGuildSettingsTable, UserSettings as UserSettingsTable} from '@fluxer/api/src/Tables';
import type {IUserSettingsRepository} from '@fluxer/api/src/user/repositories/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: ExactRow<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)
.catch((error) => {
Logger.error(
{userId: userId.toString(), guildId: guildId.toString(), error},
'Failed to fetch guild settings',
);
throw error;
}),
(current) => ({
pk: {user_id: userId, guild_id: guildId},
patch: buildPatchFromData(settings, current, USER_GUILD_SETTINGS_COLUMNS, ['user_id', 'guild_id']),
}),
UserGuildSettingsTable,
);
return new UserGuildSettings({...settings, version: result.finalVersion ?? 1});
}
async upsertSettings(settings: ExactRow<UserSettingsRow>): Promise<UserSettings> {
const userId = settings.user_id;
const result = await executeVersionedUpdate<UserSettingsRow, 'user_id'>(
() =>
this.findSettings(userId)
.then((s) => s?.toRow() ?? null)
.catch((error) => {
Logger.error({userId: userId.toString(), error}, 'Failed to fetch settings');
throw error;
}),
(current) => ({
pk: {user_id: userId},
patch: buildPatchFromData(settings, current, USER_SETTINGS_COLUMNS, ['user_id']),
}),
UserSettingsTable,
);
return new UserSettings({...settings, version: result.finalVersion ?? 1});
}
}

View File

@@ -0,0 +1,156 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {VisionarySlotRow} from '@fluxer/api/src/database/types/PaymentTypes';
import {VisionarySlot} from '@fluxer/api/src/models/VisionarySlot';
import {VisionarySlots} from '@fluxer/api/src/Tables';
import {CannotShrinkReservedSlotsError} from '@fluxer/errors/src/domains/core/CannotShrinkReservedSlotsError';
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 getVisionarySlot(slotIndex: number): Promise<VisionarySlot | null> {
const slot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
slot_index: slotIndex,
});
return slot ? new VisionarySlot(slot) : null;
}
async expandVisionarySlots(byCount: number): Promise<void> {
const existingSlots = await this.listVisionarySlots();
const maxSlotIndex = existingSlots.length > 0 ? Math.max(...existingSlots.map((s) => s.slotIndex)) : 0;
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 swapVisionarySlotReservations(
slotIndexA: number,
slotIndexB: number,
): Promise<{userIdA: UserID | null; userIdB: UserID | null}> {
const [slotA, slotB] = await Promise.all([
fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {slot_index: slotIndexA}),
fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {slot_index: slotIndexB}),
]);
const userIdA = slotA?.user_id ?? null;
const userIdB = slotB?.user_id ?? null;
const batch = new BatchBuilder();
batch.addPrepared(
VisionarySlots.upsertAll({
slot_index: slotIndexA,
user_id: userIdB,
}),
);
batch.addPrepared(
VisionarySlots.upsertAll({
slot_index: slotIndexB,
user_id: userIdA,
}),
);
await batch.execute();
return {userIdA, userIdB};
}
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
const existingSlot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
slot_index: slotIndex,
});
if (!existingSlot) {
return;
}
const sentinelUserId = createUserID(BigInt(-1));
if (userId !== sentinelUserId && existingSlot.user_id !== userId) {
return;
}
await upsertOne(
VisionarySlots.upsertAll({
slot_index: slotIndex,
user_id: null,
}),
);
}
}

View File

@@ -0,0 +1,150 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {Db} from '@fluxer/api/src/database/Cassandra';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import {User} from '@fluxer/api/src/models/User';
import {UserDataRepository} from '@fluxer/api/src/user/repositories/account/crud/UserDataRepository';
import {UserIndexRepository} from '@fluxer/api/src/user/repositories/account/crud/UserIndexRepository';
import {UserSearchRepository} from '@fluxer/api/src/user/repositories/account/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 userId = data.user_id;
const result = await this.dataRepo.upsertUserRow(data, oldData);
if (result.finalVersion === null) {
throw new Error(`Failed to update user ${userId} after max retries due to concurrent updates`);
}
const updatedData = {...data, version: result.finalVersion};
const updatedUser = new User(updatedData);
await this.indexRepo.syncIndices(updatedData, oldData);
await this.searchRepo.indexUser(updatedUser);
return updatedUser;
}
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User> {
if (!oldData) {
const existingUser = await this.findUniqueAssert(userId);
oldData = existingUser.toRow();
}
const definedPatchData = Object.fromEntries(Object.entries(patchData).filter(([, v]) => v !== undefined));
const userPatch: Record<string, ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>> = {};
for (const [key, value] of Object.entries(definedPatchData)) {
if (key === 'user_id') 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, oldData);
if (result.finalVersion === null) {
throw new Error(`Failed to update user ${userId} due to concurrent modification`);
}
const updatedData: UserRow = {
...oldData,
...definedPatchData,
user_id: userId,
version: result.finalVersion,
};
const updatedUser = new User(updatedData);
await this.indexRepo.syncIndices(updatedData, oldData);
await this.searchRepo.updateUser(updatedUser);
return updatedUser;
}
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);
}
async getActivityTracking(
userId: UserID,
): Promise<{last_active_at: Date | null; last_active_ip: string | null} | null> {
return this.dataRepo.getActivityTracking(userId);
}
async updateSubscriptionStatus(
userId: UserID,
updates: {premiumWillCancel: boolean; computedPremiumUntil: Date | null},
): Promise<{finalVersion: number | null}> {
return this.dataRepo.updateSubscriptionStatus(userId, updates);
}
}

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 '@fluxer/api/src/BrandedTypes';
import {deleteOneOrMany, fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {User} from '@fluxer/api/src/models/User';
import {UsersPendingDeletion} from '@fluxer/api/src/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 '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, fetchMany} from '@fluxer/api/src/database/Cassandra';
import type {GuildMemberByUserIdRow} from '@fluxer/api/src/database/types/GuildTypes';
import {GuildMembers, GuildMembersByUserId} from '@fluxer/api/src/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,123 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {fetchMany, fetchOne} from '@fluxer/api/src/database/Cassandra';
import type {
UserByEmailRow,
UserByPhoneRow,
UserByStripeCustomerIdRow,
UserByStripeSubscriptionIdRow,
UserByUsernameRow,
} from '@fluxer/api/src/database/types/UserTypes';
import type {User} from '@fluxer/api/src/models/User';
import {
UserByEmail,
UserByPhone,
UserByStripeCustomerId,
UserByStripeSubscriptionId,
UserByUsername,
} from '@fluxer/api/src/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,227 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {
buildPatchFromData,
Db,
type DbOp,
executeVersionedUpdate,
fetchMany,
fetchOne,
upsertOne,
} from '@fluxer/api/src/database/Cassandra';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import {EMPTY_USER_ROW, USER_COLUMNS} from '@fluxer/api/src/database/types/UserTypes';
import {User} from '@fluxer/api/src/models/User';
import {Users} from '@fluxer/api/src/Tables';
const FLUXER_BOT_USER_ID = 0n;
const DELETED_USER_ID = 1n;
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 UPDATE_LAST_ACTIVE_CQL = `UPDATE users SET last_active_at = :last_active_at, last_active_ip = :last_active_ip WHERE user_id = :user_id`;
const FETCH_ACTIVITY_TRACKING_CQL = Users.selectCql({
columns: ['last_active_at', 'last_active_ip'],
where: Users.where.eq('user_id'),
limit: 1,
});
function createFetchAllUsersFirstPageCql(limit: number) {
return 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]: DbOp<UserRow[K]>;
}>;
export class UserDataRepository {
async findUnique(userId: UserID): Promise<User | null> {
if (userId === FLUXER_BOT_USER_ID) {
return new User({
...EMPTY_USER_ROW,
user_id: createUserID(FLUXER_BOT_USER_ID),
username: 'Fluxer',
discriminator: 0,
bot: true,
system: true,
});
}
if (userId === DELETED_USER_ID) {
return new User({
...EMPTY_USER_ROW,
user_id: createUserID(DELETED_USER_ID),
username: 'DeletedUser',
discriminator: 0,
bot: false,
system: true,
});
}
const userRow = await fetchOne<UserRow>(FETCH_USER_BY_ID_CQL, {user_id: userId});
if (!userRow) {
return null;
}
return new User(userRow);
}
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;
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
async () => {
if (oldData !== undefined) {
return oldData;
}
const user = await this.findUnique(userId);
return user?.toRow() ?? null;
},
(current) => ({
pk: {user_id: userId},
patch: buildPatchFromData(data, current, USER_COLUMNS, ['user_id']),
}),
Users,
);
return {finalVersion: result.finalVersion};
}
async patchUser(userId: UserID, patch: UserPatch, oldData?: UserRow | null): Promise<{finalVersion: number | null}> {
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
async () => {
if (oldData !== undefined) {
return oldData;
}
const user = await this.findUnique(userId);
return user?.toRow() ?? null;
},
(_current) => ({
pk: {user_id: userId},
patch,
}),
Users,
);
return {finalVersion: result.finalVersion};
}
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
const {userId, lastActiveAt, lastActiveIp} = params;
const updateParams: {user_id: UserID; last_active_at: Date; last_active_ip?: string} = {
user_id: userId,
last_active_at: lastActiveAt,
};
if (lastActiveIp !== undefined) {
updateParams.last_active_ip = lastActiveIp;
}
await upsertOne(UPDATE_LAST_ACTIVE_CQL, updateParams);
}
async getActivityTracking(
userId: UserID,
): Promise<{last_active_at: Date | null; last_active_ip: string | null} | null> {
const result = await fetchOne<{last_active_at: Date | null; last_active_ip: string | null}>(
FETCH_ACTIVITY_TRACKING_CQL,
{user_id: userId},
);
return result;
}
async updateSubscriptionStatus(
userId: UserID,
updates: {
premiumWillCancel: boolean;
computedPremiumUntil: Date | null;
},
): Promise<{finalVersion: number | null}> {
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
async () => {
const user = await this.findUnique(userId);
return user?.toRow() ?? null;
},
(current) => {
const currentPremiumUntil = current?.premium_until ?? null;
const computedPremiumUntil = updates.computedPremiumUntil;
let nextPremiumUntil: Date | null = currentPremiumUntil;
if (computedPremiumUntil) {
if (!nextPremiumUntil || computedPremiumUntil > nextPremiumUntil) {
nextPremiumUntil = computedPremiumUntil;
}
}
const patch: UserPatch = {
premium_will_cancel: Db.set(updates.premiumWillCancel),
premium_until: nextPremiumUntil ? Db.set(nextPremiumUntil) : Db.clear(),
};
return {
pk: {user_id: userId},
patch,
};
},
Users,
);
return {finalVersion: result.finalVersion};
}
}

View File

@@ -0,0 +1,177 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {BatchBuilder} from '@fluxer/api/src/database/Cassandra';
import type {UserRow} from '@fluxer/api/src/database/types/UserTypes';
import {
UserByEmail,
UserByPhone,
UserByStripeCustomerId,
UserByStripeSubscriptionId,
UserByUsername,
} from '@fluxer/api/src/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,42 @@
/*
* 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 '@fluxer/api/src/Logger';
import type {User} from '@fluxer/api/src/models/User';
import {getUserSearchService} from '@fluxer/api/src/SearchFactory';
export class UserSearchRepository {
async indexUser(user: User): Promise<void> {
const userSearchService = getUserSearchService();
if (userSearchService && 'indexUser' in userSearchService) {
await userSearchService.indexUser(user).catch((error) => {
Logger.error({userId: user.id, error}, 'Failed to index user in search');
});
}
}
async updateUser(user: User): Promise<void> {
const userSearchService = getUserSearchService();
if (userSearchService && 'updateUser' in userSearchService) {
await userSearchService.updateUser(user).catch((error) => {
Logger.error({userId: user.id, error}, 'Failed to update user in search');
});
}
}
}

View File

@@ -0,0 +1,115 @@
/*
* 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 '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, Db, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {AuthSessionRow} from '@fluxer/api/src/database/types/AuthTypes';
import {AuthSession} from '@fluxer/api/src/models/AuthSession';
import {AuthSessions, AuthSessionsByUserId} from '@fluxer/api/src/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 batch = new BatchBuilder();
batch.addPrepared(AuthSessions.insert(sessionData));
batch.addPrepared(
AuthSessionsByUserId.insert({
user_id: sessionData.user_id,
session_id_hash: sessionData.session_id_hash,
}),
);
await batch.execute();
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 '@fluxer/api/src/database/Cassandra';
import type {EmailChangeTicketRow, EmailChangeTokenRow} from '@fluxer/api/src/database/types/AuthTypes';
import {EmailChangeTickets, EmailChangeTokens} from '@fluxer/api/src/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,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 {createIpAuthorizationToken, type UserID} from '@fluxer/api/src/BrandedTypes';
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {AuthorizedIpRow, IpAuthorizationTokenRow} from '@fluxer/api/src/database/types/AuthTypes';
import {AuthorizedIps, IpAuthorizationTokens, Users} from '@fluxer/api/src/Tables';
import type {IUserAccountRepository} from '@fluxer/api/src/user/repositories/IUserAccountRepository';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
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, email: string): Promise<void> {
await upsertOne(
IpAuthorizationTokens.insert({
token_: createIpAuthorizationToken(token),
user_id: userId,
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 '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {MfaBackupCodeRow} from '@fluxer/api/src/database/types/AuthTypes';
import {MfaBackupCode} from '@fluxer/api/src/models/MfaBackupCode';
import {MfaBackupCodes} from '@fluxer/api/src/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,45 @@
/*
* 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 '@fluxer/api/src/database/Cassandra';
import type {PasswordChangeTicketRow} from '@fluxer/api/src/database/types/AuthTypes';
import {PasswordChangeTickets} from '@fluxer/api/src/Tables';
const FETCH_TICKET_CQL = PasswordChangeTickets.selectCql({
where: PasswordChangeTickets.where.eq('ticket'),
limit: 1,
});
export class PasswordChangeRepository {
async createTicket(row: PasswordChangeTicketRow): Promise<void> {
await upsertOne(PasswordChangeTickets.insert(row));
}
async updateTicket(row: PasswordChangeTicketRow): Promise<void> {
await upsertOne(PasswordChangeTickets.upsertAll(row));
}
async findTicket(ticket: string): Promise<PasswordChangeTicketRow | null> {
return await fetchOne<PasswordChangeTicketRow>(FETCH_TICKET_CQL, {ticket});
}
async deleteTicket(ticket: string): Promise<void> {
await deleteOneOrMany(PasswordChangeTickets.deleteByPk({ticket}));
}
}

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 {PhoneVerificationToken, UserID} from '@fluxer/api/src/BrandedTypes';
import {
createEmailRevertToken,
createEmailVerificationToken,
createPasswordResetToken,
} from '@fluxer/api/src/BrandedTypes';
import {deleteOneOrMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {
EmailRevertTokenRow,
EmailVerificationTokenRow,
PasswordResetTokenRow,
PhoneTokenRow,
} from '@fluxer/api/src/database/types/AuthTypes';
import {EmailRevertToken} from '@fluxer/api/src/models/EmailRevertToken';
import {EmailVerificationToken} from '@fluxer/api/src/models/EmailVerificationToken';
import {PasswordResetToken} from '@fluxer/api/src/models/PasswordResetToken';
import {EmailRevertTokens, EmailVerificationTokens, PasswordResetTokens, PhoneTokens} from '@fluxer/api/src/Tables';
import {seconds} from 'itty-time';
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 = seconds('15 minutes');
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 '@fluxer/api/src/BrandedTypes';
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {WebAuthnCredentialRow} from '@fluxer/api/src/database/types/AuthTypes';
import {WebAuthnCredential} from '@fluxer/api/src/models/WebAuthnCredential';
import {WebAuthnCredentialLookup, WebAuthnCredentials} from '@fluxer/api/src/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();
}
}