refactor progress
This commit is contained in:
169
packages/api/src/user/repositories/GiftCodeRepository.tsx
Normal file
169
packages/api/src/user/repositories/GiftCodeRepository.tsx
Normal 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),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}>;
|
||||
}
|
||||
88
packages/api/src/user/repositories/IUserAuthRepository.tsx
Normal file
88
packages/api/src/user/repositories/IUserAuthRepository.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>;
|
||||
}
|
||||
204
packages/api/src/user/repositories/PaymentRepository.tsx
Normal file
204
packages/api/src/user/repositories/PaymentRepository.tsx
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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}),
|
||||
);
|
||||
}
|
||||
}
|
||||
151
packages/api/src/user/repositories/RecentMentionRepository.tsx
Normal file
151
packages/api/src/user/repositories/RecentMentionRepository.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
147
packages/api/src/user/repositories/UserAccountRepository.tsx
Normal file
147
packages/api/src/user/repositories/UserAccountRepository.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
227
packages/api/src/user/repositories/UserAuthRepository.tsx
Normal file
227
packages/api/src/user/repositories/UserAuthRepository.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
536
packages/api/src/user/repositories/UserChannelRepository.tsx
Normal file
536
packages/api/src/user/repositories/UserChannelRepository.tsx
Normal 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},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
203
packages/api/src/user/repositories/UserContentRepository.tsx
Normal file
203
packages/api/src/user/repositories/UserContentRepository.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
658
packages/api/src/user/repositories/UserRepository.tsx
Normal file
658
packages/api/src/user/repositories/UserRepository.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
139
packages/api/src/user/repositories/UserSettingsRepository.tsx
Normal file
139
packages/api/src/user/repositories/UserSettingsRepository.tsx
Normal 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});
|
||||
}
|
||||
}
|
||||
156
packages/api/src/user/repositories/VisionarySlotRepository.tsx
Normal file
156
packages/api/src/user/repositories/VisionarySlotRepository.tsx
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
@@ -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}));
|
||||
}
|
||||
}
|
||||
143
packages/api/src/user/repositories/auth/TokenRepository.tsx
Normal file
143
packages/api/src/user/repositories/auth/TokenRepository.tsx
Normal 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},
|
||||
);
|
||||
}
|
||||
}
|
||||
171
packages/api/src/user/repositories/auth/WebAuthnRepository.tsx
Normal file
171
packages/api/src/user/repositories/auth/WebAuthnRepository.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user