import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common'; import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm'; import * as admin from 'firebase-admin'; import IORedis from 'ioredis'; import { EntityManager, In, IsNull, Not, Repository } from 'typeorm'; import { User, Wallet } from './database/entities'; import { RefCode } from './database/entities/refcode.entity'; import { CheckWaitListByAddressesDto, CheckWaitListByTelegramIdDto, GetWaitListUsersDto, GetWaitListUsersResponse, OnboardDto, } from './funki-id.dto'; import { InjectRedis } from './libs/redis'; @Injectable() export class FunkiIdService { constructor( @Inject('FIREBASE_AUTH') private readonly auth: admin.auth.Auth, @InjectRepository(User, 'funki-id') private readonly userRepo: Repository, @InjectRepository(Wallet, 'funki-id') private readonly walletRepo: Repository, @InjectRepository(RefCode, 'funki-id') private readonly refCodeRepo: Repository, @InjectEntityManager('funki-id') private readonly entityManager: EntityManager, @InjectRedis() private readonly redis: IORedis, ) {} async verifyToken(token: string): Promise { try { const authToken = token.trim().split(' ').pop(); const decodedToken = await this.auth.verifyIdToken(authToken); return this.findOrCreateUserByFirebaseToken(decodedToken); } catch (error) { if (error.code === 'auth/id-token-expired') { throw new UnauthorizedException('Token expired'); } if (error.code === 'auth/id-token-revoked') { throw new ForbiddenException('Token revoked'); } throw new UnauthorizedException({ message: `Invalid token${error.code ? ' ' + error.code : ''}`, }); } } public async findOrCreateUserByFirebaseToken(token: admin.auth.DecodedIdToken) { const { uid } = token; const user = await this.userRepo.findOne({ where: { uid: uid } }); if (!user) { throw new UnauthorizedException('User not found'); // ! User must be created through telegram authorization flow at /api/funki-id/telegram/authorize // const defaultName = // displayName ?? // `Funkier ${Math.floor(Math.random() * 1000000) // .toString() // .padStart(6, '0')}`; // user = await this.userRepo.save({ uid, email, name: defaultName }); } return user; } private async generateRefCodes(n: number): Promise { const generateUniqueCode = (): string => { const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; return Array.from({ length: 6 }, () => characters[Math.floor(Math.random() * characters.length)]).join(''); }; const generatedCodes = new Set(); const uniqueCodes: string[] = []; while (uniqueCodes.length < n) { const batchSize = n - uniqueCodes.length; const newCodes = Array.from({ length: batchSize }, generateUniqueCode); const existingCodes = await this.refCodeRepo.find({ where: { code: In(newCodes) }, select: ['code'], }); const existingCodesSet = new Set(existingCodes.map(ec => ec.code)); for (const code of newCodes) { if (!existingCodesSet.has(code) && !generatedCodes.has(code)) { uniqueCodes.push(code); generatedCodes.add(code); if (uniqueCodes.length === n) break; } } } return uniqueCodes; } public async me(uid: string) { const subQuery = this.refCodeRepo .createQueryBuilder('refCode') .select('refCode.id') .where('refCode.ownerUid = :uid', { uid }) .orderBy('refCode.createdAt', 'DESC') .limit(5); const user = await this.userRepo .createQueryBuilder('user') .where('user.uid = :uid', { uid }) .leftJoinAndSelect('user.wallets', 'wallets', 'wallets.userUid = user.uid') .leftJoinAndSelect('user.refCodes', 'refCodes', `refCodes.id IN (${subQuery.getQuery()})`) .setParameters({ uid }) .getOne(); // re-generate 5 new referral codes when user runs out of codes (users must be onboarded before) if (user.refCodes.filter(rc => !rc.isUsed).length === 0 && !!user.referrerUid) { const refCodesInput = await this.generateRefCodes(5).then(codes => codes.map(code => ({ ownerUid: user.uid, code, isPermanent: false, isUsed: false })), ); const newRefCodes = await this.refCodeRepo.save(refCodesInput); user.refCodes = newRefCodes; } return user; } async getWalletAddresses(funkiId: string): Promise { return this.walletRepo .find({ where: { userUid: funkiId }, select: ['address'] }) .then(wallets => wallets.map(wallet => wallet.address)); } async getFunkiIdByWalletAddress(walletAddress: string): Promise { return this.walletRepo .findOne({ where: { address: walletAddress }, }) ?.then(wallet => wallet?.userUid); } async updateName(user: User, name: string) { return this.userRepo.save({ ...user, name }); } async getUserNameByFunkiIds(funkiIds: string[]): Promise<{ funkiId: string; name: string }[]> { return this.userRepo .find({ where: { uid: In(funkiIds) } }) .then(users => users.map(user => ({ funkiId: user.uid, name: user.name }))); } async onboard(user: User, dto: OnboardDto) { // Pause onboarding according to this post: https://atherlabs.slack.com/archives/C034P04K6EA/p1730863882370949 const forbidOnboarding = true; if (forbidOnboarding) { throw new BadRequestException('Onboarding is temporarily paused'); } const { refCode } = dto; if (user.onboarded) { throw new BadRequestException('User already onboarded'); } const refCodeEntity = await this.refCodeRepo.findOne({ where: { code: refCode }, relations: ['owner'] }); if (!refCodeEntity) { throw new BadRequestException('Invalid referral code'); } if (!refCodeEntity.isPermanent && refCodeEntity.isUsed) { throw new BadRequestException('Referral code already used'); } if (user.uid === refCodeEntity.ownerUid) { throw new BadRequestException('You cannot use your own referral code'); } await this.entityManager.transaction(async manager => { // save referrer info to user await manager.save(User, { uid: user.uid, referrerUid: refCodeEntity.ownerUid, onboarded: true }); // mark referral code as used if it's not permanent if (!refCodeEntity.isPermanent) { await manager.save(RefCode, { id: refCodeEntity.id, isUsed: true }); } }); return true; } async onboardWallet(user: User, walletAddress: string) { if (user.onboardWallet) { throw new BadRequestException('Wallet already onboarded'); } const otherUser = await this.userRepo.findOne({ where: { onboardWallet: walletAddress, uid: Not(user.uid) } }); if (otherUser) { throw new BadRequestException('Wallet already used'); } await this.userRepo.save({ uid: user.uid, onboardWallet: walletAddress }); return true; } async getWaitlistUsers(dto: GetWaitListUsersDto): Promise { const { skip, take, referrerUid } = dto; const [users, total] = await this.userRepo.findAndCount({ where: { referrerUid: referrerUid ? referrerUid : Not(IsNull()), hasRequestedDeletion: false, }, relations: ['referrer'], skip, take, order: { createdAt: dto.sortOrder === 'desc' ? 'DESC' : 'ASC', }, }); return { users, total }; } async getOnboardedUsersCount(): Promise { const cachedCount = await this.redis.get('onboarded_users_count'); if (cachedCount) { return parseInt(cachedCount); } const count = await this.userRepo.count({ where: { onboarded: true } }); await this.redis.set('onboarded_users_count', count.toString(), 'EX', 60 * 5); // 5 minutes return count; } async updateAvatar(telegramId: string, avatar: string) { const user = await this.userRepo.findOne({ where: { telegramId } }); if (!!user?.telegram) { if (user?.telegram?.avatar == avatar) return; // compare avatar url to avoid unnecessary update user.telegram.avatar = avatar; return this.userRepo.save(user); } await this.redis.set(`telegram:${telegramId}:photo`, avatar, 'EX', 60 * 60 * 24 * 30); // 30 days } async checkWaitListByAddresses(dto: CheckWaitListByAddressesDto) { const exist = await this.userRepo.exists({ where: { onboardWallet: In(dto.addresses), onboarded: true } }); return exist; } async checkWaitListByTelegramId(dto: CheckWaitListByTelegramIdDto) { const exist = await this.userRepo.exists({ where: { telegramId: dto.telegramId, onboarded: true } }); return exist; } }