initial commit
This commit is contained in:
121
fluxer_api/src/user/repositories/BetaCodeRepository.ts
Normal file
121
fluxer_api/src/user/repositories/BetaCodeRepository.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createBetaCode, type UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {BetaCodeByCodeRow, BetaCodeRow} from '~/database/CassandraTypes';
|
||||
import {BetaCode} from '~/Models';
|
||||
import {BetaCodes, BetaCodesByCode} from '~/Tables';
|
||||
|
||||
const FETCH_BETA_CODES_BY_CREATOR_QUERY = BetaCodes.selectCql({
|
||||
where: BetaCodes.where.eq('creator_id'),
|
||||
});
|
||||
|
||||
const FETCH_BETA_CODE_BY_CREATOR_AND_CODE_QUERY = BetaCodes.selectCql({
|
||||
where: [BetaCodes.where.eq('creator_id'), BetaCodes.where.eq('code')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_BETA_CODE_BY_CODE_QUERY = BetaCodesByCode.selectCql({
|
||||
where: BetaCodesByCode.where.eq('code'),
|
||||
});
|
||||
|
||||
const FETCH_BETA_CODES_BY_CREATOR_FOR_DELETE_QUERY = BetaCodes.selectCql({
|
||||
columns: ['code'],
|
||||
where: BetaCodes.where.eq('creator_id', 'user_id'),
|
||||
});
|
||||
|
||||
export class BetaCodeRepository {
|
||||
async listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>> {
|
||||
const betaCodes = await fetchMany<BetaCodeRow>(FETCH_BETA_CODES_BY_CREATOR_QUERY, {
|
||||
creator_id: creatorId,
|
||||
});
|
||||
return betaCodes.map((betaCode) => new BetaCode(betaCode));
|
||||
}
|
||||
|
||||
async getBetaCode(code: string): Promise<BetaCode | null> {
|
||||
const betaCodeByCode = await fetchOne<BetaCodeByCodeRow>(FETCH_BETA_CODE_BY_CODE_QUERY, {code});
|
||||
if (!betaCodeByCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const betaCode = await fetchOne<BetaCodeRow>(FETCH_BETA_CODE_BY_CREATOR_AND_CODE_QUERY, {
|
||||
creator_id: betaCodeByCode.creator_id,
|
||||
code: betaCodeByCode.code,
|
||||
});
|
||||
|
||||
return betaCode ? new BetaCode(betaCode) : null;
|
||||
}
|
||||
|
||||
async upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(BetaCodes.upsertAll(betaCode));
|
||||
batch.addPrepared(
|
||||
BetaCodesByCode.upsertAll({
|
||||
code: betaCode.code,
|
||||
creator_id: betaCode.creator_id,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
return new BetaCode(betaCode);
|
||||
}
|
||||
|
||||
async updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void> {
|
||||
const betaCodeByCode = await fetchOne<BetaCodeByCodeRow>(FETCH_BETA_CODE_BY_CODE_QUERY, {code});
|
||||
if (!betaCodeByCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(
|
||||
BetaCodes.patchByPk(
|
||||
{
|
||||
creator_id: betaCodeByCode.creator_id,
|
||||
code: betaCodeByCode.code,
|
||||
},
|
||||
{
|
||||
redeemer_id: Db.set(redeemerId),
|
||||
redeemed_at: Db.set(redeemedAt),
|
||||
},
|
||||
),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteBetaCode(code: string, creatorId: UserID): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(BetaCodes.deleteByPk({creator_id: creatorId, code: createBetaCode(code)}));
|
||||
batch.addPrepared(BetaCodesByCode.deleteByPk({code: createBetaCode(code), creator_id: creatorId}));
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteAllBetaCodes(userId: UserID): Promise<void> {
|
||||
const codes = await fetchMany<{code: string}>(FETCH_BETA_CODES_BY_CREATOR_FOR_DELETE_QUERY, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const betaCode of codes) {
|
||||
batch.addPrepared(BetaCodes.deleteByPk({creator_id: userId, code: createBetaCode(betaCode.code)}));
|
||||
batch.addPrepared(BetaCodesByCode.deleteByPk({code: createBetaCode(betaCode.code), creator_id: userId}));
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
160
fluxer_api/src/user/repositories/GiftCodeRepository.ts
Normal file
160
fluxer_api/src/user/repositories/GiftCodeRepository.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, executeConditional, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {GiftCodeRow} from '~/database/CassandraTypes';
|
||||
import {GiftCode} from '~/Models';
|
||||
import {GiftCodes, GiftCodesByCreator, GiftCodesByPaymentIntent, GiftCodesByRedeemer} from '~/Tables';
|
||||
|
||||
const FETCH_GIFT_CODES_BY_CREATOR_QUERY = GiftCodesByCreator.selectCql({
|
||||
columns: ['code'],
|
||||
where: GiftCodesByCreator.where.eq('created_by_user_id'),
|
||||
});
|
||||
|
||||
const FETCH_GIFT_CODE_BY_PAYMENT_INTENT_QUERY = GiftCodesByPaymentIntent.selectCql({
|
||||
columns: ['code'],
|
||||
where: GiftCodesByPaymentIntent.where.eq('stripe_payment_intent_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_GIFT_CODE_QUERY = GiftCodes.selectCql({
|
||||
where: GiftCodes.where.eq('code'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class GiftCodeRepository {
|
||||
async createGiftCode(data: GiftCodeRow): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(GiftCodes.upsertAll(data));
|
||||
batch.addPrepared(
|
||||
GiftCodesByCreator.upsertAll({
|
||||
created_by_user_id: data.created_by_user_id,
|
||||
code: data.code,
|
||||
}),
|
||||
);
|
||||
|
||||
if (data.stripe_payment_intent_id) {
|
||||
batch.addPrepared(
|
||||
GiftCodesByPaymentIntent.upsertAll({
|
||||
stripe_payment_intent_id: data.stripe_payment_intent_id,
|
||||
code: data.code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async findGiftCode(code: string): Promise<GiftCode | null> {
|
||||
const row = await fetchOne<GiftCodeRow>(FETCH_GIFT_CODE_QUERY, {code});
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new GiftCode(row);
|
||||
}
|
||||
|
||||
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
|
||||
const row = await fetchOne<{code: string}>(FETCH_GIFT_CODE_BY_PAYMENT_INTENT_QUERY, {
|
||||
stripe_payment_intent_id: paymentIntentId,
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.findGiftCode(row.code);
|
||||
}
|
||||
|
||||
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
|
||||
const rows = await fetchMany<{code: string}>(FETCH_GIFT_CODES_BY_CREATOR_QUERY, {
|
||||
created_by_user_id: userId,
|
||||
});
|
||||
|
||||
const giftCodes = await Promise.all(rows.map((row) => this.findGiftCode(row.code)));
|
||||
|
||||
return giftCodes.filter((gc) => gc !== null) as Array<GiftCode>;
|
||||
}
|
||||
|
||||
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
|
||||
const redeemedAt = new Date();
|
||||
|
||||
const q = GiftCodes.patchByPkIf(
|
||||
{code},
|
||||
{
|
||||
redeemed_by_user_id: Db.set(userId),
|
||||
redeemed_at: Db.set(redeemedAt),
|
||||
},
|
||||
{col: 'redeemed_by_user_id', expectedParam: 'expected_redeemer', expectedValue: null},
|
||||
);
|
||||
|
||||
const result = await executeConditional(q);
|
||||
|
||||
if (result.applied) {
|
||||
await upsertOne(
|
||||
GiftCodesByRedeemer.upsertAll({
|
||||
redeemed_by_user_id: userId,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
const patch: Record<string, ReturnType<typeof Db.set>> = {};
|
||||
if (data.redeemed_at !== undefined) {
|
||||
patch.redeemed_at = Db.set(data.redeemed_at);
|
||||
}
|
||||
if (data.redeemed_by_user_id !== undefined) {
|
||||
patch.redeemed_by_user_id = Db.set(data.redeemed_by_user_id);
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
batch.addPrepared(GiftCodes.patchByPk({code}, patch));
|
||||
}
|
||||
|
||||
if (data.redeemed_by_user_id) {
|
||||
batch.addPrepared(
|
||||
GiftCodesByRedeemer.upsertAll({
|
||||
redeemed_by_user_id: data.redeemed_by_user_id,
|
||||
code,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
|
||||
await upsertOne(
|
||||
GiftCodes.patchByPk(
|
||||
{code},
|
||||
{
|
||||
checkout_session_id: Db.set(checkoutSessionId),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
52
fluxer_api/src/user/repositories/IUserAccountRepository.ts
Normal file
52
fluxer_api/src/user/repositories/IUserAccountRepository.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '~/BrandedTypes';
|
||||
import type {UserRow} from '~/database/CassandraTypes';
|
||||
import type {User} from '~/Models';
|
||||
|
||||
export interface IUserAccountRepository {
|
||||
create(data: UserRow): Promise<User>;
|
||||
upsert(data: UserRow, oldData?: UserRow | null): Promise<User>;
|
||||
patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User | null>;
|
||||
findUnique(userId: UserID): Promise<User | null>;
|
||||
findUniqueAssert(userId: UserID): Promise<User>;
|
||||
findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null>;
|
||||
findDiscriminatorsByUsername(username: string): Promise<Set<number>>;
|
||||
findByEmail(email: string): Promise<User | null>;
|
||||
findByPhone(phone: string): Promise<User | null>;
|
||||
findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null>;
|
||||
findByStripeCustomerId(stripeCustomerId: string): Promise<User | null>;
|
||||
listUsers(userIds: Array<UserID>): Promise<Array<User>>;
|
||||
listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>>;
|
||||
|
||||
getUserGuildIds(userId: UserID): Promise<Array<GuildID>>;
|
||||
|
||||
addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void>;
|
||||
removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void>;
|
||||
findUsersPendingDeletion(now: Date): Promise<Array<User>>;
|
||||
findUsersPendingDeletionByDate(deletionDate: string): Promise<Array<{user_id: bigint; deletion_reason_code: number}>>;
|
||||
isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean>;
|
||||
scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void>;
|
||||
|
||||
deleteUserSecondaryIndices(userId: UserID): Promise<void>;
|
||||
removeFromAllGuilds(userId: UserID): Promise<void>;
|
||||
|
||||
updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void>;
|
||||
}
|
||||
93
fluxer_api/src/user/repositories/IUserAuthRepository.ts
Normal file
93
fluxer_api/src/user/repositories/IUserAuthRepository.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {PhoneVerificationToken, UserID} from '~/BrandedTypes';
|
||||
import type {
|
||||
AuthSessionRow,
|
||||
EmailRevertTokenRow,
|
||||
EmailVerificationTokenRow,
|
||||
PasswordResetTokenRow,
|
||||
PhoneTokenRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import type {
|
||||
AuthSession,
|
||||
EmailRevertToken,
|
||||
EmailVerificationToken,
|
||||
MfaBackupCode,
|
||||
PasswordResetToken,
|
||||
WebAuthnCredential,
|
||||
} from '~/Models';
|
||||
|
||||
export interface IUserAuthRepository {
|
||||
listAuthSessions(userId: UserID): Promise<Array<AuthSession>>;
|
||||
getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null>;
|
||||
createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession>;
|
||||
updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void>;
|
||||
deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void>;
|
||||
revokeAuthSession(sessionIdHash: Buffer): Promise<void>;
|
||||
deleteAllAuthSessions(userId: UserID): Promise<void>;
|
||||
|
||||
listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>>;
|
||||
createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>>;
|
||||
clearMfaBackupCodes(userId: UserID): Promise<void>;
|
||||
consumeMfaBackupCode(userId: UserID, code: string): Promise<void>;
|
||||
deleteAllMfaBackupCodes(userId: UserID): Promise<void>;
|
||||
|
||||
getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null>;
|
||||
createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken>;
|
||||
deleteEmailVerificationToken(token: string): Promise<void>;
|
||||
|
||||
getPasswordResetToken(token: string): Promise<PasswordResetToken | null>;
|
||||
createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken>;
|
||||
deletePasswordResetToken(token: string): Promise<void>;
|
||||
|
||||
getEmailRevertToken(token: string): Promise<EmailRevertToken | null>;
|
||||
createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken>;
|
||||
deleteEmailRevertToken(token: string): Promise<void>;
|
||||
|
||||
createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void>;
|
||||
getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null>;
|
||||
deletePhoneToken(token: PhoneVerificationToken): Promise<void>;
|
||||
updateUserActivity(userId: UserID, clientIp: string): Promise<void>;
|
||||
checkIpAuthorized(userId: UserID, ip: string): Promise<boolean>;
|
||||
createAuthorizedIp(userId: UserID, ip: string): Promise<void>;
|
||||
createIpAuthorizationToken(userId: UserID, token: string): Promise<void>;
|
||||
authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null>;
|
||||
deleteAllAuthorizedIps(userId: UserID): Promise<void>;
|
||||
|
||||
listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>>;
|
||||
getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null>;
|
||||
createWebAuthnCredential(
|
||||
userId: UserID,
|
||||
credentialId: string,
|
||||
publicKey: Buffer,
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
): Promise<void>;
|
||||
updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void>;
|
||||
updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void>;
|
||||
updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void>;
|
||||
deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void>;
|
||||
getUserIdByCredentialId(credentialId: string): Promise<UserID | null>;
|
||||
deleteAllWebAuthnCredentials(userId: UserID): Promise<void>;
|
||||
|
||||
createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void>;
|
||||
deletePendingVerification(userId: UserID): Promise<void>;
|
||||
}
|
||||
49
fluxer_api/src/user/repositories/IUserChannelRepository.ts
Normal file
49
fluxer_api/src/user/repositories/IUserChannelRepository.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {Channel} from '~/Models';
|
||||
|
||||
export interface PrivateChannelSummary {
|
||||
channelId: ChannelID;
|
||||
isGroupDm: boolean;
|
||||
channelType: number | null;
|
||||
lastMessageId: MessageID | null;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
export interface IUserChannelRepository {
|
||||
listPrivateChannels(userId: UserID): Promise<Array<Channel>>;
|
||||
deleteAllPrivateChannels(userId: UserID): Promise<void>;
|
||||
listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>>;
|
||||
|
||||
findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null>;
|
||||
createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel>;
|
||||
isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean>;
|
||||
openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void>;
|
||||
closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void>;
|
||||
|
||||
getPinnedDms(userId: UserID): Promise<Array<ChannelID>>;
|
||||
getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>>;
|
||||
addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>>;
|
||||
removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>>;
|
||||
deletePinnedDmsByUserId(userId: UserID): Promise<void>;
|
||||
|
||||
deleteAllReadStates(userId: UserID): Promise<void>;
|
||||
}
|
||||
91
fluxer_api/src/user/repositories/IUserContentRepository.ts
Normal file
91
fluxer_api/src/user/repositories/IUserContentRepository.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {
|
||||
BetaCodeRow,
|
||||
GiftCodeRow,
|
||||
PaymentBySubscriptionRow,
|
||||
PaymentRow,
|
||||
PushSubscriptionRow,
|
||||
RecentMentionRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import type {BetaCode, GiftCode, Payment, PushSubscription, RecentMention, SavedMessage, VisionarySlot} from '~/Models';
|
||||
|
||||
export interface IUserContentRepository {
|
||||
getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null>;
|
||||
listRecentMentions(
|
||||
userId: UserID,
|
||||
includeEveryone: boolean,
|
||||
includeRole: boolean,
|
||||
includeGuilds: boolean,
|
||||
limit: number,
|
||||
before?: MessageID,
|
||||
): Promise<Array<RecentMention>>;
|
||||
createRecentMention(mention: RecentMentionRow): Promise<RecentMention>;
|
||||
createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void>;
|
||||
deleteRecentMention(mention: RecentMention): Promise<void>;
|
||||
deleteAllRecentMentions(userId: UserID): Promise<void>;
|
||||
|
||||
listSavedMessages(userId: UserID, limit?: number, before?: MessageID): Promise<Array<SavedMessage>>;
|
||||
createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage>;
|
||||
deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void>;
|
||||
deleteAllSavedMessages(userId: UserID): Promise<void>;
|
||||
|
||||
listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>>;
|
||||
getBetaCode(code: string): Promise<BetaCode | null>;
|
||||
upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode>;
|
||||
updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void>;
|
||||
deleteBetaCode(code: string, creatorId: UserID): Promise<void>;
|
||||
deleteAllBetaCodes(userId: UserID): Promise<void>;
|
||||
|
||||
createGiftCode(data: GiftCodeRow): Promise<void>;
|
||||
findGiftCode(code: string): Promise<GiftCode | null>;
|
||||
findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null>;
|
||||
findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>>;
|
||||
redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}>;
|
||||
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
|
||||
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
|
||||
|
||||
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
|
||||
createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription>;
|
||||
deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void>;
|
||||
getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>>;
|
||||
deleteAllPushSubscriptions(userId: UserID): Promise<void>;
|
||||
|
||||
createPayment(data: {
|
||||
checkout_session_id: string;
|
||||
user_id: UserID;
|
||||
price_id: string;
|
||||
product_type: string;
|
||||
status: string;
|
||||
is_gift: boolean;
|
||||
created_at: Date;
|
||||
}): Promise<void>;
|
||||
updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}>;
|
||||
getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null>;
|
||||
getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null>;
|
||||
getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null>;
|
||||
|
||||
listVisionarySlots(): Promise<Array<VisionarySlot>>;
|
||||
expandVisionarySlots(byCount: number): Promise<void>;
|
||||
shrinkVisionarySlots(toCount: number): Promise<void>;
|
||||
reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void>;
|
||||
unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import type {RelationshipRow} from '~/database/CassandraTypes';
|
||||
import type {Relationship, UserNote} from '~/Models';
|
||||
|
||||
export interface IUserRelationshipRepository {
|
||||
listRelationships(sourceUserId: UserID): Promise<Array<Relationship>>;
|
||||
getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null>;
|
||||
upsertRelationship(relationship: RelationshipRow): Promise<Relationship>;
|
||||
deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void>;
|
||||
deleteAllRelationships(userId: UserID): Promise<void>;
|
||||
|
||||
getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null>;
|
||||
getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>>;
|
||||
upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote>;
|
||||
clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void>;
|
||||
deleteAllNotes(userId: UserID): Promise<void>;
|
||||
}
|
||||
33
fluxer_api/src/user/repositories/IUserRepositoryAggregate.ts
Normal file
33
fluxer_api/src/user/repositories/IUserRepositoryAggregate.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IUserAccountRepository} from './IUserAccountRepository';
|
||||
import type {IUserAuthRepository} from './IUserAuthRepository';
|
||||
import type {IUserChannelRepository} from './IUserChannelRepository';
|
||||
import type {IUserContentRepository} from './IUserContentRepository';
|
||||
import type {IUserRelationshipRepository} from './IUserRelationshipRepository';
|
||||
import type {IUserSettingsRepository} from './IUserSettingsRepository';
|
||||
|
||||
export interface IUserRepositoryAggregate
|
||||
extends IUserAccountRepository,
|
||||
IUserAuthRepository,
|
||||
IUserSettingsRepository,
|
||||
IUserRelationshipRepository,
|
||||
IUserChannelRepository,
|
||||
IUserContentRepository {}
|
||||
34
fluxer_api/src/user/repositories/IUserSettingsRepository.ts
Normal file
34
fluxer_api/src/user/repositories/IUserSettingsRepository.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '~/BrandedTypes';
|
||||
import type {UserGuildSettingsRow, UserSettingsRow} from '~/database/CassandraTypes';
|
||||
import type {UserGuildSettings, UserSettings} from '~/Models';
|
||||
|
||||
export interface IUserSettingsRepository {
|
||||
findSettings(userId: UserID): Promise<UserSettings | null>;
|
||||
upsertSettings(settings: UserSettingsRow): Promise<UserSettings>;
|
||||
deleteUserSettings(userId: UserID): Promise<void>;
|
||||
|
||||
findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null>;
|
||||
findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>>;
|
||||
upsertGuildSettings(settings: UserGuildSettingsRow): Promise<UserGuildSettings>;
|
||||
deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void>;
|
||||
deleteAllUserGuildSettings(userId: UserID): Promise<void>;
|
||||
}
|
||||
205
fluxer_api/src/user/repositories/PaymentRepository.ts
Normal file
205
fluxer_api/src/user/repositories/PaymentRepository.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {PaymentBySubscriptionRow, PaymentRow} from '~/database/CassandraTypes';
|
||||
import {Payment} from '~/Models';
|
||||
import {Payments, PaymentsByPaymentIntent, PaymentsBySubscription, PaymentsByUser} from '~/Tables';
|
||||
|
||||
const FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY = Payments.selectCql({
|
||||
where: Payments.where.eq('checkout_session_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_PAYMENT_BY_PAYMENT_INTENT_QUERY = PaymentsByPaymentIntent.selectCql({
|
||||
columns: ['checkout_session_id'],
|
||||
where: PaymentsByPaymentIntent.where.eq('payment_intent_id'),
|
||||
});
|
||||
|
||||
const FETCH_PAYMENT_BY_SUBSCRIPTION_QUERY = PaymentsBySubscription.selectCql({
|
||||
where: PaymentsBySubscription.where.eq('subscription_id'),
|
||||
});
|
||||
|
||||
const FETCH_PAYMENTS_BY_USER_QUERY = PaymentsByUser.selectCql({
|
||||
columns: ['checkout_session_id'],
|
||||
where: PaymentsByUser.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_PAYMENTS_BY_IDS_QUERY = Payments.selectCql({
|
||||
where: Payments.where.in('checkout_session_id', 'checkout_session_ids'),
|
||||
});
|
||||
|
||||
export class PaymentRepository {
|
||||
async createPayment(data: {
|
||||
checkout_session_id: string;
|
||||
user_id: UserID;
|
||||
price_id: string;
|
||||
product_type: string;
|
||||
status: string;
|
||||
is_gift: boolean;
|
||||
created_at: Date;
|
||||
}): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
const paymentRow: PaymentRow = {
|
||||
checkout_session_id: data.checkout_session_id,
|
||||
user_id: data.user_id,
|
||||
price_id: data.price_id,
|
||||
product_type: data.product_type,
|
||||
status: data.status,
|
||||
is_gift: data.is_gift,
|
||||
created_at: data.created_at,
|
||||
stripe_customer_id: null,
|
||||
payment_intent_id: null,
|
||||
subscription_id: null,
|
||||
invoice_id: null,
|
||||
amount_cents: 0,
|
||||
currency: '',
|
||||
gift_code: null,
|
||||
completed_at: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
batch.addPrepared(Payments.upsertAll(paymentRow));
|
||||
|
||||
batch.addPrepared(
|
||||
PaymentsByUser.upsertAll({
|
||||
user_id: data.user_id,
|
||||
created_at: data.created_at,
|
||||
checkout_session_id: data.checkout_session_id,
|
||||
}),
|
||||
);
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
|
||||
const checkoutSessionId = data.checkout_session_id;
|
||||
|
||||
const result = await executeVersionedUpdate(
|
||||
() =>
|
||||
fetchOne<PaymentRow>(FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY, {
|
||||
checkout_session_id: checkoutSessionId,
|
||||
}),
|
||||
(current) => {
|
||||
type PatchOp = ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>;
|
||||
const patch: Record<string, PatchOp> = {};
|
||||
|
||||
const addField = <K extends keyof PaymentRow>(key: K) => {
|
||||
const newVal = data[key];
|
||||
const oldVal = current?.[key];
|
||||
if (newVal === null) {
|
||||
if (current && oldVal !== null && oldVal !== undefined) {
|
||||
patch[key] = Db.clear();
|
||||
}
|
||||
} else if (newVal !== undefined) {
|
||||
patch[key] = Db.set(newVal);
|
||||
}
|
||||
};
|
||||
|
||||
addField('stripe_customer_id');
|
||||
addField('payment_intent_id');
|
||||
addField('subscription_id');
|
||||
addField('invoice_id');
|
||||
addField('amount_cents');
|
||||
addField('currency');
|
||||
addField('status');
|
||||
addField('gift_code');
|
||||
addField('completed_at');
|
||||
|
||||
return {
|
||||
pk: {checkout_session_id: checkoutSessionId},
|
||||
patch,
|
||||
};
|
||||
},
|
||||
Payments,
|
||||
{onFailure: 'throw'},
|
||||
);
|
||||
|
||||
if (result.applied) {
|
||||
await this.updatePaymentIndexes(data);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async updatePaymentIndexes(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
if (data.payment_intent_id) {
|
||||
batch.addPrepared(
|
||||
PaymentsByPaymentIntent.upsertAll({
|
||||
payment_intent_id: data.payment_intent_id,
|
||||
checkout_session_id: data.checkout_session_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.subscription_id) {
|
||||
const payment = await this.getPaymentByCheckoutSession(data.checkout_session_id);
|
||||
if (payment?.priceId && payment.productType) {
|
||||
batch.addPrepared(
|
||||
PaymentsBySubscription.upsertAll({
|
||||
subscription_id: data.subscription_id,
|
||||
checkout_session_id: data.checkout_session_id,
|
||||
user_id: payment.userId,
|
||||
price_id: payment.priceId,
|
||||
product_type: payment.productType,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
|
||||
const result = await fetchOne<PaymentRow>(FETCH_PAYMENT_BY_CHECKOUT_SESSION_QUERY, {
|
||||
checkout_session_id: checkoutSessionId,
|
||||
});
|
||||
return result ? new Payment(result) : null;
|
||||
}
|
||||
|
||||
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
|
||||
const mapping = await fetchOne<{checkout_session_id: string}>(FETCH_PAYMENT_BY_PAYMENT_INTENT_QUERY, {
|
||||
payment_intent_id: paymentIntentId,
|
||||
});
|
||||
if (!mapping) return null;
|
||||
return this.getPaymentByCheckoutSession(mapping.checkout_session_id);
|
||||
}
|
||||
|
||||
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
|
||||
const result = await fetchOne<PaymentBySubscriptionRow>(FETCH_PAYMENT_BY_SUBSCRIPTION_QUERY, {
|
||||
subscription_id: subscriptionId,
|
||||
});
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
async findPaymentsByUserId(userId: UserID): Promise<Array<Payment>> {
|
||||
const paymentRefs = await fetchMany<{checkout_session_id: string}>(FETCH_PAYMENTS_BY_USER_QUERY, {
|
||||
user_id: userId,
|
||||
});
|
||||
if (paymentRefs.length === 0) return [];
|
||||
const rows = await fetchMany<PaymentRow>(FETCH_PAYMENTS_BY_IDS_QUERY, {
|
||||
checkout_session_ids: paymentRefs.map((r) => r.checkout_session_id),
|
||||
});
|
||||
return rows.map((r) => new Payment(r));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, prepared, upsertOne} from '~/database/Cassandra';
|
||||
import type {PushSubscriptionRow} from '~/database/CassandraTypes';
|
||||
import {PushSubscription} from '~/Models';
|
||||
import {PushSubscriptions} from '~/Tables';
|
||||
|
||||
const FETCH_PUSH_SUBSCRIPTIONS_CQL = PushSubscriptions.selectCql({
|
||||
where: PushSubscriptions.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_BULK_PUSH_SUBSCRIPTIONS_CQL = PushSubscriptions.selectCql({
|
||||
where: PushSubscriptions.where.in('user_id', 'user_ids'),
|
||||
});
|
||||
|
||||
export class PushSubscriptionRepository {
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
const rows = await fetchMany<PushSubscriptionRow>(FETCH_PUSH_SUBSCRIPTIONS_CQL, {user_id: userId});
|
||||
return rows.map((row) => new PushSubscription(row));
|
||||
}
|
||||
|
||||
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
|
||||
await upsertOne(PushSubscriptions.upsertAll(data));
|
||||
return new PushSubscription(data);
|
||||
}
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
await deleteOneOrMany(PushSubscriptions.deleteByPk({user_id: userId, subscription_id: subscriptionId}));
|
||||
}
|
||||
|
||||
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
|
||||
if (userIds.length === 0) return new Map();
|
||||
|
||||
const rows = await fetchMany<PushSubscriptionRow>(FETCH_BULK_PUSH_SUBSCRIPTIONS_CQL, {user_ids: userIds});
|
||||
|
||||
const map = new Map<UserID, Array<PushSubscription>>();
|
||||
for (const row of rows) {
|
||||
const sub = new PushSubscription(row);
|
||||
const existing = map.get(row.user_id) ?? [];
|
||||
existing.push(sub);
|
||||
map.set(row.user_id, existing);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
prepared(PushSubscriptions.deleteCql({where: PushSubscriptions.where.eq('user_id')}), {user_id: userId}),
|
||||
);
|
||||
}
|
||||
}
|
||||
153
fluxer_api/src/user/repositories/RecentMentionRepository.ts
Normal file
153
fluxer_api/src/user/repositories/RecentMentionRepository.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createMessageID, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchOne, prepared} from '~/database/Cassandra';
|
||||
import type {RecentMentionRow} from '~/database/CassandraTypes';
|
||||
import {RecentMention} from '~/Models';
|
||||
import {RecentMentions, RecentMentionsByGuild} from '~/Tables';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
|
||||
const FETCH_RECENT_MENTION_CQL = RecentMentions.selectCql({
|
||||
where: [RecentMentions.where.eq('user_id'), RecentMentions.where.eq('message_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const createFetchRecentMentionsQuery = (limit: number) =>
|
||||
RecentMentions.selectCql({
|
||||
where: [RecentMentions.where.eq('user_id'), RecentMentions.where.lt('message_id', 'before_message_id')],
|
||||
limit,
|
||||
});
|
||||
|
||||
export class RecentMentionRepository {
|
||||
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
|
||||
const mention = await fetchOne<RecentMentionRow>(FETCH_RECENT_MENTION_CQL, {
|
||||
user_id: userId,
|
||||
message_id: messageId,
|
||||
});
|
||||
return mention ? new RecentMention(mention) : null;
|
||||
}
|
||||
|
||||
async listRecentMentions(
|
||||
userId: UserID,
|
||||
includeEveryone: boolean = true,
|
||||
includeRole: boolean = true,
|
||||
includeGuilds: boolean = true,
|
||||
limit: number = 25,
|
||||
before?: MessageID,
|
||||
): Promise<Array<RecentMention>> {
|
||||
const fetchLimit = Math.max(limit * 2, 50);
|
||||
const query = createFetchRecentMentionsQuery(fetchLimit);
|
||||
const params: {user_id: UserID; before_message_id: MessageID} = {
|
||||
user_id: userId,
|
||||
before_message_id: before || createMessageID(SnowflakeUtils.getSnowflake()),
|
||||
};
|
||||
const allMentions = await fetchMany<RecentMentionRow>(query, params);
|
||||
const filteredMentions = allMentions.filter((mention) => {
|
||||
if (!includeEveryone && mention.is_everyone) return false;
|
||||
if (!includeRole && mention.is_role) return false;
|
||||
if (!includeGuilds && mention.guild_id != null) return false;
|
||||
return true;
|
||||
});
|
||||
return filteredMentions.slice(0, limit).map((mention) => new RecentMention(mention));
|
||||
}
|
||||
|
||||
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(RecentMentions.upsertAll(mention));
|
||||
batch.addPrepared(
|
||||
RecentMentionsByGuild.insert({
|
||||
user_id: mention.user_id,
|
||||
guild_id: mention.guild_id,
|
||||
message_id: mention.message_id,
|
||||
channel_id: mention.channel_id,
|
||||
is_everyone: mention.is_everyone,
|
||||
is_role: mention.is_role,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
return new RecentMention(mention);
|
||||
}
|
||||
|
||||
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
|
||||
if (mentions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const batch = new BatchBuilder();
|
||||
for (const mention of mentions) {
|
||||
batch.addPrepared(RecentMentions.upsertAll(mention));
|
||||
batch.addPrepared(
|
||||
RecentMentionsByGuild.insert({
|
||||
user_id: mention.user_id,
|
||||
guild_id: mention.guild_id,
|
||||
message_id: mention.message_id,
|
||||
channel_id: mention.channel_id,
|
||||
is_everyone: mention.is_everyone,
|
||||
is_role: mention.is_role,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteRecentMention(mention: RecentMention): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(RecentMentions.deleteByPk({user_id: mention.userId, message_id: mention.messageId}));
|
||||
batch.addPrepared(
|
||||
RecentMentionsByGuild.deleteByPk({
|
||||
user_id: mention.userId,
|
||||
guild_id: mention.guildId,
|
||||
message_id: mention.messageId,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteAllRecentMentions(userId: UserID): Promise<void> {
|
||||
const mentions = await fetchMany<{guild_id: bigint; message_id: bigint}>(
|
||||
RecentMentions.selectCql({
|
||||
columns: ['guild_id', 'message_id'],
|
||||
where: RecentMentions.where.eq('user_id'),
|
||||
}),
|
||||
{
|
||||
user_id: userId,
|
||||
},
|
||||
);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(
|
||||
prepared(RecentMentions.deleteCql({where: RecentMentions.where.eq('user_id')}), {user_id: userId}),
|
||||
);
|
||||
|
||||
for (const mention of mentions) {
|
||||
batch.addPrepared(
|
||||
RecentMentionsByGuild.deleteByPk({
|
||||
guild_id: mention.guild_id,
|
||||
user_id: userId,
|
||||
message_id: mention.message_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (batch) {
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
72
fluxer_api/src/user/repositories/SavedMessageRepository.ts
Normal file
72
fluxer_api/src/user/repositories/SavedMessageRepository.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChannelID, createMessageID, type MessageID, type UserID} from '~/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, prepared, upsertOne} from '~/database/Cassandra';
|
||||
import type {SavedMessageRow} from '~/database/CassandraTypes';
|
||||
import {SavedMessage} from '~/Models';
|
||||
import {SavedMessages} from '~/Tables';
|
||||
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
|
||||
|
||||
const createFetchSavedMessagesQuery = (limit: number) =>
|
||||
SavedMessages.selectCql({
|
||||
where: [SavedMessages.where.eq('user_id'), SavedMessages.where.lt('message_id', 'before_message_id')],
|
||||
limit,
|
||||
});
|
||||
|
||||
export class SavedMessageRepository {
|
||||
async listSavedMessages(
|
||||
userId: UserID,
|
||||
limit: number = 25,
|
||||
before: MessageID = createMessageID(SnowflakeUtils.getSnowflake()),
|
||||
): Promise<Array<SavedMessage>> {
|
||||
const fetchLimit = Math.max(limit * 2, 50);
|
||||
const savedMessageRows = await fetchMany<SavedMessageRow>(createFetchSavedMessagesQuery(fetchLimit), {
|
||||
user_id: userId,
|
||||
before_message_id: before,
|
||||
});
|
||||
const savedMessages: Array<SavedMessage> = [];
|
||||
for (const savedMessageRow of savedMessageRows) {
|
||||
if (savedMessages.length >= limit) break;
|
||||
savedMessages.push(new SavedMessage(savedMessageRow));
|
||||
}
|
||||
return savedMessages;
|
||||
}
|
||||
|
||||
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
|
||||
const savedMessageRow: SavedMessageRow = {
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
message_id: messageId,
|
||||
saved_at: new Date(),
|
||||
};
|
||||
await upsertOne(SavedMessages.upsertAll(savedMessageRow));
|
||||
return new SavedMessage(savedMessageRow);
|
||||
}
|
||||
|
||||
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
|
||||
await deleteOneOrMany(SavedMessages.deleteByPk({user_id: userId, message_id: messageId}));
|
||||
}
|
||||
|
||||
async deleteAllSavedMessages(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
prepared(SavedMessages.deleteCql({where: SavedMessages.where.eq('user_id')}), {user_id: userId}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {MessageID, UserID} from '~/BrandedTypes';
|
||||
import {Db, deleteOneOrMany, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {ScheduledMessageRow} from '~/database/CassandraTypes';
|
||||
import {ScheduledMessage} from '~/models/ScheduledMessage';
|
||||
import {ScheduledMessages} from '~/Tables';
|
||||
|
||||
export class ScheduledMessageRepository {
|
||||
private readonly fetchCql = ScheduledMessages.selectCql({
|
||||
where: [ScheduledMessages.where.eq('user_id')],
|
||||
});
|
||||
|
||||
async listScheduledMessages(userId: UserID, limit: number = 25): Promise<Array<ScheduledMessage>> {
|
||||
const rows = await fetchMany<ScheduledMessageRow>(this.fetchCql, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const messages = rows.map((row) => ScheduledMessage.fromRow(row));
|
||||
return messages.sort((a, b) => (b.id > a.id ? 1 : a.id > b.id ? -1 : 0)).slice(0, limit);
|
||||
}
|
||||
|
||||
async getScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<ScheduledMessage | null> {
|
||||
const row = await fetchOne<ScheduledMessageRow>(
|
||||
ScheduledMessages.selectCql({
|
||||
where: [ScheduledMessages.where.eq('user_id'), ScheduledMessages.where.eq('scheduled_message_id')],
|
||||
}),
|
||||
{
|
||||
user_id: userId,
|
||||
scheduled_message_id: scheduledMessageId,
|
||||
},
|
||||
);
|
||||
return row ? ScheduledMessage.fromRow(row) : null;
|
||||
}
|
||||
|
||||
async upsertScheduledMessage(message: ScheduledMessage, ttlSeconds: number): Promise<void> {
|
||||
await ScheduledMessages.upsertAllWithTtl(message.toRow(), ttlSeconds);
|
||||
}
|
||||
|
||||
async deleteScheduledMessage(userId: UserID, scheduledMessageId: MessageID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
ScheduledMessages.deleteByPk({
|
||||
user_id: userId,
|
||||
scheduled_message_id: scheduledMessageId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async markInvalid(userId: UserID, scheduledMessageId: MessageID, reason: string, ttlSeconds: number): Promise<void> {
|
||||
await ScheduledMessages.patchByPkWithTtl(
|
||||
{
|
||||
user_id: userId,
|
||||
scheduled_message_id: scheduledMessageId,
|
||||
},
|
||||
{
|
||||
status: Db.set('invalid'),
|
||||
status_reason: Db.set(reason),
|
||||
invalidated_at: Db.set(new Date()),
|
||||
},
|
||||
ttlSeconds,
|
||||
);
|
||||
}
|
||||
}
|
||||
143
fluxer_api/src/user/repositories/UserAccountRepository.ts
Normal file
143
fluxer_api/src/user/repositories/UserAccountRepository.ts
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 {GuildID, UserID} from '~/BrandedTypes';
|
||||
import type {UserRow} from '~/database/CassandraTypes';
|
||||
import type {User} from '~/Models';
|
||||
import {UserAccountRepository as UserAccountCrudRepository} from './account/UserAccountRepository';
|
||||
import {UserDeletionRepository} from './account/UserDeletionRepository';
|
||||
import {UserGuildRepository} from './account/UserGuildRepository';
|
||||
import {UserLookupRepository} from './account/UserLookupRepository';
|
||||
import type {IUserAccountRepository} from './IUserAccountRepository';
|
||||
|
||||
export class UserAccountRepository implements IUserAccountRepository {
|
||||
private accountRepo: UserAccountCrudRepository;
|
||||
private lookupRepo: UserLookupRepository;
|
||||
private deletionRepo: UserDeletionRepository;
|
||||
private guildRepo: UserGuildRepository;
|
||||
|
||||
constructor() {
|
||||
this.accountRepo = new UserAccountCrudRepository();
|
||||
this.lookupRepo = new UserLookupRepository(this.accountRepo.findUnique.bind(this.accountRepo));
|
||||
this.deletionRepo = new UserDeletionRepository(this.accountRepo.findUnique.bind(this.accountRepo));
|
||||
this.guildRepo = new UserGuildRepository();
|
||||
}
|
||||
|
||||
async create(data: UserRow): Promise<User> {
|
||||
return this.accountRepo.create(data);
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return this.accountRepo.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return this.accountRepo.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
|
||||
return this.accountRepo.listAllUsersPaginated(limit, lastUserId);
|
||||
}
|
||||
|
||||
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
|
||||
return this.accountRepo.listUsers(userIds);
|
||||
}
|
||||
|
||||
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
|
||||
return this.accountRepo.upsert(data, oldData);
|
||||
}
|
||||
|
||||
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User | null> {
|
||||
return this.accountRepo.patchUpsert(userId, patchData, oldData);
|
||||
}
|
||||
|
||||
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
|
||||
return this.accountRepo.deleteUserSecondaryIndices(userId);
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.lookupRepo.findByEmail(email);
|
||||
}
|
||||
|
||||
async findByPhone(phone: string): Promise<User | null> {
|
||||
return this.lookupRepo.findByPhone(phone);
|
||||
}
|
||||
|
||||
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
|
||||
return this.lookupRepo.findByStripeCustomerId(stripeCustomerId);
|
||||
}
|
||||
|
||||
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
|
||||
return this.lookupRepo.findByStripeSubscriptionId(stripeSubscriptionId);
|
||||
}
|
||||
|
||||
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
|
||||
return this.lookupRepo.findByUsernameDiscriminator(username, discriminator);
|
||||
}
|
||||
|
||||
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
|
||||
return this.lookupRepo.findDiscriminatorsByUsername(username);
|
||||
}
|
||||
|
||||
async getActivityTracking(userId: UserID): Promise<{last_active_at: Date | null; last_active_ip: string | null}> {
|
||||
const user = await this.findUnique(userId);
|
||||
return {
|
||||
last_active_at: user?.lastActiveAt ?? null,
|
||||
last_active_ip: user?.lastActiveIp ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.deletionRepo.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
|
||||
return this.deletionRepo.findUsersPendingDeletion(now);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletionByDate(
|
||||
deletionDate: string,
|
||||
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
|
||||
return this.deletionRepo.findUsersPendingDeletionByDate(deletionDate);
|
||||
}
|
||||
|
||||
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
|
||||
return this.deletionRepo.isUserPendingDeletion(userId, deletionDate);
|
||||
}
|
||||
|
||||
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
|
||||
return this.deletionRepo.removePendingDeletion(userId, pendingDeletionAt);
|
||||
}
|
||||
|
||||
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.deletionRepo.scheduleDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
|
||||
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
|
||||
return this.guildRepo.getUserGuildIds(userId);
|
||||
}
|
||||
|
||||
async removeFromAllGuilds(userId: UserID): Promise<void> {
|
||||
return this.guildRepo.removeFromAllGuilds(userId);
|
||||
}
|
||||
|
||||
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
|
||||
return this.accountRepo.updateLastActiveAt(params);
|
||||
}
|
||||
}
|
||||
240
fluxer_api/src/user/repositories/UserAuthRepository.ts
Normal file
240
fluxer_api/src/user/repositories/UserAuthRepository.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {PhoneVerificationToken, UserID} from '~/BrandedTypes';
|
||||
import type {
|
||||
AuthSessionRow,
|
||||
EmailRevertTokenRow,
|
||||
EmailVerificationTokenRow,
|
||||
PasswordResetTokenRow,
|
||||
PhoneTokenRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import type {
|
||||
AuthSession,
|
||||
EmailRevertToken,
|
||||
EmailVerificationToken,
|
||||
MfaBackupCode,
|
||||
PasswordResetToken,
|
||||
WebAuthnCredential,
|
||||
} from '~/Models';
|
||||
import {AuthSessionRepository} from './auth/AuthSessionRepository';
|
||||
import {IpAuthorizationRepository} from './auth/IpAuthorizationRepository';
|
||||
import {MfaBackupCodeRepository} from './auth/MfaBackupCodeRepository';
|
||||
import {PendingVerificationRepository} from './auth/PendingVerificationRepository';
|
||||
import {TokenRepository} from './auth/TokenRepository';
|
||||
import {WebAuthnRepository} from './auth/WebAuthnRepository';
|
||||
import type {IUserAccountRepository} from './IUserAccountRepository';
|
||||
import type {IUserAuthRepository} from './IUserAuthRepository';
|
||||
|
||||
export class UserAuthRepository implements IUserAuthRepository {
|
||||
private authSessionRepository: AuthSessionRepository;
|
||||
private mfaBackupCodeRepository: MfaBackupCodeRepository;
|
||||
private tokenRepository: TokenRepository;
|
||||
private ipAuthorizationRepository: IpAuthorizationRepository;
|
||||
private webAuthnRepository: WebAuthnRepository;
|
||||
private pendingVerificationRepository: PendingVerificationRepository;
|
||||
|
||||
constructor(userAccountRepository: IUserAccountRepository) {
|
||||
this.authSessionRepository = new AuthSessionRepository();
|
||||
this.mfaBackupCodeRepository = new MfaBackupCodeRepository();
|
||||
this.tokenRepository = new TokenRepository();
|
||||
this.ipAuthorizationRepository = new IpAuthorizationRepository(userAccountRepository);
|
||||
this.webAuthnRepository = new WebAuthnRepository();
|
||||
this.pendingVerificationRepository = new PendingVerificationRepository();
|
||||
}
|
||||
|
||||
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
|
||||
return this.authSessionRepository.listAuthSessions(userId);
|
||||
}
|
||||
|
||||
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
|
||||
return this.authSessionRepository.getAuthSessionByToken(sessionIdHash);
|
||||
}
|
||||
|
||||
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
|
||||
return this.authSessionRepository.createAuthSession(sessionData);
|
||||
}
|
||||
|
||||
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
|
||||
const session = await this.getAuthSessionByToken(sessionIdHash);
|
||||
if (!session) return;
|
||||
await this.authSessionRepository.updateAuthSessionLastUsed(sessionIdHash);
|
||||
}
|
||||
|
||||
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
|
||||
return this.authSessionRepository.deleteAuthSessions(userId, sessionIdHashes);
|
||||
}
|
||||
|
||||
async revokeAuthSession(sessionIdHash: Buffer): Promise<void> {
|
||||
const session = await this.getAuthSessionByToken(sessionIdHash);
|
||||
if (!session) return;
|
||||
await this.deleteAuthSessions(session.userId, [sessionIdHash]);
|
||||
}
|
||||
|
||||
async deleteAllAuthSessions(userId: UserID): Promise<void> {
|
||||
return this.authSessionRepository.deleteAllAuthSessions(userId);
|
||||
}
|
||||
|
||||
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
|
||||
return this.mfaBackupCodeRepository.listMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
|
||||
return this.mfaBackupCodeRepository.createMfaBackupCodes(userId, codes);
|
||||
}
|
||||
|
||||
async clearMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
return this.mfaBackupCodeRepository.clearMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
|
||||
return this.mfaBackupCodeRepository.consumeMfaBackupCode(userId, code);
|
||||
}
|
||||
|
||||
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
return this.mfaBackupCodeRepository.deleteAllMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
|
||||
return this.tokenRepository.getEmailVerificationToken(token);
|
||||
}
|
||||
|
||||
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
|
||||
return this.tokenRepository.createEmailVerificationToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailVerificationToken(token: string): Promise<void> {
|
||||
return this.tokenRepository.deleteEmailVerificationToken(token);
|
||||
}
|
||||
|
||||
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
|
||||
return this.tokenRepository.getPasswordResetToken(token);
|
||||
}
|
||||
|
||||
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
|
||||
return this.tokenRepository.createPasswordResetToken(tokenData);
|
||||
}
|
||||
|
||||
async deletePasswordResetToken(token: string): Promise<void> {
|
||||
return this.tokenRepository.deletePasswordResetToken(token);
|
||||
}
|
||||
|
||||
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
|
||||
return this.tokenRepository.getEmailRevertToken(token);
|
||||
}
|
||||
|
||||
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
|
||||
return this.tokenRepository.createEmailRevertToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailRevertToken(token: string): Promise<void> {
|
||||
return this.tokenRepository.deleteEmailRevertToken(token);
|
||||
}
|
||||
|
||||
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
|
||||
return this.tokenRepository.createPhoneToken(token, phone, userId);
|
||||
}
|
||||
|
||||
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
|
||||
return this.tokenRepository.getPhoneToken(token);
|
||||
}
|
||||
|
||||
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
|
||||
return this.tokenRepository.deletePhoneToken(token);
|
||||
}
|
||||
|
||||
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
|
||||
return this.ipAuthorizationRepository.updateUserActivity(userId, clientIp);
|
||||
}
|
||||
|
||||
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
|
||||
return this.ipAuthorizationRepository.checkIpAuthorized(userId, ip);
|
||||
}
|
||||
|
||||
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
|
||||
return this.ipAuthorizationRepository.createAuthorizedIp(userId, ip);
|
||||
}
|
||||
|
||||
async createIpAuthorizationToken(userId: UserID, token: string): Promise<void> {
|
||||
return this.ipAuthorizationRepository.createIpAuthorizationToken(userId, token);
|
||||
}
|
||||
|
||||
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
|
||||
return this.ipAuthorizationRepository.authorizeIpByToken(token);
|
||||
}
|
||||
|
||||
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
|
||||
return this.ipAuthorizationRepository.getAuthorizedIps(userId);
|
||||
}
|
||||
|
||||
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
|
||||
return this.ipAuthorizationRepository.deleteAllAuthorizedIps(userId);
|
||||
}
|
||||
|
||||
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
return this.webAuthnRepository.listWebAuthnCredentials(userId);
|
||||
}
|
||||
|
||||
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
|
||||
return this.webAuthnRepository.getWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async createWebAuthnCredential(
|
||||
userId: UserID,
|
||||
credentialId: string,
|
||||
publicKey: Buffer,
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
return this.webAuthnRepository.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
|
||||
return this.webAuthnRepository.updateWebAuthnCredentialCounter(userId, credentialId, counter);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.webAuthnRepository.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
|
||||
return this.webAuthnRepository.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.webAuthnRepository.deleteWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
|
||||
return this.webAuthnRepository.getUserIdByCredentialId(credentialId);
|
||||
}
|
||||
|
||||
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
|
||||
return this.webAuthnRepository.deleteAllWebAuthnCredentials(userId);
|
||||
}
|
||||
|
||||
async createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void> {
|
||||
return this.pendingVerificationRepository.createPendingVerification(userId, createdAt, metadata);
|
||||
}
|
||||
|
||||
async deletePendingVerification(userId: UserID): Promise<void> {
|
||||
return this.pendingVerificationRepository.deletePendingVerification(userId);
|
||||
}
|
||||
}
|
||||
369
fluxer_api/src/user/repositories/UserChannelRepository.ts
Normal file
369
fluxer_api/src/user/repositories/UserChannelRepository.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import {ChannelTypes} from '~/Constants';
|
||||
import {BatchBuilder, deleteOneOrMany, fetchMany, fetchManyInChunks, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {ChannelRow, DmStateRow, PrivateChannelRow} from '~/database/CassandraTypes';
|
||||
import {Channel} from '~/Models';
|
||||
import {Channels, DmStates, PinnedDms, PrivateChannels, ReadStates} from '~/Tables';
|
||||
import type {IUserChannelRepository, PrivateChannelSummary} from './IUserChannelRepository';
|
||||
|
||||
interface PinnedDmRow {
|
||||
user_id: UserID;
|
||||
channel_id: ChannelID;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
const CHECK_PRIVATE_CHANNEL_CQL = PrivateChannels.selectCql({
|
||||
columns: ['channel_id'],
|
||||
where: [PrivateChannels.where.eq('user_id'), PrivateChannels.where.eq('channel_id')],
|
||||
});
|
||||
|
||||
const FETCH_CHANNEL_CQL = Channels.selectCql({
|
||||
columns: [
|
||||
'channel_id',
|
||||
'guild_id',
|
||||
'type',
|
||||
'name',
|
||||
'topic',
|
||||
'icon_hash',
|
||||
'url',
|
||||
'parent_id',
|
||||
'position',
|
||||
'owner_id',
|
||||
'recipient_ids',
|
||||
'nsfw',
|
||||
'rate_limit_per_user',
|
||||
'bitrate',
|
||||
'user_limit',
|
||||
'rtc_region',
|
||||
'last_message_id',
|
||||
'last_pin_timestamp',
|
||||
'permission_overwrites',
|
||||
'nicks',
|
||||
'soft_deleted',
|
||||
],
|
||||
where: [Channels.where.eq('channel_id'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_DM_STATE_CQL = DmStates.selectCql({
|
||||
where: [DmStates.where.eq('hi_user_id'), DmStates.where.eq('lo_user_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_PINNED_DMS_CQL = PinnedDms.selectCql({
|
||||
where: PinnedDms.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_PRIVATE_CHANNELS_CQL = PrivateChannels.selectCql({
|
||||
where: PrivateChannels.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_CHANNEL_METADATA_CQL = Channels.selectCql({
|
||||
columns: ['channel_id', 'type', 'last_message_id', 'soft_deleted'],
|
||||
where: [Channels.where.in('channel_id', 'channel_ids'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
|
||||
});
|
||||
|
||||
const FETCH_CHANNELS_IN_CQL = Channels.selectCql({
|
||||
where: [Channels.where.in('channel_id', 'channel_ids'), {kind: 'eq', col: 'soft_deleted', param: 'soft_deleted'}],
|
||||
});
|
||||
|
||||
const sortBySortOrder = (a: PinnedDmRow, b: PinnedDmRow): number => a.sort_order - b.sort_order;
|
||||
|
||||
async function fetchPinnedDms(userId: UserID): Promise<Array<PinnedDmRow>> {
|
||||
return fetchMany<PinnedDmRow>(FETCH_PINNED_DMS_CQL, {user_id: userId});
|
||||
}
|
||||
|
||||
export class UserChannelRepository implements IUserChannelRepository {
|
||||
async addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
|
||||
const pinnedDms = await fetchPinnedDms(userId);
|
||||
|
||||
const existingDm = pinnedDms.find((dm) => dm.channel_id === channelId);
|
||||
if (existingDm) {
|
||||
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
|
||||
}
|
||||
|
||||
const highestSortOrder = pinnedDms.length > 0 ? Math.max(...pinnedDms.map((dm) => dm.sort_order)) : -1;
|
||||
|
||||
await upsertOne(
|
||||
PinnedDms.upsertAll({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
sort_order: highestSortOrder + 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const allPinnedDms: Array<PinnedDmRow> = [
|
||||
...pinnedDms,
|
||||
{
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
sort_order: highestSortOrder + 1,
|
||||
},
|
||||
];
|
||||
|
||||
return allPinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
|
||||
}
|
||||
|
||||
async closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PrivateChannels.deleteByPk({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel> {
|
||||
const hiUserId = user1Id > user2Id ? user1Id : user2Id;
|
||||
const loUserId = user1Id > user2Id ? user2Id : user1Id;
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
const channelRow: ChannelRow = {
|
||||
channel_id: channelId,
|
||||
guild_id: null,
|
||||
type: ChannelTypes.DM,
|
||||
name: null,
|
||||
topic: null,
|
||||
icon_hash: null,
|
||||
url: null,
|
||||
parent_id: null,
|
||||
position: null,
|
||||
owner_id: null,
|
||||
recipient_ids: new Set([user1Id, user2Id]),
|
||||
nsfw: null,
|
||||
rate_limit_per_user: null,
|
||||
bitrate: null,
|
||||
user_limit: null,
|
||||
rtc_region: null,
|
||||
last_message_id: null,
|
||||
last_pin_timestamp: null,
|
||||
permission_overwrites: null,
|
||||
nicks: null,
|
||||
soft_deleted: false,
|
||||
indexed_at: null,
|
||||
version: 1,
|
||||
};
|
||||
|
||||
batch.addPrepared(Channels.upsertAll(channelRow));
|
||||
batch.addPrepared(
|
||||
DmStates.upsertAll({
|
||||
hi_user_id: hiUserId,
|
||||
lo_user_id: loUserId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
PrivateChannels.upsertAll({
|
||||
user_id: user1Id,
|
||||
channel_id: channelId,
|
||||
is_gdm: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await batch.execute();
|
||||
|
||||
return new Channel(channelRow);
|
||||
}
|
||||
|
||||
async deleteAllPrivateChannels(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PrivateChannels.deleteCql({
|
||||
where: PrivateChannels.where.eq('user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAllReadStates(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
ReadStates.deleteCql({
|
||||
where: ReadStates.where.eq('user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
}
|
||||
|
||||
async findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null> {
|
||||
const hiUserId = user1Id > user2Id ? user1Id : user2Id;
|
||||
const loUserId = user1Id > user2Id ? user2Id : user1Id;
|
||||
|
||||
const dmState = await fetchOne<DmStateRow>(FETCH_DM_STATE_CQL, {
|
||||
hi_user_id: hiUserId,
|
||||
lo_user_id: loUserId,
|
||||
});
|
||||
|
||||
if (!dmState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channel = await fetchOne<ChannelRow>(FETCH_CHANNEL_CQL, {
|
||||
channel_id: dmState.channel_id,
|
||||
soft_deleted: false,
|
||||
});
|
||||
|
||||
return channel ? new Channel(channel) : null;
|
||||
}
|
||||
|
||||
async getPinnedDms(userId: UserID): Promise<Array<ChannelID>> {
|
||||
const pinnedDms = await fetchPinnedDms(userId);
|
||||
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
|
||||
}
|
||||
|
||||
async getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>> {
|
||||
const pinnedDms = await fetchPinnedDms(userId);
|
||||
return pinnedDms.sort(sortBySortOrder);
|
||||
}
|
||||
|
||||
async isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean> {
|
||||
const result = await fetchOne<{channel_id: bigint}>(CHECK_PRIVATE_CHANNEL_CQL, {
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
});
|
||||
|
||||
return result != null;
|
||||
}
|
||||
|
||||
async listPrivateChannels(userId: UserID): Promise<Array<Channel>> {
|
||||
const rows = await fetchMany<PrivateChannelRow>(FETCH_PRIVATE_CHANNELS_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const channelIds = rows.map((row) => row.channel_id);
|
||||
const channelRows = await fetchManyInChunks<ChannelRow>(FETCH_CHANNELS_IN_CQL, channelIds, (chunk) => ({
|
||||
channel_ids: chunk,
|
||||
soft_deleted: false,
|
||||
}));
|
||||
|
||||
return channelRows.map((row) => new Channel(row));
|
||||
}
|
||||
|
||||
async listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>> {
|
||||
const rows = await fetchMany<PrivateChannelRow>(FETCH_PRIVATE_CHANNELS_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
if (rows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const channelIds = rows.map((row) => row.channel_id);
|
||||
const fetchMetadataForSoftDeleted = async (
|
||||
ids: Array<ChannelID>,
|
||||
softDeleted: boolean,
|
||||
): Promise<
|
||||
Array<{
|
||||
channel_id: ChannelID;
|
||||
type: number;
|
||||
last_message_id: MessageID | null;
|
||||
soft_deleted: boolean;
|
||||
}>
|
||||
> => {
|
||||
return fetchManyInChunks(FETCH_CHANNEL_METADATA_CQL, ids, (chunk) => ({
|
||||
channel_ids: chunk,
|
||||
soft_deleted: softDeleted,
|
||||
}));
|
||||
};
|
||||
|
||||
const channelMap = new Map<
|
||||
ChannelID,
|
||||
{
|
||||
channel_id: ChannelID;
|
||||
type: number;
|
||||
last_message_id: MessageID | null;
|
||||
soft_deleted: boolean;
|
||||
}
|
||||
>();
|
||||
|
||||
const openChannelRows = await fetchMetadataForSoftDeleted(channelIds, false);
|
||||
for (const row of openChannelRows) {
|
||||
channelMap.set(row.channel_id, row);
|
||||
}
|
||||
|
||||
const missingChannelIds = channelIds.filter((id) => !channelMap.has(id));
|
||||
if (missingChannelIds.length > 0) {
|
||||
const deletedChannelRows = await fetchMetadataForSoftDeleted(missingChannelIds, true);
|
||||
for (const row of deletedChannelRows) {
|
||||
if (!channelMap.has(row.channel_id)) {
|
||||
channelMap.set(row.channel_id, row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const channelRow = channelMap.get(row.channel_id);
|
||||
return {
|
||||
channelId: row.channel_id,
|
||||
isGroupDm: row.is_gdm ?? false,
|
||||
channelType: channelRow ? channelRow.type : null,
|
||||
lastMessageId: channelRow ? channelRow.last_message_id : null,
|
||||
open: Boolean(channelRow && !channelRow.soft_deleted),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void> {
|
||||
let resolvedIsGroupDm: boolean;
|
||||
if (isGroupDm !== undefined) {
|
||||
resolvedIsGroupDm = isGroupDm;
|
||||
} else {
|
||||
const channelRow = await fetchOne<ChannelRow>(FETCH_CHANNEL_CQL, {
|
||||
channel_id: channelId,
|
||||
soft_deleted: false,
|
||||
});
|
||||
resolvedIsGroupDm = channelRow?.type === ChannelTypes.GROUP_DM;
|
||||
}
|
||||
|
||||
await upsertOne(
|
||||
PrivateChannels.upsertAll({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
is_gdm: resolvedIsGroupDm,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
|
||||
await deleteOneOrMany(
|
||||
PinnedDms.deleteByPk({
|
||||
user_id: userId,
|
||||
channel_id: channelId,
|
||||
}),
|
||||
);
|
||||
|
||||
const pinnedDms = await fetchPinnedDms(userId);
|
||||
return pinnedDms.sort(sortBySortOrder).map((dm) => dm.channel_id);
|
||||
}
|
||||
|
||||
async deletePinnedDmsByUserId(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PinnedDms.deleteCql({
|
||||
where: PinnedDms.where.eq('user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {fetchMany, upsertOne} from '~/database/Cassandra';
|
||||
import type {UserContactChangeLogRow} from '~/database/CassandraTypes';
|
||||
import {UserContactChangeLogs} from '~/Tables';
|
||||
|
||||
const createListLogsCql = (limit: number, includeCursor: boolean) =>
|
||||
UserContactChangeLogs.selectCql({
|
||||
where: includeCursor
|
||||
? [UserContactChangeLogs.where.eq('user_id'), UserContactChangeLogs.where.lt('event_id', 'before_event_id')]
|
||||
: UserContactChangeLogs.where.eq('user_id'),
|
||||
orderBy: {col: 'event_id', direction: 'DESC'},
|
||||
limit,
|
||||
});
|
||||
|
||||
export interface ContactChangeLogListParams {
|
||||
userId: UserID;
|
||||
limit: number;
|
||||
beforeEventId?: string;
|
||||
}
|
||||
|
||||
export interface ContactChangeLogInsertParams {
|
||||
userId: UserID;
|
||||
field: string;
|
||||
oldValue: string | null;
|
||||
newValue: string | null;
|
||||
reason: string;
|
||||
actorUserId: UserID | null;
|
||||
eventAt?: Date;
|
||||
}
|
||||
|
||||
export class UserContactChangeLogRepository {
|
||||
async insertLog(params: ContactChangeLogInsertParams): Promise<void> {
|
||||
const eventAt = params.eventAt ?? new Date();
|
||||
await upsertOne(
|
||||
UserContactChangeLogs.insertWithNow(
|
||||
{
|
||||
user_id: params.userId,
|
||||
field: params.field,
|
||||
old_value: params.oldValue,
|
||||
new_value: params.newValue,
|
||||
reason: params.reason,
|
||||
actor_user_id: params.actorUserId,
|
||||
event_at: eventAt,
|
||||
},
|
||||
'event_id',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async listLogs(params: ContactChangeLogListParams): Promise<Array<UserContactChangeLogRow>> {
|
||||
const {userId, limit, beforeEventId} = params;
|
||||
const query = createListLogsCql(limit, !!beforeEventId);
|
||||
const queryParams: {user_id: UserID; before_event_id?: string} = {
|
||||
user_id: userId,
|
||||
};
|
||||
if (beforeEventId) {
|
||||
queryParams.before_event_id = beforeEventId;
|
||||
}
|
||||
const rows = await fetchMany<
|
||||
Omit<UserContactChangeLogRow, 'user_id' | 'actor_user_id'> & {
|
||||
user_id: bigint;
|
||||
actor_user_id: bigint | null;
|
||||
}
|
||||
>(query, queryParams);
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
user_id: createUserID(row.user_id),
|
||||
actor_user_id: row.actor_user_id != null ? createUserID(row.actor_user_id) : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
231
fluxer_api/src/user/repositories/UserContentRepository.ts
Normal file
231
fluxer_api/src/user/repositories/UserContentRepository.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, MessageID, UserID} from '~/BrandedTypes';
|
||||
import type {
|
||||
BetaCodeRow,
|
||||
GiftCodeRow,
|
||||
PaymentBySubscriptionRow,
|
||||
PaymentRow,
|
||||
PushSubscriptionRow,
|
||||
RecentMentionRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import type {BetaCode, GiftCode, Payment, PushSubscription, RecentMention, SavedMessage, VisionarySlot} from '~/Models';
|
||||
import {BetaCodeRepository} from './BetaCodeRepository';
|
||||
import {GiftCodeRepository} from './GiftCodeRepository';
|
||||
import type {IUserContentRepository} from './IUserContentRepository';
|
||||
import {PaymentRepository} from './PaymentRepository';
|
||||
import {PushSubscriptionRepository} from './PushSubscriptionRepository';
|
||||
import {RecentMentionRepository} from './RecentMentionRepository';
|
||||
import {SavedMessageRepository} from './SavedMessageRepository';
|
||||
import {VisionarySlotRepository} from './VisionarySlotRepository';
|
||||
|
||||
export class UserContentRepository implements IUserContentRepository {
|
||||
private betaCodeRepository: BetaCodeRepository;
|
||||
private giftCodeRepository: GiftCodeRepository;
|
||||
private paymentRepository: PaymentRepository;
|
||||
private pushSubscriptionRepository: PushSubscriptionRepository;
|
||||
private recentMentionRepository: RecentMentionRepository;
|
||||
private savedMessageRepository: SavedMessageRepository;
|
||||
private visionarySlotRepository: VisionarySlotRepository;
|
||||
|
||||
constructor() {
|
||||
this.betaCodeRepository = new BetaCodeRepository();
|
||||
this.giftCodeRepository = new GiftCodeRepository();
|
||||
this.paymentRepository = new PaymentRepository();
|
||||
this.pushSubscriptionRepository = new PushSubscriptionRepository();
|
||||
this.recentMentionRepository = new RecentMentionRepository();
|
||||
this.savedMessageRepository = new SavedMessageRepository();
|
||||
this.visionarySlotRepository = new VisionarySlotRepository();
|
||||
}
|
||||
|
||||
async listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>> {
|
||||
return this.betaCodeRepository.listBetaCodes(creatorId);
|
||||
}
|
||||
|
||||
async getBetaCode(code: string): Promise<BetaCode | null> {
|
||||
return this.betaCodeRepository.getBetaCode(code);
|
||||
}
|
||||
|
||||
async upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode> {
|
||||
return this.betaCodeRepository.upsertBetaCode(betaCode);
|
||||
}
|
||||
|
||||
async updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void> {
|
||||
return this.betaCodeRepository.updateBetaCodeRedeemed(code, redeemerId, redeemedAt);
|
||||
}
|
||||
|
||||
async deleteBetaCode(code: string, creatorId: UserID): Promise<void> {
|
||||
return this.betaCodeRepository.deleteBetaCode(code, creatorId);
|
||||
}
|
||||
|
||||
async deleteAllBetaCodes(userId: UserID): Promise<void> {
|
||||
return this.betaCodeRepository.deleteAllBetaCodes(userId);
|
||||
}
|
||||
|
||||
async createGiftCode(data: GiftCodeRow): Promise<void> {
|
||||
return this.giftCodeRepository.createGiftCode(data);
|
||||
}
|
||||
|
||||
async findGiftCode(code: string): Promise<GiftCode | null> {
|
||||
return this.giftCodeRepository.findGiftCode(code);
|
||||
}
|
||||
|
||||
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
|
||||
return this.giftCodeRepository.findGiftCodeByPaymentIntent(paymentIntentId);
|
||||
}
|
||||
|
||||
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
|
||||
return this.giftCodeRepository.findGiftCodesByCreator(userId);
|
||||
}
|
||||
|
||||
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
|
||||
return this.giftCodeRepository.redeemGiftCode(code, userId);
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
return this.giftCodeRepository.updateGiftCode(code, data);
|
||||
}
|
||||
|
||||
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
|
||||
return this.giftCodeRepository.linkGiftCodeToCheckoutSession(code, checkoutSessionId);
|
||||
}
|
||||
|
||||
async createPayment(data: {
|
||||
checkout_session_id: string;
|
||||
user_id: UserID;
|
||||
price_id: string;
|
||||
product_type: string;
|
||||
status: string;
|
||||
is_gift: boolean;
|
||||
created_at: Date;
|
||||
}): Promise<void> {
|
||||
return this.paymentRepository.createPayment(data);
|
||||
}
|
||||
|
||||
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
|
||||
return this.paymentRepository.updatePayment(data);
|
||||
}
|
||||
|
||||
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
|
||||
return this.paymentRepository.getPaymentByCheckoutSession(checkoutSessionId);
|
||||
}
|
||||
|
||||
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
|
||||
return this.paymentRepository.getPaymentByPaymentIntent(paymentIntentId);
|
||||
}
|
||||
|
||||
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
|
||||
return this.paymentRepository.getSubscriptionInfo(subscriptionId);
|
||||
}
|
||||
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
return this.pushSubscriptionRepository.listPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
|
||||
return this.pushSubscriptionRepository.createPushSubscription(data);
|
||||
}
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
return this.pushSubscriptionRepository.deletePushSubscription(userId, subscriptionId);
|
||||
}
|
||||
|
||||
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
|
||||
return this.pushSubscriptionRepository.getBulkPushSubscriptions(userIds);
|
||||
}
|
||||
|
||||
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
|
||||
return this.pushSubscriptionRepository.deleteAllPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
|
||||
return this.recentMentionRepository.getRecentMention(userId, messageId);
|
||||
}
|
||||
|
||||
async listRecentMentions(
|
||||
userId: UserID,
|
||||
includeEveryone: boolean = true,
|
||||
includeRole: boolean = true,
|
||||
includeGuilds: boolean = true,
|
||||
limit: number = 25,
|
||||
before?: MessageID,
|
||||
): Promise<Array<RecentMention>> {
|
||||
return this.recentMentionRepository.listRecentMentions(
|
||||
userId,
|
||||
includeEveryone,
|
||||
includeRole,
|
||||
includeGuilds,
|
||||
limit,
|
||||
before,
|
||||
);
|
||||
}
|
||||
|
||||
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
|
||||
return this.recentMentionRepository.createRecentMention(mention);
|
||||
}
|
||||
|
||||
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
|
||||
return this.recentMentionRepository.createRecentMentions(mentions);
|
||||
}
|
||||
|
||||
async deleteRecentMention(mention: RecentMention): Promise<void> {
|
||||
return this.recentMentionRepository.deleteRecentMention(mention);
|
||||
}
|
||||
|
||||
async deleteAllRecentMentions(userId: UserID): Promise<void> {
|
||||
return this.recentMentionRepository.deleteAllRecentMentions(userId);
|
||||
}
|
||||
|
||||
async listSavedMessages(userId: UserID, limit: number = 25, before?: MessageID): Promise<Array<SavedMessage>> {
|
||||
return this.savedMessageRepository.listSavedMessages(userId, limit, before);
|
||||
}
|
||||
|
||||
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
|
||||
return this.savedMessageRepository.createSavedMessage(userId, channelId, messageId);
|
||||
}
|
||||
|
||||
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
|
||||
return this.savedMessageRepository.deleteSavedMessage(userId, messageId);
|
||||
}
|
||||
|
||||
async deleteAllSavedMessages(userId: UserID): Promise<void> {
|
||||
return this.savedMessageRepository.deleteAllSavedMessages(userId);
|
||||
}
|
||||
|
||||
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
|
||||
return this.visionarySlotRepository.listVisionarySlots();
|
||||
}
|
||||
|
||||
async expandVisionarySlots(byCount: number): Promise<void> {
|
||||
return this.visionarySlotRepository.expandVisionarySlots(byCount);
|
||||
}
|
||||
|
||||
async shrinkVisionarySlots(toCount: number): Promise<void> {
|
||||
return this.visionarySlotRepository.shrinkVisionarySlots(toCount);
|
||||
}
|
||||
|
||||
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
return this.visionarySlotRepository.reserveVisionarySlot(slotIndex, userId);
|
||||
}
|
||||
|
||||
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
return this.visionarySlotRepository.unreserveVisionarySlot(slotIndex, userId);
|
||||
}
|
||||
}
|
||||
215
fluxer_api/src/user/repositories/UserRelationshipRepository.ts
Normal file
215
fluxer_api/src/user/repositories/UserRelationshipRepository.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, deleteOneOrMany, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {NoteRow, RelationshipRow} from '~/database/CassandraTypes';
|
||||
import {Relationship, UserNote} from '~/Models';
|
||||
import {Notes, Relationships} from '~/Tables';
|
||||
import type {IUserRelationshipRepository} from './IUserRelationshipRepository';
|
||||
|
||||
const FETCH_ALL_NOTES_CQL = Notes.selectCql({
|
||||
where: Notes.where.eq('source_user_id'),
|
||||
});
|
||||
|
||||
const FETCH_NOTE_CQL = Notes.selectCql({
|
||||
where: [Notes.where.eq('source_user_id'), Notes.where.eq('target_user_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_RELATIONSHIPS_CQL = Relationships.selectCql({
|
||||
where: Relationships.where.eq('source_user_id'),
|
||||
});
|
||||
|
||||
const FETCH_RELATIONSHIP_CQL = Relationships.selectCql({
|
||||
where: [
|
||||
Relationships.where.eq('source_user_id'),
|
||||
Relationships.where.eq('target_user_id'),
|
||||
Relationships.where.eq('type'),
|
||||
],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_ALL_NOTES_FOR_DELETE_QUERY = Notes.selectCql({
|
||||
columns: ['source_user_id', 'target_user_id'],
|
||||
limit: 10000,
|
||||
});
|
||||
|
||||
const FETCH_RELATIONSHIPS_FOR_DELETE_CQL = Relationships.selectCql({
|
||||
columns: ['source_user_id', 'target_user_id', 'type'],
|
||||
where: Relationships.where.eq('source_user_id', 'user_id'),
|
||||
});
|
||||
|
||||
export class UserRelationshipRepository implements IUserRelationshipRepository {
|
||||
async clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
Notes.deleteByPk({
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAllNotes(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
Notes.deleteCql({
|
||||
where: Notes.where.eq('source_user_id', 'user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
|
||||
const allNotes = await fetchMany<{source_user_id: bigint; target_user_id: bigint}>(
|
||||
FETCH_ALL_NOTES_FOR_DELETE_QUERY,
|
||||
{},
|
||||
);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const note of allNotes) {
|
||||
if (note.target_user_id === BigInt(userId)) {
|
||||
batch.addPrepared(
|
||||
Notes.deleteByPk({
|
||||
source_user_id: createUserID(note.source_user_id),
|
||||
target_user_id: createUserID(note.target_user_id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (batch) {
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAllRelationships(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
Relationships.deleteCql({
|
||||
where: Relationships.where.eq('source_user_id', 'user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
|
||||
const relationships = await fetchMany<{source_user_id: bigint; target_user_id: bigint; type: number}>(
|
||||
FETCH_RELATIONSHIPS_FOR_DELETE_CQL,
|
||||
{user_id: userId},
|
||||
);
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const rel of relationships) {
|
||||
if (rel.target_user_id === BigInt(userId)) {
|
||||
batch.addPrepared(
|
||||
Relationships.deleteByPk({
|
||||
source_user_id: createUserID(rel.source_user_id),
|
||||
target_user_id: createUserID(rel.target_user_id),
|
||||
type: rel.type,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
Relationships.deleteByPk({
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
type,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null> {
|
||||
const relationship = await fetchOne<RelationshipRow>(FETCH_RELATIONSHIP_CQL, {
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
type,
|
||||
});
|
||||
return relationship ? new Relationship(relationship) : null;
|
||||
}
|
||||
|
||||
async getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null> {
|
||||
const note = await fetchOne<NoteRow>(FETCH_NOTE_CQL, {
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
});
|
||||
return note ? new UserNote(note) : null;
|
||||
}
|
||||
|
||||
async getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>> {
|
||||
const notes = await fetchMany<NoteRow>(FETCH_ALL_NOTES_CQL, {source_user_id: sourceUserId});
|
||||
const noteMap = new Map<UserID, string>();
|
||||
for (const note of notes) {
|
||||
noteMap.set(note.target_user_id, note.note);
|
||||
}
|
||||
return noteMap;
|
||||
}
|
||||
|
||||
async listRelationships(sourceUserId: UserID): Promise<Array<Relationship>> {
|
||||
const relationships = await fetchMany<RelationshipRow>(FETCH_RELATIONSHIPS_CQL, {
|
||||
source_user_id: sourceUserId,
|
||||
});
|
||||
return relationships.map((rel) => new Relationship(rel));
|
||||
}
|
||||
|
||||
async upsertRelationship(relationship: RelationshipRow): Promise<Relationship> {
|
||||
const result = await executeVersionedUpdate<RelationshipRow, 'source_user_id' | 'target_user_id' | 'type'>(
|
||||
() =>
|
||||
fetchOne(FETCH_RELATIONSHIP_CQL, {
|
||||
source_user_id: relationship.source_user_id,
|
||||
target_user_id: relationship.target_user_id,
|
||||
type: relationship.type,
|
||||
}),
|
||||
(current) => ({
|
||||
pk: {
|
||||
source_user_id: relationship.source_user_id,
|
||||
target_user_id: relationship.target_user_id,
|
||||
type: relationship.type,
|
||||
},
|
||||
patch: {
|
||||
nickname: Db.set(relationship.nickname),
|
||||
since: Db.set(relationship.since),
|
||||
version: Db.set((current?.version ?? 0) + 1),
|
||||
},
|
||||
}),
|
||||
Relationships,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
return new Relationship({
|
||||
...relationship,
|
||||
version: result.finalVersion ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
async upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote> {
|
||||
const result = await executeVersionedUpdate<NoteRow, 'source_user_id' | 'target_user_id'>(
|
||||
() => fetchOne(FETCH_NOTE_CQL, {source_user_id: sourceUserId, target_user_id: targetUserId}),
|
||||
(current) => ({
|
||||
pk: {source_user_id: sourceUserId, target_user_id: targetUserId},
|
||||
patch: {note: Db.set(note), version: Db.set((current?.version ?? 0) + 1)},
|
||||
}),
|
||||
Notes,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
return new UserNote({
|
||||
source_user_id: sourceUserId,
|
||||
target_user_id: targetUserId,
|
||||
note,
|
||||
version: result.finalVersion ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
660
fluxer_api/src/user/repositories/UserRepository.ts
Normal file
660
fluxer_api/src/user/repositories/UserRepository.ts
Normal file
@@ -0,0 +1,660 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {ChannelID, GuildID, MessageID, PhoneVerificationToken, UserID} from '~/BrandedTypes';
|
||||
import type {
|
||||
AuthSessionRow,
|
||||
BetaCodeRow,
|
||||
EmailRevertTokenRow,
|
||||
EmailVerificationTokenRow,
|
||||
GiftCodeRow,
|
||||
PasswordResetTokenRow,
|
||||
PaymentBySubscriptionRow,
|
||||
PaymentRow,
|
||||
PhoneTokenRow,
|
||||
PushSubscriptionRow,
|
||||
RecentMentionRow,
|
||||
RelationshipRow,
|
||||
UserGuildSettingsRow,
|
||||
UserRow,
|
||||
UserSettingsRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import type {
|
||||
AuthSession,
|
||||
BetaCode,
|
||||
Channel,
|
||||
EmailRevertToken,
|
||||
EmailVerificationToken,
|
||||
GiftCode,
|
||||
MfaBackupCode,
|
||||
PasswordResetToken,
|
||||
Payment,
|
||||
PushSubscription,
|
||||
ReadState,
|
||||
RecentMention,
|
||||
Relationship,
|
||||
SavedMessage,
|
||||
User,
|
||||
UserGuildSettings,
|
||||
UserNote,
|
||||
UserSettings,
|
||||
VisionarySlot,
|
||||
WebAuthnCredential,
|
||||
} from '~/Models';
|
||||
import {ReadStateRepository} from '~/read_state/ReadStateRepository';
|
||||
import type {PrivateChannelSummary} from './IUserChannelRepository';
|
||||
import type {IUserRepositoryAggregate} from './IUserRepositoryAggregate';
|
||||
import {UserAccountRepository} from './UserAccountRepository';
|
||||
import {UserAuthRepository} from './UserAuthRepository';
|
||||
import {UserChannelRepository} from './UserChannelRepository';
|
||||
import {UserContentRepository} from './UserContentRepository';
|
||||
import {UserRelationshipRepository} from './UserRelationshipRepository';
|
||||
import {UserSettingsRepository} from './UserSettingsRepository';
|
||||
|
||||
export class UserRepository implements IUserRepositoryAggregate {
|
||||
private accountRepo: UserAccountRepository;
|
||||
private settingsRepo: UserSettingsRepository;
|
||||
private authRepo: UserAuthRepository;
|
||||
private relationshipRepo: UserRelationshipRepository;
|
||||
private channelRepo: UserChannelRepository;
|
||||
private contentRepo: UserContentRepository;
|
||||
private readStateRepo: ReadStateRepository;
|
||||
|
||||
constructor() {
|
||||
this.accountRepo = new UserAccountRepository();
|
||||
this.settingsRepo = new UserSettingsRepository();
|
||||
this.authRepo = new UserAuthRepository(this.accountRepo);
|
||||
this.relationshipRepo = new UserRelationshipRepository();
|
||||
this.channelRepo = new UserChannelRepository();
|
||||
this.contentRepo = new UserContentRepository();
|
||||
this.readStateRepo = new ReadStateRepository();
|
||||
}
|
||||
|
||||
async create(data: UserRow): Promise<User> {
|
||||
return this.accountRepo.create(data);
|
||||
}
|
||||
|
||||
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
|
||||
return this.accountRepo.upsert(data, oldData);
|
||||
}
|
||||
|
||||
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User | null> {
|
||||
return this.accountRepo.patchUpsert(userId, patchData, oldData);
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return this.accountRepo.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return this.accountRepo.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
|
||||
return this.accountRepo.findByUsernameDiscriminator(username, discriminator);
|
||||
}
|
||||
|
||||
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
|
||||
return this.accountRepo.findDiscriminatorsByUsername(username);
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.accountRepo.findByEmail(email);
|
||||
}
|
||||
|
||||
async findByPhone(phone: string): Promise<User | null> {
|
||||
return this.accountRepo.findByPhone(phone);
|
||||
}
|
||||
|
||||
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
|
||||
return this.accountRepo.findByStripeSubscriptionId(stripeSubscriptionId);
|
||||
}
|
||||
|
||||
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
|
||||
return this.accountRepo.findByStripeCustomerId(stripeCustomerId);
|
||||
}
|
||||
|
||||
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
|
||||
return this.accountRepo.listUsers(userIds);
|
||||
}
|
||||
|
||||
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
|
||||
return this.accountRepo.listAllUsersPaginated(limit, lastUserId);
|
||||
}
|
||||
|
||||
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
|
||||
return this.accountRepo.getUserGuildIds(userId);
|
||||
}
|
||||
|
||||
async getActivityTracking(userId: UserID): Promise<{last_active_at: Date | null; last_active_ip: string | null}> {
|
||||
return this.accountRepo.getActivityTracking(userId);
|
||||
}
|
||||
|
||||
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.accountRepo.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
|
||||
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
|
||||
return this.accountRepo.removePendingDeletion(userId, pendingDeletionAt);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
|
||||
return this.accountRepo.findUsersPendingDeletion(now);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletionByDate(
|
||||
deletionDate: string,
|
||||
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
|
||||
return this.accountRepo.findUsersPendingDeletionByDate(deletionDate);
|
||||
}
|
||||
|
||||
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
|
||||
return this.accountRepo.isUserPendingDeletion(userId, deletionDate);
|
||||
}
|
||||
|
||||
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.accountRepo.scheduleDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
|
||||
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
|
||||
return this.accountRepo.deleteUserSecondaryIndices(userId);
|
||||
}
|
||||
|
||||
async removeFromAllGuilds(userId: UserID): Promise<void> {
|
||||
return this.accountRepo.removeFromAllGuilds(userId);
|
||||
}
|
||||
|
||||
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
|
||||
return this.accountRepo.updateLastActiveAt(params);
|
||||
}
|
||||
|
||||
async findSettings(userId: UserID): Promise<UserSettings | null> {
|
||||
return this.settingsRepo.findSettings(userId);
|
||||
}
|
||||
|
||||
async upsertSettings(settings: UserSettingsRow): Promise<UserSettings> {
|
||||
return this.settingsRepo.upsertSettings(settings);
|
||||
}
|
||||
|
||||
async deleteUserSettings(userId: UserID): Promise<void> {
|
||||
return this.settingsRepo.deleteUserSettings(userId);
|
||||
}
|
||||
|
||||
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
|
||||
return this.settingsRepo.findGuildSettings(userId, guildId);
|
||||
}
|
||||
|
||||
async findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>> {
|
||||
return this.settingsRepo.findAllGuildSettings(userId);
|
||||
}
|
||||
|
||||
async upsertGuildSettings(settings: UserGuildSettingsRow): Promise<UserGuildSettings> {
|
||||
return this.settingsRepo.upsertGuildSettings(settings);
|
||||
}
|
||||
|
||||
async deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void> {
|
||||
return this.settingsRepo.deleteGuildSettings(userId, guildId);
|
||||
}
|
||||
|
||||
async deleteAllUserGuildSettings(userId: UserID): Promise<void> {
|
||||
return this.settingsRepo.deleteAllUserGuildSettings(userId);
|
||||
}
|
||||
|
||||
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
|
||||
return this.authRepo.listAuthSessions(userId);
|
||||
}
|
||||
|
||||
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
|
||||
return this.authRepo.getAuthSessionByToken(sessionIdHash);
|
||||
}
|
||||
|
||||
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
|
||||
return this.authRepo.createAuthSession(sessionData);
|
||||
}
|
||||
|
||||
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
|
||||
return this.authRepo.updateAuthSessionLastUsed(sessionIdHash);
|
||||
}
|
||||
|
||||
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
|
||||
return this.authRepo.deleteAuthSessions(userId, sessionIdHashes);
|
||||
}
|
||||
|
||||
async revokeAuthSession(sessionIdHash: Buffer): Promise<void> {
|
||||
return this.authRepo.revokeAuthSession(sessionIdHash);
|
||||
}
|
||||
|
||||
async deleteAllAuthSessions(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deleteAllAuthSessions(userId);
|
||||
}
|
||||
|
||||
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
|
||||
return this.authRepo.listMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
|
||||
return this.authRepo.createMfaBackupCodes(userId, codes);
|
||||
}
|
||||
|
||||
async clearMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
return this.authRepo.clearMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
|
||||
return this.authRepo.consumeMfaBackupCode(userId, code);
|
||||
}
|
||||
|
||||
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deleteAllMfaBackupCodes(userId);
|
||||
}
|
||||
|
||||
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
|
||||
return this.authRepo.getEmailVerificationToken(token);
|
||||
}
|
||||
|
||||
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
|
||||
return this.authRepo.createEmailVerificationToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailVerificationToken(token: string): Promise<void> {
|
||||
return this.authRepo.deleteEmailVerificationToken(token);
|
||||
}
|
||||
|
||||
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
|
||||
return this.authRepo.getPasswordResetToken(token);
|
||||
}
|
||||
|
||||
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
|
||||
return this.authRepo.createPasswordResetToken(tokenData);
|
||||
}
|
||||
|
||||
async deletePasswordResetToken(token: string): Promise<void> {
|
||||
return this.authRepo.deletePasswordResetToken(token);
|
||||
}
|
||||
|
||||
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
|
||||
return this.authRepo.getEmailRevertToken(token);
|
||||
}
|
||||
|
||||
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
|
||||
return this.authRepo.createEmailRevertToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailRevertToken(token: string): Promise<void> {
|
||||
return this.authRepo.deleteEmailRevertToken(token);
|
||||
}
|
||||
|
||||
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
|
||||
return this.authRepo.createPhoneToken(token, phone, userId);
|
||||
}
|
||||
|
||||
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
|
||||
return this.authRepo.getPhoneToken(token);
|
||||
}
|
||||
|
||||
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
|
||||
return this.authRepo.deletePhoneToken(token);
|
||||
}
|
||||
|
||||
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
|
||||
return this.authRepo.updateUserActivity(userId, clientIp);
|
||||
}
|
||||
|
||||
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
|
||||
return this.authRepo.checkIpAuthorized(userId, ip);
|
||||
}
|
||||
|
||||
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
|
||||
return this.authRepo.createAuthorizedIp(userId, ip);
|
||||
}
|
||||
|
||||
async createIpAuthorizationToken(userId: UserID, token: string): Promise<void> {
|
||||
return this.authRepo.createIpAuthorizationToken(userId, token);
|
||||
}
|
||||
|
||||
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
|
||||
return this.authRepo.authorizeIpByToken(token);
|
||||
}
|
||||
|
||||
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
|
||||
return this.authRepo.getAuthorizedIps(userId);
|
||||
}
|
||||
|
||||
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deleteAllAuthorizedIps(userId);
|
||||
}
|
||||
|
||||
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
return this.authRepo.listWebAuthnCredentials(userId);
|
||||
}
|
||||
|
||||
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
|
||||
return this.authRepo.getWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async createWebAuthnCredential(
|
||||
userId: UserID,
|
||||
credentialId: string,
|
||||
publicKey: Buffer,
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
return this.authRepo.createWebAuthnCredential(userId, credentialId, publicKey, counter, transports, name);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
|
||||
return this.authRepo.updateWebAuthnCredentialCounter(userId, credentialId, counter);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.authRepo.updateWebAuthnCredentialLastUsed(userId, credentialId);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
|
||||
return this.authRepo.updateWebAuthnCredentialName(userId, credentialId, name);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
return this.authRepo.deleteWebAuthnCredential(userId, credentialId);
|
||||
}
|
||||
|
||||
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
|
||||
return this.authRepo.getUserIdByCredentialId(credentialId);
|
||||
}
|
||||
|
||||
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deleteAllWebAuthnCredentials(userId);
|
||||
}
|
||||
|
||||
async createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void> {
|
||||
return this.authRepo.createPendingVerification(userId, createdAt, metadata);
|
||||
}
|
||||
|
||||
async deletePendingVerification(userId: UserID): Promise<void> {
|
||||
return this.authRepo.deletePendingVerification(userId);
|
||||
}
|
||||
|
||||
async listRelationships(sourceUserId: UserID): Promise<Array<Relationship>> {
|
||||
return this.relationshipRepo.listRelationships(sourceUserId);
|
||||
}
|
||||
|
||||
async getRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<Relationship | null> {
|
||||
return this.relationshipRepo.getRelationship(sourceUserId, targetUserId, type);
|
||||
}
|
||||
|
||||
async upsertRelationship(relationship: RelationshipRow): Promise<Relationship> {
|
||||
return this.relationshipRepo.upsertRelationship(relationship);
|
||||
}
|
||||
|
||||
async deleteRelationship(sourceUserId: UserID, targetUserId: UserID, type: number): Promise<void> {
|
||||
return this.relationshipRepo.deleteRelationship(sourceUserId, targetUserId, type);
|
||||
}
|
||||
|
||||
async deleteAllRelationships(userId: UserID): Promise<void> {
|
||||
return this.relationshipRepo.deleteAllRelationships(userId);
|
||||
}
|
||||
|
||||
async getUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<UserNote | null> {
|
||||
return this.relationshipRepo.getUserNote(sourceUserId, targetUserId);
|
||||
}
|
||||
|
||||
async getUserNotes(sourceUserId: UserID): Promise<Map<UserID, string>> {
|
||||
return this.relationshipRepo.getUserNotes(sourceUserId);
|
||||
}
|
||||
|
||||
async upsertUserNote(sourceUserId: UserID, targetUserId: UserID, note: string): Promise<UserNote> {
|
||||
return this.relationshipRepo.upsertUserNote(sourceUserId, targetUserId, note);
|
||||
}
|
||||
|
||||
async clearUserNote(sourceUserId: UserID, targetUserId: UserID): Promise<void> {
|
||||
return this.relationshipRepo.clearUserNote(sourceUserId, targetUserId);
|
||||
}
|
||||
|
||||
async deleteAllNotes(userId: UserID): Promise<void> {
|
||||
return this.relationshipRepo.deleteAllNotes(userId);
|
||||
}
|
||||
|
||||
async listPrivateChannels(userId: UserID): Promise<Array<Channel>> {
|
||||
return this.channelRepo.listPrivateChannels(userId);
|
||||
}
|
||||
|
||||
async listPrivateChannelSummaries(userId: UserID): Promise<Array<PrivateChannelSummary>> {
|
||||
return this.channelRepo.listPrivateChannelSummaries(userId);
|
||||
}
|
||||
|
||||
async deleteAllPrivateChannels(userId: UserID): Promise<void> {
|
||||
return this.channelRepo.deleteAllPrivateChannels(userId);
|
||||
}
|
||||
|
||||
async findExistingDmState(user1Id: UserID, user2Id: UserID): Promise<Channel | null> {
|
||||
return this.channelRepo.findExistingDmState(user1Id, user2Id);
|
||||
}
|
||||
|
||||
async createDmChannelAndState(user1Id: UserID, user2Id: UserID, channelId: ChannelID): Promise<Channel> {
|
||||
return this.channelRepo.createDmChannelAndState(user1Id, user2Id, channelId);
|
||||
}
|
||||
|
||||
async isDmChannelOpen(userId: UserID, channelId: ChannelID): Promise<boolean> {
|
||||
return this.channelRepo.isDmChannelOpen(userId, channelId);
|
||||
}
|
||||
|
||||
async openDmForUser(userId: UserID, channelId: ChannelID, isGroupDm?: boolean): Promise<void> {
|
||||
return this.channelRepo.openDmForUser(userId, channelId, isGroupDm);
|
||||
}
|
||||
|
||||
async closeDmForUser(userId: UserID, channelId: ChannelID): Promise<void> {
|
||||
return this.channelRepo.closeDmForUser(userId, channelId);
|
||||
}
|
||||
|
||||
async getPinnedDms(userId: UserID): Promise<Array<ChannelID>> {
|
||||
return this.channelRepo.getPinnedDms(userId);
|
||||
}
|
||||
|
||||
async getPinnedDmsWithDetails(userId: UserID): Promise<Array<{channel_id: ChannelID; sort_order: number}>> {
|
||||
return this.channelRepo.getPinnedDmsWithDetails(userId);
|
||||
}
|
||||
|
||||
async addPinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
|
||||
return this.channelRepo.addPinnedDm(userId, channelId);
|
||||
}
|
||||
|
||||
async removePinnedDm(userId: UserID, channelId: ChannelID): Promise<Array<ChannelID>> {
|
||||
return this.channelRepo.removePinnedDm(userId, channelId);
|
||||
}
|
||||
|
||||
async deletePinnedDmsByUserId(userId: UserID): Promise<void> {
|
||||
return this.channelRepo.deletePinnedDmsByUserId(userId);
|
||||
}
|
||||
|
||||
async deleteAllReadStates(userId: UserID): Promise<void> {
|
||||
return this.channelRepo.deleteAllReadStates(userId);
|
||||
}
|
||||
|
||||
async getReadStates(userId: UserID): Promise<Array<ReadState>> {
|
||||
return this.readStateRepo.listReadStates(userId);
|
||||
}
|
||||
|
||||
async getRecentMention(userId: UserID, messageId: MessageID): Promise<RecentMention | null> {
|
||||
return this.contentRepo.getRecentMention(userId, messageId);
|
||||
}
|
||||
|
||||
async listRecentMentions(
|
||||
userId: UserID,
|
||||
includeEveryone: boolean,
|
||||
includeRole: boolean,
|
||||
includeGuilds: boolean,
|
||||
limit: number,
|
||||
before?: MessageID,
|
||||
): Promise<Array<RecentMention>> {
|
||||
return this.contentRepo.listRecentMentions(userId, includeEveryone, includeRole, includeGuilds, limit, before);
|
||||
}
|
||||
|
||||
async createRecentMention(mention: RecentMentionRow): Promise<RecentMention> {
|
||||
return this.contentRepo.createRecentMention(mention);
|
||||
}
|
||||
|
||||
async createRecentMentions(mentions: Array<RecentMentionRow>): Promise<void> {
|
||||
return this.contentRepo.createRecentMentions(mentions);
|
||||
}
|
||||
|
||||
async deleteRecentMention(mention: RecentMention): Promise<void> {
|
||||
return this.contentRepo.deleteRecentMention(mention);
|
||||
}
|
||||
|
||||
async deleteAllRecentMentions(userId: UserID): Promise<void> {
|
||||
return this.contentRepo.deleteAllRecentMentions(userId);
|
||||
}
|
||||
|
||||
async listSavedMessages(userId: UserID, limit?: number, before?: MessageID): Promise<Array<SavedMessage>> {
|
||||
return this.contentRepo.listSavedMessages(userId, limit, before);
|
||||
}
|
||||
|
||||
async createSavedMessage(userId: UserID, channelId: ChannelID, messageId: MessageID): Promise<SavedMessage> {
|
||||
return this.contentRepo.createSavedMessage(userId, channelId, messageId);
|
||||
}
|
||||
|
||||
async deleteSavedMessage(userId: UserID, messageId: MessageID): Promise<void> {
|
||||
return this.contentRepo.deleteSavedMessage(userId, messageId);
|
||||
}
|
||||
|
||||
async deleteAllSavedMessages(userId: UserID): Promise<void> {
|
||||
return this.contentRepo.deleteAllSavedMessages(userId);
|
||||
}
|
||||
|
||||
async listBetaCodes(creatorId: UserID): Promise<Array<BetaCode>> {
|
||||
return this.contentRepo.listBetaCodes(creatorId);
|
||||
}
|
||||
|
||||
async getBetaCode(code: string): Promise<BetaCode | null> {
|
||||
return this.contentRepo.getBetaCode(code);
|
||||
}
|
||||
|
||||
async upsertBetaCode(betaCode: BetaCodeRow): Promise<BetaCode> {
|
||||
return this.contentRepo.upsertBetaCode(betaCode);
|
||||
}
|
||||
|
||||
async updateBetaCodeRedeemed(code: string, redeemerId: UserID, redeemedAt: Date): Promise<void> {
|
||||
return this.contentRepo.updateBetaCodeRedeemed(code, redeemerId, redeemedAt);
|
||||
}
|
||||
|
||||
async deleteBetaCode(code: string, creatorId: UserID): Promise<void> {
|
||||
return this.contentRepo.deleteBetaCode(code, creatorId);
|
||||
}
|
||||
|
||||
async deleteAllBetaCodes(userId: UserID): Promise<void> {
|
||||
return this.contentRepo.deleteAllBetaCodes(userId);
|
||||
}
|
||||
|
||||
async createGiftCode(data: GiftCodeRow): Promise<void> {
|
||||
return this.contentRepo.createGiftCode(data);
|
||||
}
|
||||
|
||||
async findGiftCode(code: string): Promise<GiftCode | null> {
|
||||
return this.contentRepo.findGiftCode(code);
|
||||
}
|
||||
|
||||
async findGiftCodeByPaymentIntent(paymentIntentId: string): Promise<GiftCode | null> {
|
||||
return this.contentRepo.findGiftCodeByPaymentIntent(paymentIntentId);
|
||||
}
|
||||
|
||||
async findGiftCodesByCreator(userId: UserID): Promise<Array<GiftCode>> {
|
||||
return this.contentRepo.findGiftCodesByCreator(userId);
|
||||
}
|
||||
|
||||
async redeemGiftCode(code: string, userId: UserID): Promise<{applied: boolean}> {
|
||||
return this.contentRepo.redeemGiftCode(code, userId);
|
||||
}
|
||||
|
||||
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
|
||||
return this.contentRepo.updateGiftCode(code, data);
|
||||
}
|
||||
|
||||
async linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void> {
|
||||
return this.contentRepo.linkGiftCodeToCheckoutSession(code, checkoutSessionId);
|
||||
}
|
||||
|
||||
async listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>> {
|
||||
return this.contentRepo.listPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async createPushSubscription(data: PushSubscriptionRow): Promise<PushSubscription> {
|
||||
return this.contentRepo.createPushSubscription(data);
|
||||
}
|
||||
|
||||
async deletePushSubscription(userId: UserID, subscriptionId: string): Promise<void> {
|
||||
return this.contentRepo.deletePushSubscription(userId, subscriptionId);
|
||||
}
|
||||
|
||||
async getBulkPushSubscriptions(userIds: Array<UserID>): Promise<Map<UserID, Array<PushSubscription>>> {
|
||||
return this.contentRepo.getBulkPushSubscriptions(userIds);
|
||||
}
|
||||
|
||||
async deleteAllPushSubscriptions(userId: UserID): Promise<void> {
|
||||
return this.contentRepo.deleteAllPushSubscriptions(userId);
|
||||
}
|
||||
|
||||
async createPayment(data: {
|
||||
checkout_session_id: string;
|
||||
user_id: UserID;
|
||||
price_id: string;
|
||||
product_type: string;
|
||||
status: string;
|
||||
is_gift: boolean;
|
||||
created_at: Date;
|
||||
}): Promise<void> {
|
||||
return this.contentRepo.createPayment(data);
|
||||
}
|
||||
|
||||
async updatePayment(data: Partial<PaymentRow> & {checkout_session_id: string}): Promise<{applied: boolean}> {
|
||||
return this.contentRepo.updatePayment(data);
|
||||
}
|
||||
|
||||
async getPaymentByCheckoutSession(checkoutSessionId: string): Promise<Payment | null> {
|
||||
return this.contentRepo.getPaymentByCheckoutSession(checkoutSessionId);
|
||||
}
|
||||
|
||||
async getPaymentByPaymentIntent(paymentIntentId: string): Promise<Payment | null> {
|
||||
return this.contentRepo.getPaymentByPaymentIntent(paymentIntentId);
|
||||
}
|
||||
|
||||
async getSubscriptionInfo(subscriptionId: string): Promise<PaymentBySubscriptionRow | null> {
|
||||
return this.contentRepo.getSubscriptionInfo(subscriptionId);
|
||||
}
|
||||
|
||||
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
|
||||
return this.contentRepo.listVisionarySlots();
|
||||
}
|
||||
|
||||
async expandVisionarySlots(byCount: number): Promise<void> {
|
||||
return this.contentRepo.expandVisionarySlots(byCount);
|
||||
}
|
||||
|
||||
async shrinkVisionarySlots(toCount: number): Promise<void> {
|
||||
return this.contentRepo.shrinkVisionarySlots(toCount);
|
||||
}
|
||||
|
||||
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
return this.contentRepo.reserveVisionarySlot(slotIndex, userId);
|
||||
}
|
||||
|
||||
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
return this.contentRepo.unreserveVisionarySlot(slotIndex, userId);
|
||||
}
|
||||
}
|
||||
117
fluxer_api/src/user/repositories/UserSettingsRepository.ts
Normal file
117
fluxer_api/src/user/repositories/UserSettingsRepository.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '~/BrandedTypes';
|
||||
import {buildPatchFromData, deleteOneOrMany, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {UserGuildSettingsRow, UserSettingsRow} from '~/database/CassandraTypes';
|
||||
import {USER_GUILD_SETTINGS_COLUMNS, USER_SETTINGS_COLUMNS} from '~/database/types/UserTypes';
|
||||
import {UserGuildSettings, UserSettings} from '~/Models';
|
||||
import {UserGuildSettings as UserGuildSettingsTable, UserSettings as UserSettingsTable} from '~/Tables';
|
||||
import type {IUserSettingsRepository} from './IUserSettingsRepository';
|
||||
|
||||
const FETCH_USER_SETTINGS_CQL = UserSettingsTable.selectCql({
|
||||
where: UserSettingsTable.where.eq('user_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_GUILD_SETTINGS_CQL = UserGuildSettingsTable.selectCql({
|
||||
where: [UserGuildSettingsTable.where.eq('user_id'), UserGuildSettingsTable.where.eq('guild_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_ALL_USER_GUILD_SETTINGS_CQL = UserGuildSettingsTable.selectCql({
|
||||
where: UserGuildSettingsTable.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class UserSettingsRepository implements IUserSettingsRepository {
|
||||
async deleteAllUserGuildSettings(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
UserGuildSettingsTable.deleteCql({
|
||||
where: UserGuildSettingsTable.where.eq('user_id'),
|
||||
}),
|
||||
{user_id: userId},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteGuildSettings(userId: UserID, guildId: GuildID): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
UserGuildSettingsTable.deleteByPk({
|
||||
user_id: userId,
|
||||
guild_id: guildId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteUserSettings(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(UserSettingsTable.deleteByPk({user_id: userId}));
|
||||
}
|
||||
|
||||
async findGuildSettings(userId: UserID, guildId: GuildID | null): Promise<UserGuildSettings | null> {
|
||||
const settings = await fetchOne<UserGuildSettingsRow>(FETCH_USER_GUILD_SETTINGS_CQL, {
|
||||
user_id: userId,
|
||||
guild_id: guildId ? guildId : 0n,
|
||||
});
|
||||
return settings ? new UserGuildSettings(settings) : null;
|
||||
}
|
||||
|
||||
async findAllGuildSettings(userId: UserID): Promise<Array<UserGuildSettings>> {
|
||||
const rows = await fetchMany<UserGuildSettingsRow>(FETCH_ALL_USER_GUILD_SETTINGS_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
return rows.map((row) => new UserGuildSettings(row));
|
||||
}
|
||||
|
||||
async findSettings(userId: UserID): Promise<UserSettings | null> {
|
||||
const settings = await fetchOne<UserSettingsRow>(FETCH_USER_SETTINGS_CQL, {user_id: userId});
|
||||
return settings ? new UserSettings(settings) : null;
|
||||
}
|
||||
|
||||
async upsertGuildSettings(settings: UserGuildSettingsRow): Promise<UserGuildSettings> {
|
||||
const userId = settings.user_id;
|
||||
const guildId = settings.guild_id;
|
||||
|
||||
const result = await executeVersionedUpdate<UserGuildSettingsRow, 'user_id' | 'guild_id'>(
|
||||
() => this.findGuildSettings(userId, guildId).then((s) => s?.toRow() ?? null),
|
||||
(current) => ({
|
||||
pk: {user_id: userId, guild_id: guildId},
|
||||
patch: buildPatchFromData(settings, current, USER_GUILD_SETTINGS_COLUMNS, ['user_id', 'guild_id']),
|
||||
}),
|
||||
UserGuildSettingsTable,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
return new UserGuildSettings({...settings, version: result.finalVersion ?? 1});
|
||||
}
|
||||
|
||||
async upsertSettings(settings: UserSettingsRow): Promise<UserSettings> {
|
||||
const userId = settings.user_id;
|
||||
|
||||
const result = await executeVersionedUpdate<UserSettingsRow, 'user_id'>(
|
||||
() => this.findSettings(userId).then((s) => s?.toRow() ?? null),
|
||||
(current) => ({
|
||||
pk: {user_id: userId},
|
||||
patch: buildPatchFromData(settings, current, USER_SETTINGS_COLUMNS, ['user_id']),
|
||||
}),
|
||||
UserSettingsTable,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
return new UserSettings({...settings, version: result.finalVersion ?? 1});
|
||||
}
|
||||
}
|
||||
114
fluxer_api/src/user/repositories/VisionarySlotRepository.ts
Normal file
114
fluxer_api/src/user/repositories/VisionarySlotRepository.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {VisionarySlotRow} from '~/database/CassandraTypes';
|
||||
import {CannotShrinkReservedSlotsError} from '~/Errors';
|
||||
import {VisionarySlot} from '~/Models';
|
||||
import {VisionarySlots} from '~/Tables';
|
||||
|
||||
const FETCH_ALL_VISIONARY_SLOTS_QUERY = VisionarySlots.selectCql();
|
||||
|
||||
const FETCH_VISIONARY_SLOT_QUERY = VisionarySlots.selectCql({
|
||||
where: VisionarySlots.where.eq('slot_index'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class VisionarySlotRepository {
|
||||
async listVisionarySlots(): Promise<Array<VisionarySlot>> {
|
||||
const slots = await fetchMany<VisionarySlotRow>(FETCH_ALL_VISIONARY_SLOTS_QUERY, {});
|
||||
return slots.map((slot) => new VisionarySlot(slot));
|
||||
}
|
||||
|
||||
async expandVisionarySlots(byCount: number): Promise<void> {
|
||||
const existingSlots = await this.listVisionarySlots();
|
||||
const maxSlotIndex = existingSlots.length > 0 ? Math.max(...existingSlots.map((s) => s.slotIndex)) : -1;
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (let i = 1; i <= byCount; i++) {
|
||||
const newSlotIndex = maxSlotIndex + i;
|
||||
batch.addPrepared(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: newSlotIndex,
|
||||
user_id: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async shrinkVisionarySlots(toCount: number): Promise<void> {
|
||||
const existingSlots = await this.listVisionarySlots();
|
||||
if (existingSlots.length <= toCount) return;
|
||||
|
||||
const sortedSlots = existingSlots.sort((a, b) => b.slotIndex - a.slotIndex);
|
||||
const slotsToRemove = sortedSlots.slice(0, existingSlots.length - toCount);
|
||||
|
||||
const reservedSlots = slotsToRemove.filter((slot) => slot.userId !== null);
|
||||
if (reservedSlots.length > 0) {
|
||||
throw new CannotShrinkReservedSlotsError(reservedSlots.map((s) => s.slotIndex));
|
||||
}
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const slot of slotsToRemove) {
|
||||
batch.addPrepared(VisionarySlots.deleteByPk({slot_index: slot.slotIndex}));
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async reserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
const existingSlot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
|
||||
slot_index: slotIndex,
|
||||
});
|
||||
|
||||
if (!existingSlot) {
|
||||
await upsertOne(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: slotIndex,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
await upsertOne(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: slotIndex,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async unreserveVisionarySlot(slotIndex: number, userId: UserID): Promise<void> {
|
||||
const existingSlot = await fetchOne<VisionarySlotRow>(FETCH_VISIONARY_SLOT_QUERY, {
|
||||
slot_index: slotIndex,
|
||||
});
|
||||
|
||||
if (!existingSlot || existingSlot.user_id !== userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertOne(
|
||||
VisionarySlots.upsertAll({
|
||||
slot_index: slotIndex,
|
||||
user_id: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {Db} from '~/database/Cassandra';
|
||||
import type {UserRow} from '~/database/CassandraTypes';
|
||||
import {User} from '~/Models';
|
||||
import {UserDataRepository} from './crud/UserDataRepository';
|
||||
import {UserIndexRepository} from './crud/UserIndexRepository';
|
||||
import {UserSearchRepository} from './crud/UserSearchRepository';
|
||||
|
||||
export class UserAccountRepository {
|
||||
private dataRepo: UserDataRepository;
|
||||
private indexRepo: UserIndexRepository;
|
||||
private searchRepo: UserSearchRepository;
|
||||
|
||||
constructor() {
|
||||
this.dataRepo = new UserDataRepository();
|
||||
this.indexRepo = new UserIndexRepository();
|
||||
this.searchRepo = new UserSearchRepository();
|
||||
}
|
||||
|
||||
async create(data: UserRow): Promise<User> {
|
||||
return this.upsert(data);
|
||||
}
|
||||
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
return this.dataRepo.findUnique(userId);
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return this.dataRepo.findUniqueAssert(userId);
|
||||
}
|
||||
|
||||
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
|
||||
return this.dataRepo.listAllUsersPaginated(limit, lastUserId);
|
||||
}
|
||||
|
||||
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
|
||||
return this.dataRepo.listUsers(userIds);
|
||||
}
|
||||
|
||||
async upsert(data: UserRow, oldData?: UserRow | null): Promise<User> {
|
||||
const result = await this.dataRepo.upsertUserRow(data, oldData);
|
||||
|
||||
if (result.finalVersion === null) {
|
||||
throw new Error(`Failed to update user ${data.user_id} after max retries due to concurrent updates`);
|
||||
}
|
||||
|
||||
await this.indexRepo.syncIndices(data, oldData);
|
||||
const user = new User(data);
|
||||
await this.searchRepo.indexUser(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
async patchUpsert(userId: UserID, patchData: Partial<UserRow>, oldData?: UserRow | null): Promise<User | null> {
|
||||
if (!oldData) {
|
||||
const existingUser = await this.findUnique(userId);
|
||||
if (!existingUser) return null;
|
||||
oldData = existingUser.toRow();
|
||||
}
|
||||
|
||||
const newData: UserRow = {...oldData, ...patchData, user_id: userId};
|
||||
|
||||
const userPatch: Record<string, ReturnType<typeof Db.set> | ReturnType<typeof Db.clear>> = {};
|
||||
for (const [key, value] of Object.entries(patchData)) {
|
||||
if (key === 'user_id') continue;
|
||||
if (value === undefined) continue;
|
||||
|
||||
const userRowKey = key as keyof UserRow;
|
||||
|
||||
if (value === null) {
|
||||
const oldVal = oldData?.[userRowKey];
|
||||
if (oldVal !== null && oldVal !== undefined) {
|
||||
userPatch[key] = Db.clear();
|
||||
}
|
||||
} else {
|
||||
userPatch[key] = Db.set(value);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.dataRepo.patchUser(userId, userPatch);
|
||||
|
||||
if (result.finalVersion === null) {
|
||||
throw new Error(`Failed to patch user ${userId} after max retries due to concurrent updates`);
|
||||
}
|
||||
|
||||
await this.indexRepo.syncIndices(newData, oldData);
|
||||
|
||||
const user = new User(newData);
|
||||
await this.searchRepo.indexUser(user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async deleteUserSecondaryIndices(userId: UserID): Promise<void> {
|
||||
const user = await this.findUnique(userId);
|
||||
if (!user) return;
|
||||
|
||||
await this.indexRepo.deleteIndices(
|
||||
userId,
|
||||
user.username,
|
||||
user.discriminator,
|
||||
user.email,
|
||||
user.phone,
|
||||
user.stripeSubscriptionId,
|
||||
);
|
||||
}
|
||||
|
||||
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
|
||||
await this.dataRepo.updateLastActiveAt(params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchMany, upsertOne} from '~/database/Cassandra';
|
||||
import type {User} from '~/Models';
|
||||
import {UsersPendingDeletion} from '~/Tables';
|
||||
|
||||
const FETCH_USERS_PENDING_DELETION_BY_DATE_CQL = UsersPendingDeletion.selectCql({
|
||||
columns: ['user_id', 'pending_deletion_at'],
|
||||
where: [UsersPendingDeletion.where.eq('deletion_date'), UsersPendingDeletion.where.lte('pending_deletion_at', 'now')],
|
||||
});
|
||||
|
||||
const FETCH_USERS_PENDING_DELETION_BY_DATE_ALL_CQL = UsersPendingDeletion.selectCql({
|
||||
columns: ['user_id', 'deletion_reason_code'],
|
||||
where: UsersPendingDeletion.where.eq('deletion_date'),
|
||||
});
|
||||
|
||||
const FETCH_USER_PENDING_DELETION_CHECK_CQL = UsersPendingDeletion.selectCql({
|
||||
columns: ['user_id'],
|
||||
where: [UsersPendingDeletion.where.eq('deletion_date'), UsersPendingDeletion.where.eq('user_id')],
|
||||
});
|
||||
|
||||
export class UserDeletionRepository {
|
||||
constructor(private findUniqueUser: (userId: UserID) => Promise<User | null>) {}
|
||||
|
||||
async addPendingDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
const deletionDate = pendingDeletionAt.toISOString().split('T')[0];
|
||||
|
||||
await upsertOne(
|
||||
UsersPendingDeletion.upsertAll({
|
||||
deletion_date: deletionDate,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
user_id: userId,
|
||||
deletion_reason_code: deletionReasonCode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async findUsersPendingDeletion(now: Date): Promise<Array<User>> {
|
||||
const userIds = new Set<bigint>();
|
||||
|
||||
const startDate = new Date(now);
|
||||
startDate.setDate(startDate.getDate() - 30);
|
||||
|
||||
const currentDate = new Date(startDate);
|
||||
while (currentDate <= now) {
|
||||
const deletionDate = currentDate.toISOString().split('T')[0];
|
||||
|
||||
const rows = await fetchMany<{user_id: bigint; pending_deletion_at: Date}>(
|
||||
FETCH_USERS_PENDING_DELETION_BY_DATE_CQL,
|
||||
{
|
||||
deletion_date: deletionDate,
|
||||
now,
|
||||
},
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
userIds.add(row.user_id);
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const users: Array<User> = [];
|
||||
for (const userId of userIds) {
|
||||
const user = await this.findUniqueUser(createUserID(userId));
|
||||
if (user?.pendingDeletionAt && user.pendingDeletionAt <= now) {
|
||||
users.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
return users;
|
||||
}
|
||||
|
||||
async findUsersPendingDeletionByDate(
|
||||
deletionDate: string,
|
||||
): Promise<Array<{user_id: bigint; deletion_reason_code: number}>> {
|
||||
const rows = await fetchMany<{user_id: bigint; deletion_reason_code: number}>(
|
||||
FETCH_USERS_PENDING_DELETION_BY_DATE_ALL_CQL,
|
||||
{deletion_date: deletionDate},
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async isUserPendingDeletion(userId: UserID, deletionDate: string): Promise<boolean> {
|
||||
const rows = await fetchMany<{user_id: bigint}>(FETCH_USER_PENDING_DELETION_CHECK_CQL, {
|
||||
deletion_date: deletionDate,
|
||||
user_id: userId,
|
||||
});
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async removePendingDeletion(userId: UserID, pendingDeletionAt: Date): Promise<void> {
|
||||
const deletionDate = pendingDeletionAt.toISOString().split('T')[0];
|
||||
|
||||
await deleteOneOrMany(
|
||||
UsersPendingDeletion.deleteByPk({
|
||||
deletion_date: deletionDate,
|
||||
pending_deletion_at: pendingDeletionAt,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async scheduleDeletion(userId: UserID, pendingDeletionAt: Date, deletionReasonCode: number): Promise<void> {
|
||||
return this.addPendingDeletion(userId, pendingDeletionAt, deletionReasonCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, fetchMany} from '~/database/Cassandra';
|
||||
import type {GuildMemberByUserIdRow} from '~/database/CassandraTypes';
|
||||
import {GuildMembers, GuildMembersByUserId} from '~/Tables';
|
||||
|
||||
const FETCH_GUILD_MEMBERS_BY_USER_CQL = GuildMembersByUserId.selectCql({
|
||||
where: GuildMembersByUserId.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class UserGuildRepository {
|
||||
async getUserGuildIds(userId: UserID): Promise<Array<GuildID>> {
|
||||
const guilds = await fetchMany<GuildMemberByUserIdRow>(FETCH_GUILD_MEMBERS_BY_USER_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
return guilds.map((g) => g.guild_id);
|
||||
}
|
||||
|
||||
async removeFromAllGuilds(userId: UserID): Promise<void> {
|
||||
const guilds = await fetchMany<GuildMemberByUserIdRow>(FETCH_GUILD_MEMBERS_BY_USER_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const guild of guilds) {
|
||||
batch.addPrepared(
|
||||
GuildMembers.deleteByPk({
|
||||
guild_id: guild.guild_id,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildMembersByUserId.deleteByPk({
|
||||
user_id: userId,
|
||||
guild_id: guild.guild_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
117
fluxer_api/src/user/repositories/account/UserLookupRepository.ts
Normal file
117
fluxer_api/src/user/repositories/account/UserLookupRepository.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import type {
|
||||
UserByEmailRow,
|
||||
UserByPhoneRow,
|
||||
UserByStripeCustomerIdRow,
|
||||
UserByStripeSubscriptionIdRow,
|
||||
UserByUsernameRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import type {User} from '~/Models';
|
||||
import {UserByEmail, UserByPhone, UserByStripeCustomerId, UserByStripeSubscriptionId, UserByUsername} from '~/Tables';
|
||||
|
||||
const FETCH_DISCRIMINATORS_BY_USERNAME_QUERY = UserByUsername.select({
|
||||
columns: ['discriminator', 'user_id'],
|
||||
where: UserByUsername.where.eq('username'),
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_EMAIL_QUERY = UserByEmail.select({
|
||||
columns: ['user_id'],
|
||||
where: UserByEmail.where.eq('email_lower'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_PHONE_QUERY = UserByPhone.select({
|
||||
columns: ['user_id'],
|
||||
where: UserByPhone.where.eq('phone'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_STRIPE_CUSTOMER_ID_QUERY = UserByStripeCustomerId.select({
|
||||
columns: ['user_id'],
|
||||
where: UserByStripeCustomerId.where.eq('stripe_customer_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_STRIPE_SUBSCRIPTION_ID_QUERY = UserByStripeSubscriptionId.select({
|
||||
columns: ['user_id'],
|
||||
where: UserByStripeSubscriptionId.where.eq('stripe_subscription_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_USER_ID_BY_USERNAME_DISCRIMINATOR_QUERY = UserByUsername.select({
|
||||
columns: ['user_id'],
|
||||
where: [UserByUsername.where.eq('username'), UserByUsername.where.eq('discriminator')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class UserLookupRepository {
|
||||
constructor(private findUniqueUser: (userId: UserID) => Promise<User | null>) {}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
const emailLower = email.toLowerCase();
|
||||
const result = await fetchOne<Pick<UserByEmailRow, 'user_id'>>(
|
||||
FETCH_USER_ID_BY_EMAIL_QUERY.bind({email_lower: emailLower}),
|
||||
);
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findByPhone(phone: string): Promise<User | null> {
|
||||
const result = await fetchOne<Pick<UserByPhoneRow, 'user_id'>>(FETCH_USER_ID_BY_PHONE_QUERY.bind({phone}));
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
|
||||
const result = await fetchOne<Pick<UserByStripeCustomerIdRow, 'user_id'>>(
|
||||
FETCH_USER_ID_BY_STRIPE_CUSTOMER_ID_QUERY.bind({stripe_customer_id: stripeCustomerId}),
|
||||
);
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findByStripeSubscriptionId(stripeSubscriptionId: string): Promise<User | null> {
|
||||
const result = await fetchOne<Pick<UserByStripeSubscriptionIdRow, 'user_id'>>(
|
||||
FETCH_USER_ID_BY_STRIPE_SUBSCRIPTION_ID_QUERY.bind({stripe_subscription_id: stripeSubscriptionId}),
|
||||
);
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findByUsernameDiscriminator(username: string, discriminator: number): Promise<User | null> {
|
||||
const usernameLower = username.toLowerCase();
|
||||
const result = await fetchOne<Pick<UserByUsernameRow, 'user_id'>>(
|
||||
FETCH_USER_ID_BY_USERNAME_DISCRIMINATOR_QUERY.bind({username: usernameLower, discriminator}),
|
||||
);
|
||||
if (!result) return null;
|
||||
return await this.findUniqueUser(result.user_id);
|
||||
}
|
||||
|
||||
async findDiscriminatorsByUsername(username: string): Promise<Set<number>> {
|
||||
const usernameLower = username.toLowerCase();
|
||||
const result = await fetchMany<Pick<UserByUsernameRow, 'discriminator'>>(
|
||||
FETCH_DISCRIMINATORS_BY_USERNAME_QUERY.bind({username: usernameLower}),
|
||||
);
|
||||
return new Set(result.map((r) => r.discriminator));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createUserID, type UserID} from '~/BrandedTypes';
|
||||
import {buildPatchFromData, Db, executeVersionedUpdate, fetchMany, fetchOne} from '~/database/Cassandra';
|
||||
import {EMPTY_USER_ROW, USER_COLUMNS, type UserRow} from '~/database/CassandraTypes';
|
||||
import {User} from '~/Models';
|
||||
import {Users} from '~/Tables';
|
||||
|
||||
const FETCH_USERS_BY_IDS_CQL = Users.selectCql({
|
||||
where: Users.where.in('user_id', 'user_ids'),
|
||||
});
|
||||
|
||||
const FETCH_USER_BY_ID_CQL = Users.selectCql({
|
||||
where: Users.where.eq('user_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const createFetchAllUsersFirstPageCql = (limit: number) => Users.selectCql({limit});
|
||||
|
||||
const createFetchAllUsersPaginatedCql = (limit: number) =>
|
||||
Users.selectCql({
|
||||
where: Users.where.tokenGt('user_id', 'last_user_id'),
|
||||
limit,
|
||||
});
|
||||
|
||||
type UserPatch = Partial<{
|
||||
[K in Exclude<keyof UserRow, 'user_id'> & string]: import('~/database/Cassandra').DbOp<UserRow[K]>;
|
||||
}>;
|
||||
|
||||
export class UserDataRepository {
|
||||
async findUnique(userId: UserID): Promise<User | null> {
|
||||
if (userId === 0n) {
|
||||
return new User({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: createUserID(0n),
|
||||
username: 'Fluxer',
|
||||
discriminator: 0,
|
||||
bot: true,
|
||||
system: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (userId === 1n) {
|
||||
return new User({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: createUserID(1n),
|
||||
username: 'Deleted User',
|
||||
discriminator: 0,
|
||||
bot: false,
|
||||
system: true,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await fetchOne<UserRow>(FETCH_USER_BY_ID_CQL, {user_id: userId});
|
||||
return user ? new User(user) : null;
|
||||
}
|
||||
|
||||
async findUniqueAssert(userId: UserID): Promise<User> {
|
||||
return (await this.findUnique(userId))!;
|
||||
}
|
||||
|
||||
async listAllUsersPaginated(limit: number, lastUserId?: UserID): Promise<Array<User>> {
|
||||
let users: Array<UserRow>;
|
||||
|
||||
if (lastUserId) {
|
||||
const cql = createFetchAllUsersPaginatedCql(limit);
|
||||
users = await fetchMany<UserRow>(cql, {last_user_id: lastUserId});
|
||||
} else {
|
||||
const cql = createFetchAllUsersFirstPageCql(limit);
|
||||
users = await fetchMany<UserRow>(cql, {});
|
||||
}
|
||||
|
||||
return users.map((user) => new User(user));
|
||||
}
|
||||
|
||||
async listUsers(userIds: Array<UserID>): Promise<Array<User>> {
|
||||
if (userIds.length === 0) return [];
|
||||
const users = await fetchMany<UserRow>(FETCH_USERS_BY_IDS_CQL, {user_ids: userIds});
|
||||
return users.map((user) => new User(user));
|
||||
}
|
||||
|
||||
async upsertUserRow(data: UserRow, oldData?: UserRow | null): Promise<{finalVersion: number | null}> {
|
||||
const userId = data.user_id;
|
||||
let isFirstAttempt = true;
|
||||
|
||||
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
|
||||
async () => {
|
||||
if (isFirstAttempt && oldData !== undefined) {
|
||||
isFirstAttempt = false;
|
||||
return oldData;
|
||||
}
|
||||
isFirstAttempt = false;
|
||||
const user = await this.findUnique(userId);
|
||||
return user?.toRow() ?? null;
|
||||
},
|
||||
(current) => ({
|
||||
pk: {user_id: userId},
|
||||
patch: buildPatchFromData(data, current, USER_COLUMNS, ['user_id']),
|
||||
}),
|
||||
Users,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
return {finalVersion: result.finalVersion};
|
||||
}
|
||||
|
||||
async patchUser(userId: UserID, patch: UserPatch): Promise<{finalVersion: number | null}> {
|
||||
const result = await executeVersionedUpdate<UserRow, 'user_id'>(
|
||||
async () => {
|
||||
const user = await this.findUnique(userId);
|
||||
return user?.toRow() ?? null;
|
||||
},
|
||||
(_current) => ({
|
||||
pk: {user_id: userId},
|
||||
patch,
|
||||
}),
|
||||
Users,
|
||||
{onFailure: 'log'},
|
||||
);
|
||||
|
||||
return {finalVersion: result.finalVersion};
|
||||
}
|
||||
|
||||
async updateLastActiveAt(params: {userId: UserID; lastActiveAt: Date; lastActiveIp?: string}): Promise<void> {
|
||||
const {userId, lastActiveAt, lastActiveIp} = params;
|
||||
|
||||
const patch: UserPatch = {
|
||||
last_active_at: Db.set(lastActiveAt),
|
||||
...(lastActiveIp !== undefined ? {last_active_ip: Db.set(lastActiveIp)} : {}),
|
||||
};
|
||||
|
||||
await this.patchUser(userId, patch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder} from '~/database/Cassandra';
|
||||
import type {UserRow} from '~/database/CassandraTypes';
|
||||
import {UserByEmail, UserByPhone, UserByStripeCustomerId, UserByStripeSubscriptionId, UserByUsername} from '~/Tables';
|
||||
|
||||
export class UserIndexRepository {
|
||||
async syncIndices(data: UserRow, oldData?: UserRow | null): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
if (!!data.username && data.discriminator != null && data.discriminator !== undefined) {
|
||||
batch.addPrepared(
|
||||
UserByUsername.upsertAll({
|
||||
username: data.username.toLowerCase(),
|
||||
discriminator: data.discriminator,
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.username && oldData.discriminator != null && oldData.discriminator !== undefined) {
|
||||
if (
|
||||
oldData.username.toLowerCase() !== data.username?.toLowerCase() ||
|
||||
oldData.discriminator !== data.discriminator
|
||||
) {
|
||||
batch.addPrepared(
|
||||
UserByUsername.deleteByPk({
|
||||
username: oldData.username.toLowerCase(),
|
||||
discriminator: oldData.discriminator,
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.email) {
|
||||
batch.addPrepared(
|
||||
UserByEmail.upsertAll({
|
||||
email_lower: data.email.toLowerCase(),
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.email && oldData.email.toLowerCase() !== data.email?.toLowerCase()) {
|
||||
batch.addPrepared(
|
||||
UserByEmail.deleteByPk({
|
||||
email_lower: oldData.email.toLowerCase(),
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.phone) {
|
||||
batch.addPrepared(
|
||||
UserByPhone.upsertAll({
|
||||
phone: data.phone,
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.phone && oldData.phone !== data.phone) {
|
||||
batch.addPrepared(
|
||||
UserByPhone.deleteByPk({
|
||||
phone: oldData.phone,
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.stripe_subscription_id) {
|
||||
batch.addPrepared(
|
||||
UserByStripeSubscriptionId.upsertAll({
|
||||
stripe_subscription_id: data.stripe_subscription_id,
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.stripe_subscription_id && oldData.stripe_subscription_id !== data.stripe_subscription_id) {
|
||||
batch.addPrepared(
|
||||
UserByStripeSubscriptionId.deleteByPk({
|
||||
stripe_subscription_id: oldData.stripe_subscription_id,
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.stripe_customer_id) {
|
||||
batch.addPrepared(
|
||||
UserByStripeCustomerId.upsertAll({
|
||||
stripe_customer_id: data.stripe_customer_id,
|
||||
user_id: data.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (oldData?.stripe_customer_id && oldData.stripe_customer_id !== data.stripe_customer_id) {
|
||||
batch.addPrepared(
|
||||
UserByStripeCustomerId.deleteByPk({
|
||||
stripe_customer_id: oldData.stripe_customer_id,
|
||||
user_id: oldData.user_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteIndices(
|
||||
userId: UserID,
|
||||
username: string,
|
||||
discriminator: number,
|
||||
email?: string | null,
|
||||
phone?: string | null,
|
||||
stripeSubscriptionId?: string | null,
|
||||
): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
|
||||
batch.addPrepared(
|
||||
UserByUsername.deleteByPk({
|
||||
username: username.toLowerCase(),
|
||||
discriminator: discriminator,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
|
||||
if (email) {
|
||||
batch.addPrepared(
|
||||
UserByEmail.deleteByPk({
|
||||
email_lower: email.toLowerCase(),
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (phone) {
|
||||
batch.addPrepared(
|
||||
UserByPhone.deleteByPk({
|
||||
phone: phone,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (stripeSubscriptionId) {
|
||||
batch.addPrepared(
|
||||
UserByStripeSubscriptionId.deleteByPk({
|
||||
stripe_subscription_id: stripeSubscriptionId,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Logger} from '~/Logger';
|
||||
import {getUserSearchService} from '~/Meilisearch';
|
||||
import type {User} from '~/Models';
|
||||
|
||||
export class UserSearchRepository {
|
||||
async indexUser(user: User): Promise<void> {
|
||||
const userSearchService = getUserSearchService();
|
||||
if (userSearchService) {
|
||||
await userSearchService.indexUser(user).catch((error) => {
|
||||
Logger.error({userId: user.id, error}, 'Failed to index user in search');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
135
fluxer_api/src/user/repositories/auth/AuthSessionRepository.ts
Normal file
135
fluxer_api/src/user/repositories/auth/AuthSessionRepository.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, executeConditional, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {AuthSessionRow} from '~/database/CassandraTypes';
|
||||
import {Logger} from '~/Logger';
|
||||
import {AuthSession} from '~/Models';
|
||||
import {AuthSessions, AuthSessionsByUserId} from '~/Tables';
|
||||
|
||||
const FETCH_AUTH_SESSIONS_CQL = AuthSessions.selectCql({
|
||||
where: AuthSessions.where.in('session_id_hash', 'session_id_hashes'),
|
||||
});
|
||||
|
||||
const FETCH_AUTH_SESSION_BY_TOKEN_CQL = AuthSessions.selectCql({
|
||||
where: AuthSessions.where.eq('session_id_hash'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL = AuthSessionsByUserId.selectCql({
|
||||
columns: ['session_id_hash'],
|
||||
where: AuthSessionsByUserId.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class AuthSessionRepository {
|
||||
async createAuthSession(sessionData: AuthSessionRow): Promise<AuthSession> {
|
||||
const sessionResult = await executeConditional(AuthSessions.insertIfNotExists(sessionData));
|
||||
|
||||
if (!sessionResult.applied) {
|
||||
const existingSession = await this.getAuthSessionByToken(sessionData.session_id_hash);
|
||||
if (!existingSession) {
|
||||
Logger.error(
|
||||
{sessionIdHash: sessionData.session_id_hash},
|
||||
'Failed to create or retrieve existing auth session',
|
||||
);
|
||||
throw new Error('Failed to create or retrieve existing auth session');
|
||||
}
|
||||
Logger.debug(
|
||||
{sessionIdHash: sessionData.session_id_hash},
|
||||
'Auth session already exists, returning existing session',
|
||||
);
|
||||
return existingSession;
|
||||
}
|
||||
|
||||
try {
|
||||
await upsertOne(
|
||||
AuthSessionsByUserId.insert({
|
||||
user_id: sessionData.user_id,
|
||||
session_id_hash: sessionData.session_id_hash,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error({sessionIdHash: sessionData.session_id_hash, error}, 'Failed to create AuthSessionsByUserId entry');
|
||||
}
|
||||
|
||||
return new AuthSession(sessionData);
|
||||
}
|
||||
|
||||
async getAuthSessionByToken(sessionIdHash: Buffer): Promise<AuthSession | null> {
|
||||
const session = await fetchOne<AuthSessionRow>(FETCH_AUTH_SESSION_BY_TOKEN_CQL, {session_id_hash: sessionIdHash});
|
||||
return session ? new AuthSession(session) : null;
|
||||
}
|
||||
|
||||
async listAuthSessions(userId: UserID): Promise<Array<AuthSession>> {
|
||||
const sessionHashes = await fetchMany<{session_id_hash: Buffer}>(FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
if (sessionHashes.length === 0) return [];
|
||||
const sessions = await fetchMany<AuthSessionRow>(FETCH_AUTH_SESSIONS_CQL, {
|
||||
session_id_hashes: sessionHashes.map((s) => s.session_id_hash),
|
||||
});
|
||||
return sessions.map((session) => new AuthSession(session));
|
||||
}
|
||||
|
||||
async updateAuthSessionLastUsed(sessionIdHash: Buffer): Promise<void> {
|
||||
await upsertOne(
|
||||
AuthSessions.patchByPk(
|
||||
{session_id_hash: sessionIdHash},
|
||||
{
|
||||
approx_last_used_at: Db.set(new Date()),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAuthSessions(userId: UserID, sessionIdHashes: Array<Buffer>): Promise<void> {
|
||||
const batch = new BatchBuilder();
|
||||
for (const sessionIdHash of sessionIdHashes) {
|
||||
batch.addPrepared(AuthSessions.deleteByPk({session_id_hash: sessionIdHash}));
|
||||
batch.addPrepared(AuthSessionsByUserId.deleteByPk({user_id: userId, session_id_hash: sessionIdHash}));
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async deleteAllAuthSessions(userId: UserID): Promise<void> {
|
||||
const sessions = await fetchMany<{session_id_hash: Buffer}>(FETCH_AUTH_SESSION_HASHES_BY_USER_ID_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const session of sessions) {
|
||||
batch.addPrepared(
|
||||
AuthSessions.deleteByPk({
|
||||
session_id_hash: session.session_id_hash,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
AuthSessionsByUserId.deleteByPk({
|
||||
user_id: userId,
|
||||
session_id_hash: session.session_id_hash,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (batch) {
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {deleteOneOrMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {EmailChangeTicketRow, EmailChangeTokenRow} from '~/database/CassandraTypes';
|
||||
import {EmailChangeTickets, EmailChangeTokens} from '~/Tables';
|
||||
|
||||
const FETCH_TICKET_CQL = EmailChangeTickets.selectCql({
|
||||
where: EmailChangeTickets.where.eq('ticket'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_TOKEN_CQL = EmailChangeTokens.selectCql({
|
||||
where: EmailChangeTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class EmailChangeRepository {
|
||||
async createTicket(row: EmailChangeTicketRow): Promise<void> {
|
||||
await upsertOne(EmailChangeTickets.insert(row));
|
||||
}
|
||||
|
||||
async updateTicket(row: EmailChangeTicketRow): Promise<void> {
|
||||
await upsertOne(EmailChangeTickets.upsertAll(row));
|
||||
}
|
||||
|
||||
async findTicket(ticket: string): Promise<EmailChangeTicketRow | null> {
|
||||
return await fetchOne<EmailChangeTicketRow>(FETCH_TICKET_CQL, {ticket});
|
||||
}
|
||||
|
||||
async deleteTicket(ticket: string): Promise<void> {
|
||||
await deleteOneOrMany(EmailChangeTickets.deleteByPk({ticket}));
|
||||
}
|
||||
|
||||
async createToken(row: EmailChangeTokenRow): Promise<void> {
|
||||
await upsertOne(EmailChangeTokens.insert(row));
|
||||
}
|
||||
|
||||
async findToken(token: string): Promise<EmailChangeTokenRow | null> {
|
||||
return await fetchOne<EmailChangeTokenRow>(FETCH_TOKEN_CQL, {token_: token});
|
||||
}
|
||||
|
||||
async deleteToken(token: string): Promise<void> {
|
||||
await deleteOneOrMany(EmailChangeTokens.deleteByPk({token_: token}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createIpAuthorizationToken, type UserID} from '~/BrandedTypes';
|
||||
import {UserFlags} from '~/Constants';
|
||||
import {Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {AuthorizedIpRow, IpAuthorizationTokenRow} from '~/database/CassandraTypes';
|
||||
import {AuthorizedIps, IpAuthorizationTokens, Users} from '~/Tables';
|
||||
import type {IUserAccountRepository} from '../IUserAccountRepository';
|
||||
|
||||
export {IpAuthorizationTokens};
|
||||
|
||||
const AUTHORIZE_IP_BY_TOKEN_CQL = IpAuthorizationTokens.selectCql({
|
||||
where: IpAuthorizationTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const CHECK_IP_AUTHORIZED_CQL = AuthorizedIps.selectCql({
|
||||
where: [AuthorizedIps.where.eq('user_id'), AuthorizedIps.where.eq('ip')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const GET_AUTHORIZED_IPS_CQL = AuthorizedIps.selectCql({
|
||||
where: AuthorizedIps.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class IpAuthorizationRepository {
|
||||
constructor(private userAccountRepository: IUserAccountRepository) {}
|
||||
|
||||
async checkIpAuthorized(userId: UserID, ip: string): Promise<boolean> {
|
||||
const result = await fetchOne<AuthorizedIpRow>(CHECK_IP_AUTHORIZED_CQL, {
|
||||
user_id: userId,
|
||||
ip,
|
||||
});
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async createAuthorizedIp(userId: UserID, ip: string): Promise<void> {
|
||||
await upsertOne(AuthorizedIps.insert({user_id: userId, ip}));
|
||||
}
|
||||
|
||||
async createIpAuthorizationToken(userId: UserID, token: string): Promise<void> {
|
||||
const user = await this.userAccountRepository.findUnique(userId);
|
||||
await upsertOne(
|
||||
IpAuthorizationTokens.insert({
|
||||
token_: createIpAuthorizationToken(token),
|
||||
user_id: userId,
|
||||
email: user!.email!,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async authorizeIpByToken(token: string): Promise<{userId: UserID; email: string} | null> {
|
||||
const result = await fetchOne<IpAuthorizationTokenRow>(AUTHORIZE_IP_BY_TOKEN_CQL, {token_: token});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await deleteOneOrMany(
|
||||
IpAuthorizationTokens.deleteByPk({
|
||||
token_: createIpAuthorizationToken(token),
|
||||
user_id: result.user_id,
|
||||
}),
|
||||
);
|
||||
|
||||
const user = await this.userAccountRepository.findUnique(result.user_id);
|
||||
if (!user || user.flags & UserFlags.DELETED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {userId: result.user_id, email: result.email};
|
||||
}
|
||||
|
||||
async updateUserActivity(userId: UserID, clientIp: string): Promise<void> {
|
||||
const now = new Date();
|
||||
await upsertOne(
|
||||
Users.patchByPk(
|
||||
{user_id: userId},
|
||||
{
|
||||
last_active_at: Db.set(now),
|
||||
last_active_ip: Db.set(clientIp),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getAuthorizedIps(userId: UserID): Promise<Array<{ip: string}>> {
|
||||
const ips = await fetchMany<AuthorizedIpRow>(GET_AUTHORIZED_IPS_CQL, {user_id: userId});
|
||||
return ips.map((row) => ({ip: row.ip}));
|
||||
}
|
||||
|
||||
async deleteAllAuthorizedIps(userId: UserID): Promise<void> {
|
||||
const ips = await fetchMany<AuthorizedIpRow>(GET_AUTHORIZED_IPS_CQL, {user_id: userId});
|
||||
|
||||
for (const row of ips) {
|
||||
await deleteOneOrMany(
|
||||
AuthorizedIps.deleteByPk({
|
||||
user_id: userId,
|
||||
ip: row.ip,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createMfaBackupCode, type UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, upsertOne} from '~/database/Cassandra';
|
||||
import type {MfaBackupCodeRow} from '~/database/CassandraTypes';
|
||||
import {MfaBackupCode} from '~/Models';
|
||||
import {MfaBackupCodes} from '~/Tables';
|
||||
|
||||
const FETCH_MFA_BACKUP_CODES_CQL = MfaBackupCodes.selectCql({
|
||||
where: MfaBackupCodes.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class MfaBackupCodeRepository {
|
||||
async listMfaBackupCodes(userId: UserID): Promise<Array<MfaBackupCode>> {
|
||||
const codes = await fetchMany<MfaBackupCodeRow>(FETCH_MFA_BACKUP_CODES_CQL, {user_id: userId});
|
||||
return codes.map((code) => new MfaBackupCode(code));
|
||||
}
|
||||
|
||||
async createMfaBackupCodes(userId: UserID, codes: Array<string>): Promise<Array<MfaBackupCode>> {
|
||||
const batch = new BatchBuilder();
|
||||
const backupCodes: Array<MfaBackupCode> = [];
|
||||
for (const code of codes) {
|
||||
const codeRow: MfaBackupCodeRow = {user_id: userId, code: createMfaBackupCode(code), consumed: false};
|
||||
batch.addPrepared(MfaBackupCodes.insert(codeRow));
|
||||
backupCodes.push(new MfaBackupCode(codeRow));
|
||||
}
|
||||
await batch.execute();
|
||||
return backupCodes;
|
||||
}
|
||||
|
||||
async clearMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
const codes = await this.listMfaBackupCodes(userId);
|
||||
if (codes.length === 0) return;
|
||||
const batch = new BatchBuilder();
|
||||
for (const code of codes) {
|
||||
batch.addPrepared(MfaBackupCodes.deleteByPk({user_id: userId, code: createMfaBackupCode(code.code)}));
|
||||
}
|
||||
await batch.execute();
|
||||
}
|
||||
|
||||
async consumeMfaBackupCode(userId: UserID, code: string): Promise<void> {
|
||||
await upsertOne(
|
||||
MfaBackupCodes.patchByPk(
|
||||
{user_id: userId, code: createMfaBackupCode(code)},
|
||||
{
|
||||
consumed: Db.set(true),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteAllMfaBackupCodes(userId: UserID): Promise<void> {
|
||||
await deleteOneOrMany(MfaBackupCodes.deleteCql({where: MfaBackupCodes.where.eq('user_id')}), {user_id: userId});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {UserID} from '~/BrandedTypes';
|
||||
import {BatchBuilder, executeConditional, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import {PendingVerifications, PendingVerificationsByTime} from '~/Tables';
|
||||
|
||||
const FETCH_PENDING_VERIFICATION_CQL = PendingVerifications.selectCql({
|
||||
where: PendingVerifications.where.eq('user_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class PendingVerificationRepository {
|
||||
async createPendingVerification(userId: UserID, createdAt: Date, metadata: Map<string, string>): Promise<void> {
|
||||
const verificationResult = await executeConditional(
|
||||
PendingVerifications.insertIfNotExists({
|
||||
user_id: userId,
|
||||
created_at: createdAt,
|
||||
version: 1,
|
||||
metadata,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!verificationResult.applied) {
|
||||
return;
|
||||
}
|
||||
|
||||
await upsertOne(
|
||||
PendingVerificationsByTime.insert({
|
||||
created_at: createdAt,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async deletePendingVerification(userId: UserID): Promise<void> {
|
||||
const pending = await fetchOne<{user_id: bigint; created_at: Date}>(FETCH_PENDING_VERIFICATION_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
if (!pending) return;
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
batch.addPrepared(PendingVerifications.deleteByPk({user_id: userId}));
|
||||
batch.addPrepared(
|
||||
PendingVerificationsByTime.deleteByPk({
|
||||
created_at: pending.created_at,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
141
fluxer_api/src/user/repositories/auth/TokenRepository.ts
Normal file
141
fluxer_api/src/user/repositories/auth/TokenRepository.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
createEmailRevertToken,
|
||||
createEmailVerificationToken,
|
||||
createPasswordResetToken,
|
||||
type PhoneVerificationToken,
|
||||
type UserID,
|
||||
} from '~/BrandedTypes';
|
||||
import {deleteOneOrMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {
|
||||
EmailRevertTokenRow,
|
||||
EmailVerificationTokenRow,
|
||||
PasswordResetTokenRow,
|
||||
PhoneTokenRow,
|
||||
} from '~/database/CassandraTypes';
|
||||
import {EmailRevertToken, EmailVerificationToken, PasswordResetToken} from '~/Models';
|
||||
import {EmailRevertTokens, EmailVerificationTokens, PasswordResetTokens, PhoneTokens} from '~/Tables';
|
||||
|
||||
const FETCH_EMAIL_VERIFICATION_TOKEN_CQL = EmailVerificationTokens.selectCql({
|
||||
where: EmailVerificationTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_PASSWORD_RESET_TOKEN_CQL = PasswordResetTokens.selectCql({
|
||||
where: PasswordResetTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_EMAIL_REVERT_TOKEN_CQL = EmailRevertTokens.selectCql({
|
||||
where: EmailRevertTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_PHONE_TOKEN_CQL = PhoneTokens.selectCql({
|
||||
where: PhoneTokens.where.eq('token_'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
export class TokenRepository {
|
||||
async getEmailVerificationToken(token: string): Promise<EmailVerificationToken | null> {
|
||||
const tokenRow = await fetchOne<EmailVerificationTokenRow>(FETCH_EMAIL_VERIFICATION_TOKEN_CQL, {token_: token});
|
||||
return tokenRow ? new EmailVerificationToken(tokenRow) : null;
|
||||
}
|
||||
|
||||
async createEmailVerificationToken(tokenData: EmailVerificationTokenRow): Promise<EmailVerificationToken> {
|
||||
await upsertOne(EmailVerificationTokens.insert(tokenData));
|
||||
return new EmailVerificationToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailVerificationToken(token: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
EmailVerificationTokens.deleteCql({
|
||||
where: EmailVerificationTokens.where.eq('token_'),
|
||||
}),
|
||||
{token_: createEmailVerificationToken(token)},
|
||||
);
|
||||
}
|
||||
|
||||
async getPasswordResetToken(token: string): Promise<PasswordResetToken | null> {
|
||||
const tokenRow = await fetchOne<PasswordResetTokenRow>(FETCH_PASSWORD_RESET_TOKEN_CQL, {token_: token});
|
||||
return tokenRow ? new PasswordResetToken(tokenRow) : null;
|
||||
}
|
||||
|
||||
async createPasswordResetToken(tokenData: PasswordResetTokenRow): Promise<PasswordResetToken> {
|
||||
await upsertOne(PasswordResetTokens.insert(tokenData));
|
||||
return new PasswordResetToken(tokenData);
|
||||
}
|
||||
|
||||
async deletePasswordResetToken(token: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PasswordResetTokens.deleteCql({
|
||||
where: PasswordResetTokens.where.eq('token_'),
|
||||
}),
|
||||
{token_: createPasswordResetToken(token)},
|
||||
);
|
||||
}
|
||||
|
||||
async getEmailRevertToken(token: string): Promise<EmailRevertToken | null> {
|
||||
const tokenRow = await fetchOne<EmailRevertTokenRow>(FETCH_EMAIL_REVERT_TOKEN_CQL, {token_: token});
|
||||
return tokenRow ? new EmailRevertToken(tokenRow) : null;
|
||||
}
|
||||
|
||||
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
|
||||
await upsertOne(EmailRevertTokens.insert(tokenData));
|
||||
return new EmailRevertToken(tokenData);
|
||||
}
|
||||
|
||||
async deleteEmailRevertToken(token: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
EmailRevertTokens.deleteCql({
|
||||
where: EmailRevertTokens.where.eq('token_'),
|
||||
}),
|
||||
{token_: createEmailRevertToken(token)},
|
||||
);
|
||||
}
|
||||
|
||||
async createPhoneToken(token: PhoneVerificationToken, phone: string, userId: UserID | null): Promise<void> {
|
||||
const TTL = 900;
|
||||
await upsertOne(
|
||||
PhoneTokens.insertWithTtl(
|
||||
{
|
||||
token_: token,
|
||||
phone,
|
||||
user_id: userId,
|
||||
},
|
||||
TTL,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async getPhoneToken(token: PhoneVerificationToken): Promise<PhoneTokenRow | null> {
|
||||
return await fetchOne<PhoneTokenRow>(FETCH_PHONE_TOKEN_CQL, {token_: token});
|
||||
}
|
||||
|
||||
async deletePhoneToken(token: PhoneVerificationToken): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
PhoneTokens.deleteCql({
|
||||
where: PhoneTokens.where.eq('token_'),
|
||||
}),
|
||||
{token_: token},
|
||||
);
|
||||
}
|
||||
}
|
||||
171
fluxer_api/src/user/repositories/auth/WebAuthnRepository.ts
Normal file
171
fluxer_api/src/user/repositories/auth/WebAuthnRepository.ts
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 '~/BrandedTypes';
|
||||
import {BatchBuilder, Db, deleteOneOrMany, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
|
||||
import type {WebAuthnCredentialRow} from '~/database/CassandraTypes';
|
||||
import {WebAuthnCredential} from '~/Models';
|
||||
import {WebAuthnCredentialLookup, WebAuthnCredentials} from '~/Tables';
|
||||
|
||||
const FETCH_USER_ID_BY_CREDENTIAL_ID_CQL = WebAuthnCredentialLookup.selectCql({
|
||||
where: WebAuthnCredentialLookup.where.eq('credential_id'),
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_WEBAUTHN_CREDENTIALS_CQL = WebAuthnCredentials.selectCql({
|
||||
where: WebAuthnCredentials.where.eq('user_id'),
|
||||
});
|
||||
|
||||
const FETCH_WEBAUTHN_CREDENTIAL_CQL = WebAuthnCredentials.selectCql({
|
||||
where: [WebAuthnCredentials.where.eq('user_id'), WebAuthnCredentials.where.eq('credential_id')],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
const FETCH_WEBAUTHN_CREDENTIALS_FOR_USER_CQL = WebAuthnCredentials.selectCql({
|
||||
columns: ['credential_id'],
|
||||
where: WebAuthnCredentials.where.eq('user_id'),
|
||||
});
|
||||
|
||||
export class WebAuthnRepository {
|
||||
async listWebAuthnCredentials(userId: UserID): Promise<Array<WebAuthnCredential>> {
|
||||
const credentials = await fetchMany<WebAuthnCredentialRow>(FETCH_WEBAUTHN_CREDENTIALS_CQL, {user_id: userId});
|
||||
return credentials.map((cred) => new WebAuthnCredential(cred));
|
||||
}
|
||||
|
||||
async getWebAuthnCredential(userId: UserID, credentialId: string): Promise<WebAuthnCredential | null> {
|
||||
const cred = await fetchOne<WebAuthnCredentialRow>(FETCH_WEBAUTHN_CREDENTIAL_CQL, {
|
||||
user_id: userId,
|
||||
credential_id: credentialId,
|
||||
});
|
||||
|
||||
if (!cred) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new WebAuthnCredential(cred);
|
||||
}
|
||||
|
||||
async createWebAuthnCredential(
|
||||
userId: UserID,
|
||||
credentialId: string,
|
||||
publicKey: Buffer,
|
||||
counter: bigint,
|
||||
transports: Set<string> | null,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const credentialData = {
|
||||
user_id: userId,
|
||||
credential_id: credentialId,
|
||||
public_key: publicKey,
|
||||
counter: counter,
|
||||
transports: transports,
|
||||
name: name,
|
||||
created_at: new Date(),
|
||||
last_used_at: null,
|
||||
version: 1 as const,
|
||||
};
|
||||
|
||||
await upsertOne(WebAuthnCredentials.insert(credentialData));
|
||||
|
||||
await upsertOne(
|
||||
WebAuthnCredentialLookup.insert({
|
||||
credential_id: credentialId,
|
||||
user_id: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialCounter(userId: UserID, credentialId: string, counter: bigint): Promise<void> {
|
||||
await upsertOne(
|
||||
WebAuthnCredentials.patchByPk(
|
||||
{user_id: userId, credential_id: credentialId},
|
||||
{
|
||||
counter: Db.set(counter),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialLastUsed(userId: UserID, credentialId: string): Promise<void> {
|
||||
await upsertOne(
|
||||
WebAuthnCredentials.patchByPk(
|
||||
{user_id: userId, credential_id: credentialId},
|
||||
{
|
||||
last_used_at: Db.set(new Date()),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateWebAuthnCredentialName(userId: UserID, credentialId: string, name: string): Promise<void> {
|
||||
await upsertOne(
|
||||
WebAuthnCredentials.patchByPk(
|
||||
{user_id: userId, credential_id: credentialId},
|
||||
{
|
||||
name: Db.set(name),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteWebAuthnCredential(userId: UserID, credentialId: string): Promise<void> {
|
||||
await deleteOneOrMany(
|
||||
WebAuthnCredentials.deleteByPk({
|
||||
user_id: userId,
|
||||
credential_id: credentialId,
|
||||
}),
|
||||
);
|
||||
|
||||
await deleteOneOrMany(
|
||||
WebAuthnCredentialLookup.deleteByPk({
|
||||
credential_id: credentialId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async getUserIdByCredentialId(credentialId: string): Promise<UserID | null> {
|
||||
const row = await fetchOne<{credential_id: string; user_id: UserID}>(FETCH_USER_ID_BY_CREDENTIAL_ID_CQL, {
|
||||
credential_id: credentialId,
|
||||
});
|
||||
return row?.user_id ?? null;
|
||||
}
|
||||
|
||||
async deleteAllWebAuthnCredentials(userId: UserID): Promise<void> {
|
||||
const credentials = await fetchMany<{credential_id: string}>(FETCH_WEBAUTHN_CREDENTIALS_FOR_USER_CQL, {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
const batch = new BatchBuilder();
|
||||
for (const cred of credentials) {
|
||||
batch.addPrepared(
|
||||
WebAuthnCredentials.deleteByPk({
|
||||
user_id: userId,
|
||||
credential_id: cred.credential_id,
|
||||
}),
|
||||
);
|
||||
batch.addPrepared(
|
||||
WebAuthnCredentialLookup.deleteByPk({
|
||||
credential_id: cred.credential_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await batch.execute();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user