refactor progress

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

View File

@@ -0,0 +1,659 @@
/*
* 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 {EmojiID, GuildID, StickerID, UserID} from '@fluxer/api/src/BrandedTypes';
import {createEmojiID, createGuildID, createStickerID} from '@fluxer/api/src/BrandedTypes';
import {
mapGuildEmojisWithUsersToResponse,
mapGuildEmojiToResponse,
mapGuildStickersWithUsersToResponse,
mapGuildStickerToResponse,
} from '@fluxer/api/src/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {ExpressionAssetPurger} from '@fluxer/api/src/guild/services/content/ExpressionAssetPurger';
import type {AvatarService} from '@fluxer/api/src/infrastructure/AvatarService';
import {getMetricsService} from '@fluxer/api/src/infrastructure/MetricsService';
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
import type {UserCacheService} from '@fluxer/api/src/infrastructure/UserCacheService';
import type {LimitConfigService} from '@fluxer/api/src/limits/LimitConfigService';
import {resolveLimitSafe} from '@fluxer/api/src/limits/LimitConfigUtils';
import {createLimitMatchContext} from '@fluxer/api/src/limits/LimitMatchContextBuilder';
import type {RequestCache} from '@fluxer/api/src/middleware/RequestCacheMiddleware';
import {ExpressionPack} from '@fluxer/api/src/models/ExpressionPack';
import type {User} from '@fluxer/api/src/models/User';
import type {
PackExpressionAccessResolution,
PackExpressionAccessResolver,
PackExpressionAccessResolverParams,
} from '@fluxer/api/src/pack/PackExpressionAccessResolver';
import {mapPackToSummary} from '@fluxer/api/src/pack/PackModel';
import type {PackRepository, PackType} from '@fluxer/api/src/pack/PackRepository';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import type {LimitKey} from '@fluxer/constants/src/LimitConfigMetadata';
import {
MAX_CREATED_PACKS_NON_PREMIUM,
MAX_INSTALLED_PACKS_NON_PREMIUM,
MAX_PACK_EXPRESSIONS,
} from '@fluxer/constants/src/LimitConstants';
import {ManagedTraits} from '@fluxer/constants/src/ManagedTraits';
import {FeatureAccessError} from '@fluxer/errors/src/domains/core/FeatureAccessError';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import {UnknownGuildEmojiError} from '@fluxer/errors/src/domains/guild/UnknownGuildEmojiError';
import {UnknownGuildStickerError} from '@fluxer/errors/src/domains/guild/UnknownGuildStickerError';
import {InvalidPackTypeError} from '@fluxer/errors/src/domains/pack/InvalidPackTypeError';
import {MaxPackExpressionsError} from '@fluxer/errors/src/domains/pack/MaxPackExpressionsError';
import {MaxPackLimitError} from '@fluxer/errors/src/domains/pack/MaxPackLimitError';
import {PackAccessDeniedError} from '@fluxer/errors/src/domains/pack/PackAccessDeniedError';
import {UnknownPackError} from '@fluxer/errors/src/domains/pack/UnknownPackError';
import type {
GuildEmojiResponse,
GuildEmojiWithUserResponse,
GuildStickerResponse,
GuildStickerWithUserResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {PackDashboardResponse, PackDashboardSectionResponse} from '@fluxer/schema/src/domains/pack/PackSchemas';
export class PackService {
constructor(
private readonly packRepository: PackRepository,
private readonly guildRepository: IGuildRepositoryAggregate,
private readonly avatarService: AvatarService,
private readonly snowflakeService: SnowflakeService,
private readonly assetPurger: ExpressionAssetPurger,
private readonly userRepository: IUserRepository,
private readonly userCacheService: UserCacheService,
private readonly limitConfigService: LimitConfigService,
) {}
private async requireExpressionPackAccess(userId: UserID): Promise<void> {
const user = await this.userRepository.findUnique(userId);
if (!user || !user.traits.has(ManagedTraits.EXPRESSION_PACKS)) {
throw new FeatureTemporarilyDisabledError();
}
}
private async hasFeatureAccess(userId: UserID, limitKey: LimitKey): Promise<boolean> {
const user = await this.userRepository.findUnique(userId);
if (!user) {
return false;
}
const ctx = createLimitMatchContext({user});
const value = resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, limitKey, 0);
return value > 0;
}
private async ensurePackOwner(userId: UserID, packId: GuildID): Promise<ExpressionPack> {
const pack = await this.packRepository.getPack(packId);
if (!pack) {
throw new UnknownPackError();
}
if (pack.creatorId !== userId) {
throw new PackAccessDeniedError();
}
return pack;
}
private async collectInstalledPacks(userId: UserID): Promise<Array<{pack: ExpressionPack; installedAt: Date}>> {
const installations = await this.packRepository.listInstallations(userId);
const results: Array<{pack: ExpressionPack; installedAt: Date}> = [];
for (const row of installations) {
const pack = await this.packRepository.getPack(row.pack_id);
if (!pack) {
await this.packRepository.removeInstallation(userId, row.pack_id);
continue;
}
results.push({pack, installedAt: row.installed_at});
}
return results;
}
private async requireFeature(userId: UserID, limitKey: LimitKey): Promise<void> {
if (!(await this.hasFeatureAccess(userId, limitKey))) {
throw new FeatureAccessError();
}
}
private async getInstalledPackIdsByType(userId: UserID, packType: PackType): Promise<Set<GuildID>> {
const installations = await this.packRepository.listInstallations(userId);
return new Set(installations.filter((row) => row.pack_type === packType).map((row) => row.pack_id));
}
private buildPackExpressionAccessResolution(
userId: UserID,
packType: PackType,
pack: ExpressionPack | null,
): PackExpressionAccessResolution {
if (!pack) {
return 'not-pack';
}
if (pack.type !== packType) {
return 'not-pack';
}
return pack.creatorId === userId ? 'accessible' : 'not-accessible';
}
async createPackExpressionAccessResolver(
params: PackExpressionAccessResolverParams,
): Promise<PackExpressionAccessResolver> {
const {userId, type} = params;
if (!userId) {
return {
resolve: async () => 'not-pack',
};
}
const installedPackIds = await this.getInstalledPackIdsByType(userId, type);
const resolutionCache = new Map<GuildID, PackExpressionAccessResolution>();
return {
resolve: async (packId: GuildID) => {
if (installedPackIds.has(packId)) {
resolutionCache.set(packId, 'accessible');
return 'accessible';
}
const cached = resolutionCache.get(packId);
if (cached) {
return cached;
}
const pack = await this.packRepository.getPack(packId);
const resolution = this.buildPackExpressionAccessResolution(userId, type, pack);
resolutionCache.set(packId, resolution);
return resolution;
},
};
}
async listUserPacks(userId: UserID): Promise<PackDashboardResponse> {
await this.requireExpressionPackAccess(userId);
const user = await this.userRepository.findUnique(userId);
const ctx = createLimitMatchContext({user});
resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, 'feature_global_expressions', 0);
const createdEmoji = await this.packRepository.listPacksByCreator(userId, 'emoji');
const createdSticker = await this.packRepository.listPacksByCreator(userId, 'sticker');
const installations = await this.collectInstalledPacks(userId);
const installedEmoji = installations
.filter((entry) => entry.pack.type === 'emoji')
.map((entry) => mapPackToSummary(entry.pack, entry.installedAt));
const installedSticker = installations
.filter((entry) => entry.pack.type === 'sticker')
.map((entry) => mapPackToSummary(entry.pack, entry.installedAt));
const fallbackCreatedLimit = MAX_CREATED_PACKS_NON_PREMIUM;
const fallbackInstalledLimit = MAX_INSTALLED_PACKS_NON_PREMIUM;
const createdLimit = this.resolveLimitForUser(user ?? null, 'max_created_packs', fallbackCreatedLimit);
const installedLimit = this.resolveLimitForUser(user ?? null, 'max_installed_packs', fallbackInstalledLimit);
const emojiSection: PackDashboardSectionResponse = {
installed_limit: installedLimit,
created_limit: createdLimit,
installed: installedEmoji,
created: createdEmoji.map((pack) => mapPackToSummary(pack)),
};
const stickerSection: PackDashboardSectionResponse = {
installed_limit: installedLimit,
created_limit: createdLimit,
installed: installedSticker,
created: createdSticker.map((pack) => mapPackToSummary(pack)),
};
return {
emoji: emojiSection,
sticker: stickerSection,
};
}
async createPack(params: {
user: User;
type: PackType;
name: string;
description?: string | null;
}): Promise<ExpressionPack> {
await this.requireExpressionPackAccess(params.user.id);
await this.requireFeature(params.user.id, 'feature_global_expressions');
const createdCount = await this.packRepository.countPacksByCreator(params.user.id, params.type);
const fallbackLimit = MAX_CREATED_PACKS_NON_PREMIUM;
const limit = this.resolveLimitForUser(params.user, 'max_created_packs', fallbackLimit);
if (createdCount >= limit) {
throw new MaxPackLimitError(params.type, limit, 'create');
}
const now = new Date();
const packId = createGuildID(await this.snowflakeService.generate());
return await this.packRepository.upsertPack({
pack_id: packId,
pack_type: params.type,
creator_id: params.user.id,
name: params.name,
description: params.description ?? null,
created_at: now,
updated_at: now,
version: 1,
});
}
async updatePack(params: {
userId: UserID;
packId: GuildID;
name?: string;
description?: string | null;
}): Promise<ExpressionPack> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
const now = new Date();
const updatedPack = new ExpressionPack({
...pack.toRow(),
name: params.name ?? pack.name,
description: params.description === undefined ? pack.description : params.description,
updated_at: now,
});
return await this.packRepository.upsertPack(updatedPack.toRow());
}
async deletePack(userId: UserID, packId: GuildID): Promise<void> {
await this.requireExpressionPackAccess(userId);
await this.ensurePackOwner(userId, packId);
await this.packRepository.deletePack(packId);
}
async installPack(userId: UserID, packId: GuildID): Promise<void> {
await this.requireExpressionPackAccess(userId);
const pack = await this.packRepository.getPack(packId);
if (!pack) {
throw new UnknownPackError();
}
const alreadyInstalled = await this.packRepository.hasInstallation(userId, packId);
if (alreadyInstalled) {
return;
}
await this.requireFeature(userId, 'feature_global_expressions');
const user = await this.userRepository.findUnique(userId);
const fallbackLimit = MAX_INSTALLED_PACKS_NON_PREMIUM;
const limit = this.resolveLimitForUser(user ?? null, 'max_installed_packs', fallbackLimit);
const installations = await this.collectInstalledPacks(userId);
const typeCount = installations.filter((entry) => entry.pack.type === pack.type).length;
if (typeCount >= limit) {
throw new MaxPackLimitError(pack.type, limit, 'install');
}
await this.packRepository.addInstallation({
user_id: userId,
pack_id: packId,
pack_type: pack.type,
installed_at: new Date(),
});
}
async uninstallPack(userId: UserID, packId: GuildID): Promise<void> {
await this.requireExpressionPackAccess(userId);
await this.packRepository.removeInstallation(userId, packId);
}
async getInstalledPackIds(userId: UserID): Promise<Set<GuildID>> {
await this.requireExpressionPackAccess(userId);
const installations = await this.collectInstalledPacks(userId);
return new Set(installations.map((entry) => entry.pack.id));
}
async getPackEmojis(params: {
userId: UserID;
packId: GuildID;
requestCache: RequestCache;
}): Promise<Array<GuildEmojiWithUserResponse>> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
const emojis = await this.guildRepository.listEmojis(pack.id);
return await mapGuildEmojisWithUsersToResponse(emojis, this.userCacheService, params.requestCache);
}
async getPackStickers(params: {
userId: UserID;
packId: GuildID;
requestCache: RequestCache;
}): Promise<Array<GuildStickerWithUserResponse>> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
const stickers = await this.guildRepository.listStickers(pack.id);
return await mapGuildStickersWithUsersToResponse(stickers, this.userCacheService, params.requestCache);
}
async createPackEmoji(params: {
user: User;
packId: GuildID;
name: string;
image: string;
}): Promise<GuildEmojiResponse> {
await this.requireExpressionPackAccess(params.user.id);
await this.requireFeature(params.user.id, 'feature_global_expressions');
const pack = await this.ensurePackOwner(params.user.id, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
const emojiCount = await this.guildRepository.countEmojis(pack.id);
const expressionLimit = this.resolveLimitForUser(params.user, 'max_pack_expressions', MAX_PACK_EXPRESSIONS);
if (emojiCount >= expressionLimit) {
throw new MaxPackExpressionsError(expressionLimit);
}
const {animated, imageBuffer, contentType} = await this.avatarService.processEmoji({
errorPath: 'image',
base64Image: params.image,
});
const emojiId = createEmojiID(await this.snowflakeService.generate());
await this.avatarService.uploadEmoji({
prefix: 'emojis',
emojiId,
imageBuffer,
contentType,
});
const emoji = await this.guildRepository.upsertEmoji({
guild_id: pack.id,
emoji_id: emojiId,
name: params.name,
creator_id: params.user.id,
animated,
version: 1,
});
return mapGuildEmojiToResponse(emoji);
}
async bulkCreatePackEmojis(params: {
user: User;
packId: GuildID;
emojis: Array<{name: string; image: string}>;
}): Promise<{success: Array<GuildEmojiResponse>; failed: Array<{name: string; error: string}>}> {
await this.requireExpressionPackAccess(params.user.id);
await this.requireFeature(params.user.id, 'feature_global_expressions');
const pack = await this.ensurePackOwner(params.user.id, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
let emojiCount = await this.guildRepository.countEmojis(pack.id);
const expressionLimit = this.resolveLimitForUser(params.user, 'max_pack_expressions', MAX_PACK_EXPRESSIONS);
const success: Array<GuildEmojiResponse> = [];
const failed: Array<{name: string; error: string}> = [];
for (const emojiData of params.emojis) {
if (emojiCount >= expressionLimit) {
failed.push({name: emojiData.name, error: 'Pack expression limit reached'});
continue;
}
try {
const {animated, imageBuffer, contentType} = await this.avatarService.processEmoji({
errorPath: `emojis[${success.length + failed.length}].image`,
base64Image: emojiData.image,
});
const emojiId = createEmojiID(await this.snowflakeService.generate());
await this.avatarService.uploadEmoji({
prefix: 'emojis',
emojiId,
imageBuffer,
contentType,
});
const emoji = await this.guildRepository.upsertEmoji({
guild_id: pack.id,
emoji_id: emojiId,
name: emojiData.name,
creator_id: params.user.id,
animated,
version: 1,
});
success.push(mapGuildEmojiToResponse(emoji));
emojiCount += 1;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
failed.push({name: emojiData.name, error: message});
}
}
return {success, failed};
}
async updatePackEmoji(params: {
userId: UserID;
packId: GuildID;
emojiId: EmojiID;
name: string;
}): Promise<GuildEmojiResponse> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
const emoji = await this.guildRepository.getEmoji(params.emojiId, pack.id);
if (!emoji) {
throw new UnknownGuildEmojiError();
}
const updatedEmoji = await this.guildRepository.upsertEmoji({
...emoji.toRow(),
name: params.name,
});
return mapGuildEmojiToResponse(updatedEmoji);
}
async deletePackEmoji(params: {userId: UserID; packId: GuildID; emojiId: EmojiID; purge?: boolean}): Promise<void> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'emoji') {
throw new InvalidPackTypeError('emoji');
}
const emoji = await this.guildRepository.getEmoji(params.emojiId, pack.id);
if (!emoji) {
throw new UnknownGuildEmojiError();
}
await this.guildRepository.deleteEmoji(pack.id, params.emojiId);
if (params.purge) {
await this.assetPurger.purgeEmoji(emoji.id.toString());
}
}
async createPackSticker(params: {
user: User;
packId: GuildID;
name: string;
description?: string | null;
tags: Array<string>;
image: string;
}): Promise<GuildStickerResponse> {
await this.requireExpressionPackAccess(params.user.id);
await this.requireFeature(params.user.id, 'feature_global_expressions');
const pack = await this.ensurePackOwner(params.user.id, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
const stickerCount = await this.guildRepository.countStickers(pack.id);
const expressionLimit = this.resolveLimitForUser(params.user, 'max_pack_expressions', MAX_PACK_EXPRESSIONS);
if (stickerCount >= expressionLimit) {
throw new MaxPackExpressionsError(expressionLimit);
}
const {animated, imageBuffer} = await this.avatarService.processSticker({
errorPath: 'image',
base64Image: params.image,
});
const stickerId = createStickerID(await this.snowflakeService.generate());
await this.avatarService.uploadSticker({prefix: 'stickers', stickerId, imageBuffer});
const sticker = await this.guildRepository.upsertSticker({
guild_id: pack.id,
sticker_id: stickerId,
name: params.name,
description: params.description ?? null,
tags: params.tags,
animated,
creator_id: params.user.id,
version: 1,
});
const response = mapGuildStickerToResponse(sticker);
getMetricsService().counter({name: 'fluxer.stickers.created', value: 1});
return response;
}
async bulkCreatePackStickers(params: {
user: User;
packId: GuildID;
stickers: Array<{name: string; description?: string | null; tags: Array<string>; image: string}>;
}): Promise<{success: Array<GuildStickerResponse>; failed: Array<{name: string; error: string}>}> {
await this.requireExpressionPackAccess(params.user.id);
await this.requireFeature(params.user.id, 'feature_global_expressions');
const pack = await this.ensurePackOwner(params.user.id, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
let stickerCount = await this.guildRepository.countStickers(pack.id);
const expressionLimit = this.resolveLimitForUser(params.user, 'max_pack_expressions', MAX_PACK_EXPRESSIONS);
const success: Array<GuildStickerResponse> = [];
const failed: Array<{name: string; error: string}> = [];
for (const stickerData of params.stickers) {
if (stickerCount >= expressionLimit) {
failed.push({name: stickerData.name, error: 'Pack expression limit reached'});
continue;
}
try {
const {animated, imageBuffer} = await this.avatarService.processSticker({
errorPath: `stickers[${success.length + failed.length}].image`,
base64Image: stickerData.image,
});
const stickerId = createStickerID(await this.snowflakeService.generate());
await this.avatarService.uploadSticker({prefix: 'stickers', stickerId, imageBuffer});
const sticker = await this.guildRepository.upsertSticker({
guild_id: pack.id,
sticker_id: stickerId,
name: stickerData.name,
description: stickerData.description ?? null,
tags: stickerData.tags,
animated,
creator_id: params.user.id,
version: 1,
});
success.push(mapGuildStickerToResponse(sticker));
stickerCount += 1;
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
failed.push({name: stickerData.name, error: message});
}
}
for (const _ of success) {
getMetricsService().counter({name: 'fluxer.stickers.created', value: 1});
}
return {success, failed};
}
async updatePackSticker(params: {
userId: UserID;
packId: GuildID;
stickerId: StickerID;
name: string;
description?: string | null;
tags: Array<string>;
}): Promise<GuildStickerResponse> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
const sticker = await this.guildRepository.getSticker(params.stickerId, pack.id);
if (!sticker) {
throw new UnknownGuildStickerError();
}
const updatedSticker = await this.guildRepository.upsertSticker({
...sticker.toRow(),
name: params.name,
description: params.description ?? null,
tags: params.tags,
});
return mapGuildStickerToResponse(updatedSticker);
}
async deletePackSticker(params: {
userId: UserID;
packId: GuildID;
stickerId: StickerID;
purge?: boolean;
}): Promise<void> {
await this.requireExpressionPackAccess(params.userId);
const pack = await this.ensurePackOwner(params.userId, params.packId);
if (pack.type !== 'sticker') {
throw new InvalidPackTypeError('sticker');
}
const sticker = await this.guildRepository.getSticker(params.stickerId, pack.id);
if (!sticker) {
throw new UnknownGuildStickerError();
}
await this.guildRepository.deleteSticker(pack.id, params.stickerId);
if (params.purge) {
await this.assetPurger.purgeSticker(sticker.id.toString());
}
getMetricsService().counter({name: 'fluxer.stickers.deleted', value: 1});
}
private resolveLimitForUser(user: User | null, key: LimitKey, fallback: number): number {
const ctx = createLimitMatchContext({user});
return resolveLimitSafe(this.limitConfigService.getConfigSnapshot(), ctx, key, fallback);
}
}