add eslint config

This commit is contained in:
Sergey Krylov 2025-07-29 05:30:29 +03:00
parent d1c5ac7e19
commit c4bba76b50
155 changed files with 2155 additions and 2033 deletions

View File

@ -1,113 +1,108 @@
// @ts-check import config from 'eslint-config-ksv741';
import eslint from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import stylistic from '@stylistic/eslint-plugin'
export default tseslint.config( export default [
...config,
{ {
ignores: [ name: 'my-rules',
'node_modules', files: [
'dist' '**/*.js',
'**/*.ts',
'**/*.tsx',
], ],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
stylistic.configs.recommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
'@stylistic': stylistic,
}
},
{
rules: { rules: {
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'import/order': [
'error', {
pathGroups: [
{
pattern: '__spec__/**',
group: 'builtin',
position: 'before',
},
{
pattern: 'apps/**',
group: 'internal',
position: 'after',
},
{
pattern: 'pages/**',
group: 'internal',
position: 'after',
},
{
pattern: 'widgets/**',
group: 'internal',
position: 'after',
},
{
pattern: 'features/**',
group: 'internal',
position: 'after',
},
{
pattern: 'entities/**',
group: 'internal',
position: 'after',
},
{
pattern: 'shared/**',
group: 'internal',
position: 'after',
},
],
distinctGroup: true,
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'object',
'index',
'type',
],
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'no-await-in-loop': 'off',
'no-void': ['error', { allowAsStatement: true }],
// "@typescript-eslint/no-extraneous-class": ['error', {allowEmpty: true}],
// "@typescript-eslint/parameter-properties": ['error', { "allow": ["private readonly"] }],
'@typescript-eslint/max-params': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
// todo fixme
'import/max-dependencies': 'off',
'@typescript-eslint/no-unsafe-type-assertion': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'import/no-cycle': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'import/extensions': 'off',
'@typescript-eslint/no-unnecessary-condition': 'off',
'max-classes-per-file': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-argument': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unnecessary-type-conversion': 'off',
'@typescript-eslint/consistent-return': 'off',
'@typescript-eslint/class-methods-use-this': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn', '@typescript-eslint/member-ordering': 'off',
'@typescript-eslint/no-unsafe-argument': 'warn' 'no-undefined': 'off',
'@typescript-eslint/no-misused-spread': 'off',
'@typescript-eslint/require-await': 'off',
'@typescript-eslint/parameter-properties': 'off',
'@typescript-eslint/no-extraneous-class': 'off',
'@stylistic/max-len': 'off',
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
'import/no-extraneous-dependencies': 'off',
}, },
}, },
); ];
// import config from 'eslint-config-ksv741/ts-base'
//
// export default [
// ...config,
// {
// name: 'my-custom',
// files: [
// '**/*.js',
// '**/*.ts'
// ],
// rules: {
// 'func-style': ["error", "declaration", { "allowArrowFunctions": true }],
// 'import/order': [
// 'error', {
// pathGroups: [
// {
// pattern: '__spec__/**',
// group: 'builtin',
// position: 'before',
// },
// {
// pattern: 'apps/**',
// group: 'internal',
// position: 'after',
// },
// {
// pattern: 'pages/**',
// group: 'internal',
// position: 'after',
// },
// {
// pattern: 'widgets/**',
// group: 'internal',
// position: 'after',
// },
// {
// pattern: 'features/**',
// group: 'internal',
// position: 'after',
// },
// {
// pattern: 'entities/**',
// group: 'internal',
// position: 'after',
// },
// {
// pattern: 'shared/**',
// group: 'internal',
// position: 'after',
// },
// ],
// distinctGroup: true,
// groups: [
// 'builtin',
// 'external',
// 'internal',
// 'parent',
// 'sibling',
// 'object',
// 'index',
// 'type',
// ],
// 'newlines-between': 'always',
// alphabetize: {
// order: 'asc',
// caseInsensitive: true,
// },
// },
// ],
// }
// }
// ];

View File

@ -66,15 +66,13 @@
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.34.2", "sharp": "^0.34.2",
"stripe": "^18.3.0", "stripe": "^18.3.0",
"telegraf": "^4.16.3" "telegraf": "^4.16.3",
"uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0", "@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0", "@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@stylistic/eslint-plugin": "^5.0.0",
"@swc/cli": "^0.6.0", "@swc/cli": "^0.6.0",
"@swc/core": "^1.10.7", "@swc/core": "^1.10.7",
"@types/cookie-parser": "^1.4.9", "@types/cookie-parser": "^1.4.9",
@ -96,8 +94,7 @@
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3", "typescript": "^5.7.3"
"typescript-eslint": "^8.20.0"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [

View File

@ -1,14 +1,16 @@
import { isDev } from '@/src/shared/util/is-dev.util' import * as path from 'node:path';
import { ApolloDriverConfig } from '@nestjs/apollo'
import { ConfigService } from '@nestjs/config' import { isDev } from '@/src/shared/util/is-dev.util';
import { Request, Response } from 'express'
import * as path from 'node:path' import type { ProcessEnv } from '../../shared/types/env';
import { ProcessEnv } from '../../shared/types/env' import type { ApolloDriverConfig } from '@nestjs/apollo';
import type { ConfigService } from '@nestjs/config';
import type { Request, Response } from 'express';
type ContextType = { type ContextType = {
req: Request req: Request;
res: Response res: Response;
} };
export function getGraphQLConfig(configService: ConfigService<ProcessEnv>): ApolloDriverConfig { export function getGraphQLConfig(configService: ConfigService<ProcessEnv>): ApolloDriverConfig {
return { return {
@ -18,5 +20,5 @@ export function getGraphQLConfig(configService: ConfigService<ProcessEnv>): Apol
sortSchema: true, sortSchema: true,
context: ({ req, res }: ContextType) => ({ req, res }), context: ({ req, res }: ContextType) => ({ req, res }),
installSubscriptionHandlers: true, installSubscriptionHandlers: true,
} };
} }

View File

@ -1,11 +1,11 @@
import { TypeLiveKitOptions } from '@/src/module/libs/livekit/type/livekit.type' import type { ProcessEnv } from '../../shared/types/env';
import { ConfigService } from '@nestjs/config' import type { TypeLiveKitOptions } from '@/src/module/libs/livekit/type/livekit.type';
import { ProcessEnv } from '../../shared/types/env' import type { ConfigService } from '@nestjs/config';
export function getLiveKitConfig(configService: ConfigService<ProcessEnv>): TypeLiveKitOptions { export function getLiveKitConfig(configService: ConfigService<ProcessEnv>): TypeLiveKitOptions {
return { return {
apiSecret: configService.getOrThrow('LIVEKIT_API_SECRET'), apiSecret: configService.getOrThrow('LIVEKIT_API_SECRET'),
apiKey: configService.getOrThrow('LIVEKIT_API_KEY'), apiKey: configService.getOrThrow('LIVEKIT_API_KEY'),
apiUrl: configService.getOrThrow('LIVEKIT_URL'), apiUrl: configService.getOrThrow('LIVEKIT_URL'),
} };
} }

View File

@ -1,6 +1,6 @@
import { TypeStripeOptions } from '@/src/module/libs/stripe/types/stripe.type' import type { TypeStripeOptions } from '@/src/module/libs/stripe/types/stripe.type';
import { ProcessEnv } from '@/src/shared/types/env' import type { ProcessEnv } from '@/src/shared/types/env';
import { ConfigService } from '@nestjs/config' import type { ConfigService } from '@nestjs/config';
export function getStripeConfig(configService: ConfigService<ProcessEnv>): TypeStripeOptions { export function getStripeConfig(configService: ConfigService<ProcessEnv>): TypeStripeOptions {
return { return {
@ -8,5 +8,5 @@ export function getStripeConfig(configService: ConfigService<ProcessEnv>): TypeS
apiVersion: '2025-06-30.basil', apiVersion: '2025-06-30.basil',
}, },
apiKey: configService.getOrThrow('STRIPE_SECRET_KEY'), apiKey: configService.getOrThrow('STRIPE_SECRET_KEY'),
} };
} }

View File

@ -1,9 +1,9 @@
import { ConfigService } from '@nestjs/config' import type { ProcessEnv } from '../../shared/types/env';
import { TelegrafModuleOptions } from 'nestjs-telegraf' import type { ConfigService } from '@nestjs/config';
import { ProcessEnv } from '../../shared/types/env'; import type { TelegrafModuleOptions } from 'nestjs-telegraf';
export function getTelegrafOptions(configService: ConfigService<ProcessEnv>): TelegrafModuleOptions { export function getTelegrafOptions(configService: ConfigService<ProcessEnv>): TelegrafModuleOptions {
return { return {
token: configService.getOrThrow('TELEGRAM_BOT_TOKEN'), token: configService.getOrThrow('TELEGRAM_BOT_TOKEN'),
} };
} }

View File

@ -1,37 +1,39 @@
import { getGraphQLConfig } from '@/src/core/config/graphql.config' import { ApolloDriver } from '@nestjs/apollo';
import { getLiveKitConfig } from '@/src/core/config/livekit.config' import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import { getGraphQLConfig } from '@/src/core/config/graphql.config';
import { getLiveKitConfig } from '@/src/core/config/livekit.config';
import { getStripeConfig } from '@/src/core/config/stripe.config'; import { getStripeConfig } from '@/src/core/config/stripe.config';
import { AccountModule } from '@/src/module/auth/account/account.module' import { AccountModule } from '@/src/module/auth/account/account.module';
import { DeactivateModule } from '@/src/module/auth/deactivate/deactivate.module' import { DeactivateModule } from '@/src/module/auth/deactivate/deactivate.module';
import { PasswordRecoveryModule } from '@/src/module/auth/password-recovery/password-recovery.module' import { PasswordRecoveryModule } from '@/src/module/auth/password-recovery/password-recovery.module';
import { ProfileModule } from '@/src/module/auth/profile/profile.module' import { ProfileModule } from '@/src/module/auth/profile/profile.module';
import { SessionModule } from '@/src/module/auth/session/session.module' import { SessionModule } from '@/src/module/auth/session/session.module';
import { TotpModule } from '@/src/module/auth/totp/totp.module' import { TotpModule } from '@/src/module/auth/totp/totp.module';
import { VerificationModule } from '@/src/module/auth/verification/verification.module' import { VerificationModule } from '@/src/module/auth/verification/verification.module';
import { CategoryModule } from '@/src/module/category/category.module' import { CategoryModule } from '@/src/module/category/category.module';
import { ChannelModule } from '@/src/module/channel/channel.module' import { ChannelModule } from '@/src/module/channel/channel.module';
import { ChatModule } from '@/src/module/chat/chat.module' import { ChatModule } from '@/src/module/chat/chat.module';
import { CronModule } from '@/src/module/cron/cron.module' import { CronModule } from '@/src/module/cron/cron.module';
import { FollowModule } from '@/src/module/follow/follow.module' import { FollowModule } from '@/src/module/follow/follow.module';
import { LiveKitModule } from '@/src/module/libs/livekit/livekit.module' import { LiveKitModule } from '@/src/module/libs/livekit/livekit.module';
import { MailModule } from '@/src/module/libs/mail/mail.module' import { MailModule } from '@/src/module/libs/mail/mail.module';
import { StorageModule } from '@/src/module/libs/storage/storage.module' import { StorageModule } from '@/src/module/libs/storage/storage.module';
import { StripeModule } from '@/src/module/libs/stripe/stripe.module'; import { StripeModule } from '@/src/module/libs/stripe/stripe.module';
import { TelegramModule } from '@/src/module/libs/telegram/telegram.module' import { TelegramModule } from '@/src/module/libs/telegram/telegram.module';
import { NotificationModule } from '@/src/module/notification/notification.module' import { NotificationModule } from '@/src/module/notification/notification.module';
import { PlanModule } from '@/src/module/sponsorship/plan/plan.module'; import { PlanModule } from '@/src/module/sponsorship/plan/plan.module';
import { SubscriptionModule } from '@/src/module/sponsorship/subscription/subscription.module'; import { SubscriptionModule } from '@/src/module/sponsorship/subscription/subscription.module';
import { TransactionModule } from '@/src/module/sponsorship/transaction/transaction.module'; import { TransactionModule } from '@/src/module/sponsorship/transaction/transaction.module';
import { IngressModule } from '@/src/module/stream/ingress/ingress.module' import { IngressModule } from '@/src/module/stream/ingress/ingress.module';
import { StreamModule } from '@/src/module/stream/stream.module' import { StreamModule } from '@/src/module/stream/stream.module';
import { WebhookModule } from '@/src/module/webhook/webhook.module' import { WebhookModule } from '@/src/module/webhook/webhook.module';
import { IS_DEV } from '@/src/shared/util/is-dev.util' import { IS_DEV } from '@/src/shared/util/is-dev.util';
import { ApolloDriver } from '@nestjs/apollo'
import { Module } from '@nestjs/common' import { PrismaModule } from './prisma/prisma.module';
import { ConfigModule, ConfigService } from '@nestjs/config' import { RedisModule } from './redis/redis.module';
import { GraphQLModule } from '@nestjs/graphql'
import { PrismaModule } from './prisma/prisma.module'
import { RedisModule } from './redis/redis.module'
@Module({ @Module({
imports: [ imports: [

View File

@ -125,4 +125,4 @@ export const CATEGORIES = [
'Погрузитесь в захватывающий мир спорта, где страсть, соревнование и дух командной игры сливаются воедино! Эта категория предназначена для всех поклонников активного образа жизни и спортивных событий, где вы сможете следить за увлекательными матчами, обсуждать стратегии команд и делиться своим опытом с другими фанатами. Присоединяйтесь к стримам, следите за последними новостями и результатами, а также находите единомышленников для совместных тренировок и обсуждений. Откройте для себя удивительный мир спорта, где каждое соревнование — это возможность проявить свои силы и стремление к победе!', 'Погрузитесь в захватывающий мир спорта, где страсть, соревнование и дух командной игры сливаются воедино! Эта категория предназначена для всех поклонников активного образа жизни и спортивных событий, где вы сможете следить за увлекательными матчами, обсуждать стратегии команд и делиться своим опытом с другими фанатами. Присоединяйтесь к стримам, следите за последними новостями и результатами, а также находите единомышленников для совместных тренировок и обсуждений. Откройте для себя удивительный мир спорта, где каждое соревнование — это возможность проявить свои силы и стремление к победе!',
thumbnailUrl: '/categories/sport.webp', thumbnailUrl: '/categories/sport.webp',
}, },
] ];

View File

@ -1,5 +1,5 @@
export const STREAMS = { export const STREAMS = {
'minecraft': [ minecraft: [
'Проходим Minecraft: выживание с нуля!', 'Проходим Minecraft: выживание с нуля!',
'Строим эпические постройки в Minecraft', 'Строим эпические постройки в Minecraft',
'Тайны Майнкрафт: приключения начинаются!', 'Тайны Майнкрафт: приключения начинаются!',
@ -23,7 +23,7 @@ export const STREAMS = {
'Обзор новых машин и тюнинга в GTA Online', 'Обзор новых машин и тюнинга в GTA Online',
'Создаем свой бизнес в Лос-Сантосе', 'Создаем свой бизнес в Лос-Сантосе',
], ],
'rust': [ rust: [
'Рейдим базы в Rust', 'Рейдим базы в Rust',
'Выживание в пустоши: Rust', 'Выживание в пустоши: Rust',
'Новые механики Rust: тестируем!', 'Новые механики Rust: тестируем!',
@ -71,7 +71,7 @@ export const STREAMS = {
'Становимся охотниками за головами в RDR2', 'Становимся охотниками за головами в RDR2',
'Обзор легендарных зверей и трофеев', 'Обзор легендарных зверей и трофеев',
], ],
'learning': [ learning: [
'Изучаем основы фотографии', 'Изучаем основы фотографии',
'Как стать мастером ораторского искусства', 'Как стать мастером ораторского искусства',
'Погружаемся в искусство рисования', 'Погружаемся в искусство рисования',
@ -83,7 +83,7 @@ export const STREAMS = {
'Изучаем основы кулинарии: готовим вместе', 'Изучаем основы кулинарии: готовим вместе',
'Учим основы финансовой грамотности', 'Учим основы финансовой грамотности',
], ],
'fortnite': [ fortnite: [
'Стрим по Fortnite: королевская битва!', 'Стрим по Fortnite: королевская битва!',
'Секреты строительства в Fortnite', 'Секреты строительства в Fortnite',
'Лучшие тактики для победы в Fortnite', 'Лучшие тактики для победы в Fortnite',
@ -107,7 +107,7 @@ export const STREAMS = {
'Киберспортивные команды: следим за матчами', 'Киберспортивные команды: следим за матчами',
'Участвуем в тренировочных матчах CS:GO', 'Участвуем в тренировочных матчах CS:GO',
], ],
'programming': [ programming: [
'Программируем на JavaScript: от простого к сложному', 'Программируем на JavaScript: от простого к сложному',
'Разработка игр на Python: шаг за шагом', 'Разработка игр на Python: шаг за шагом',
'Создание веб-приложений с React: практическое руководство', 'Создание веб-приложений с React: практическое руководство',
@ -155,7 +155,7 @@ export const STREAMS = {
'Стримим клановые битвы в Clash Royale', 'Стримим клановые битвы в Clash Royale',
'Побеждаем в дуэлях и соревнованиях', 'Побеждаем в дуэлях и соревнованиях',
], ],
'music': [ music: [
'Музыкальные новинки: обсуждаем хиты!', 'Музыкальные новинки: обсуждаем хиты!',
'Слушаем и обсуждаем любимые альбомы', 'Слушаем и обсуждаем любимые альбомы',
'Обсуждаем музыкальные жанры: что слушать?', 'Обсуждаем музыкальные жанры: что слушать?',
@ -203,7 +203,7 @@ export const STREAMS = {
'Проходим ранговые игры в League of Legends', 'Проходим ранговые игры в League of Legends',
'Обзор новых скинов и событий в League of Legends', 'Обзор новых скинов и событий в League of Legends',
], ],
'sport': [ sport: [
'Обсуждаем спортивные события: последние новости!', 'Обсуждаем спортивные события: последние новости!',
'Спортивные тренировки: секреты успеха', 'Спортивные тренировки: секреты успеха',
'Лучшие моменты в мире спорта', 'Лучшие моменты в мире спорта',
@ -215,4 +215,4 @@ export const STREAMS = {
'Реакции на последние спортивные трансляции', 'Реакции на последние спортивные трансляции',
'Участвуем в спортивных турнирах и соревнованиях', 'Участвуем в спортивных турнирах и соревнованиях',
], ],
} };

View File

@ -101,4 +101,4 @@ export const USERNAMES = [
'tristan', 'tristan',
'ulysses', 'ulysses',
'violet', 'violet',
] ];

View File

@ -1,5 +1,6 @@
import { Global, Module } from '@nestjs/common' import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service'
import { PrismaService } from './prisma.service';
@Global() @Global()
@Module({ @Module({

View File

@ -1,10 +1,11 @@
import { BadRequestException, Logger } from '@nestjs/common' import { BadRequestException, Logger } from '@nestjs/common';
import { hash } from 'argon2' import { hash } from 'argon2';
import { Prisma, PrismaClient } from '../../../prisma/generated'
import { CATEGORIES } from './data/categories.data' import { Prisma, PrismaClient } from '@/prisma/generated';
import { USERNAMES } from './data/users.data'
import { STREAMS } from './data/streams.data' import { CATEGORIES } from './data/categories.data';
import { STREAMS } from './data/streams.data';
import { USERNAMES } from './data/users.data';
const prisma = new PrismaClient({ const prisma = new PrismaClient({
transactionOptions: { transactionOptions: {
@ -12,25 +13,25 @@ const prisma = new PrismaClient({
timeout: 10000, timeout: 10000,
isolationLevel: Prisma.TransactionIsolationLevel.Serializable, isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
}, },
}) });
async function main() { async function main() {
try { try {
Logger.log('Начало заполнения базы данных') Logger.log('Начало заполнения базы данных');
await prisma.$transaction([ await prisma.$transaction([
prisma.user.deleteMany({}), prisma.user.deleteMany({}),
prisma.socialLink.deleteMany({}), prisma.socialLink.deleteMany({}),
prisma.stream.deleteMany({}), prisma.stream.deleteMany({}),
prisma.category.deleteMany({}), prisma.category.deleteMany({}),
]) ]);
await prisma.category.createMany({ data: CATEGORIES }) await prisma.category.createMany({ data: CATEGORIES });
Logger.log('Категории успешно созданы') Logger.log('Категории успешно созданы');
const categories = await prisma.category.findMany() const categories = await prisma.category.findMany();
const categoriesBySlug = Object.fromEntries( const categoriesBySlug = Object.fromEntries(
categories.map(category => [category.slug, category]), categories.map((category) => [category.slug, category]),
) );
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
for (const name of USERNAMES) { for (const name of USERNAMES) {
@ -38,9 +39,9 @@ async function main() {
Object.keys(categoriesBySlug)[ Object.keys(categoriesBySlug)[
Math.floor(Math.random() * Object.keys(categoriesBySlug).length) Math.floor(Math.random() * Object.keys(categoriesBySlug).length)
] ]
] ];
const userExists = await tx.user.findUnique({ where: { name } }) const userExists = await tx.user.findUnique({ where: { name } });
if (!userExists) { if (!userExists) {
const createdUser = await tx.user.create({ const createdUser = await tx.user.create({
data: { data: {
@ -70,10 +71,10 @@ async function main() {
// create: {}, // create: {},
// }, // },
}, },
}) });
const randomTitles = STREAMS[randomCategory.slug] as string const randomTitles = STREAMS[randomCategory.slug] as string;
const randomTitle = randomTitles[Math.floor(Math.random() * randomTitles.length)] const randomTitle = randomTitles[Math.floor(Math.random() * randomTitles.length)];
await tx.stream.create({ await tx.stream.create({
data: { data: {
@ -90,21 +91,20 @@ async function main() {
}, },
}, },
}, },
}) });
Logger.log(`Пользователь "${createdUser.name}" и его стрим успешно созданы`) Logger.log(`Пользователь "${createdUser.name}" и его стрим успешно созданы`);
} }
} }
}) });
} } catch (e) {
catch (e) { Logger.error(e);
Logger.error(e)
throw new BadRequestException('Ошибка при заполнении базы данных') throw new BadRequestException('Ошибка при заполнении базы данных');
} } finally {
finally { Logger.log('Закрытие соединения с базой данных');
Logger.log('Закрытие соединения с базой данных') await prisma.$disconnect();
await prisma.$disconnect() Logger.log('Соединение с базой данныз успешно закрыто');
Logger.log('Соединение с базой данныз успешно закрыто')
} }
} }
main() void main();

View File

@ -1,13 +1,14 @@
import { PrismaClient } from '@/prisma/generated' import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'
import { PrismaClient } from '@/prisma/generated';
@Injectable() @Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit() { async onModuleInit() {
await this.$connect() await this.$connect();
} }
async onModuleDestroy() { async onModuleDestroy() {
await this.$disconnect() await this.$disconnect();
} }
} }

View File

@ -1,5 +1,6 @@
import { Global, Module } from '@nestjs/common' import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service'
import { RedisService } from './redis.service';
@Global() @Global()
@Module({ @Module({

View File

@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common' import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config' import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis' import Redis from 'ioredis';
import { ProcessEnv } from '../../shared/types/env'; import { ProcessEnv } from '../../shared/types/env';
@Injectable() @Injectable()
@ -8,6 +9,6 @@ export class RedisService extends Redis {
constructor( constructor(
private readonly configService: ConfigService<ProcessEnv>, private readonly configService: ConfigService<ProcessEnv>,
) { ) {
super(configService.getOrThrow('REDIS_URI')) super(configService.getOrThrow('REDIS_URI'));
} }
} }

View File

@ -1,27 +1,28 @@
import { RedisService } from '@/src/core/redis/redis.service' import { ValidationPipe } from '@nestjs/common';
import { ms, StringValue } from '@/src/shared/util/ms.util' import { ConfigService } from '@nestjs/config';
import { parseBoolean } from '@/src/shared/util/parse-boolean.util' import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common' import RedisStore from 'connect-redis';
import { ConfigService } from '@nestjs/config' import * as cookieParser from 'cookie-parser';
import { NestFactory } from '@nestjs/core' import * as session from 'express-session';
import RedisStore from 'connect-redis' import * as graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js';
import * as cookieParser from 'cookie-parser'
import { CoreModule } from '@/src/core/core.module' import { CoreModule } from '@/src/core/core.module';
import * as session from 'express-session' import { RedisService } from '@/src/core/redis/redis.service';
import * as graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js' import { ms } from '@/src/shared/util/ms.util';
import { parseBoolean } from '@/src/shared/util/parse-boolean.util';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(CoreModule, { rawBody: true }) const app = await NestFactory.create(CoreModule, { rawBody: true });
const config = app.get(ConfigService) const config = app.get(ConfigService);
const redis = app.get(RedisService) const redis = app.get(RedisService);
app.use(cookieParser(config.getOrThrow('COOKIE_SECRET'))) app.use(cookieParser(config.getOrThrow('COOKIE_SECRET')));
app.use(config.getOrThrow('GRAPHQL_PREFIX'), graphqlUploadExpress()) app.use(config.getOrThrow('GRAPHQL_PREFIX'), graphqlUploadExpress());
app.useGlobalPipes(new ValidationPipe({ app.useGlobalPipes(new ValidationPipe({
transform: true, transform: true,
})) }));
app.use(session({ app.use(session({
secret: config.getOrThrow('SESSION_SECRET'), secret: config.getOrThrow('SESSION_SECRET'),
@ -39,14 +40,14 @@ async function bootstrap() {
client: redis, client: redis,
prefix: config.getOrThrow('SESSION_FOLDER'), prefix: config.getOrThrow('SESSION_FOLDER'),
}), }),
})) }));
app.enableCors({ app.enableCors({
origin: config.getOrThrow('ALLOWED_ORIGIN'), origin: config.getOrThrow<string>('ALLOWED_ORIGIN'),
credentials: true, credentials: true,
exposedHeaders: ['set-cookie'], exposedHeaders: ['set-cookie'],
}) });
await app.listen(config.getOrThrow('APPLICATION_PORT')) await app.listen(config.getOrThrow('APPLICATION_PORT'));
} }
void bootstrap() void bootstrap();

View File

@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { VerificationService } from '@/src/module/auth/verification/verification.service'; import { VerificationService } from '@/src/module/auth/verification/verification.service';
import { Module } from '@nestjs/common'
import { AccountService } from './account.service' import { AccountResolver } from './account.resolver';
import { AccountResolver } from './account.resolver' import { AccountService } from './account.service';
@Module({ @Module({
providers: [AccountResolver, AccountService, VerificationService], providers: [AccountResolver, AccountService, VerificationService],

View File

@ -1,12 +1,16 @@
import { ChangeEmailInput } from '@/src/module/auth/account/inputs/change-email.input' import {
import { ChangePasswordInput } from '@/src/module/auth/account/inputs/change-password.input' Args, Mutation, Query, Resolver,
import { Authorization } from '@/src/shared/decorators/auth.decorator' } from '@nestjs/graphql';
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
import { User } from '@prisma/generated' import { ChangeEmailInput } from '@/src/module/auth/account/inputs/change-email.input';
import { CreateUserInput } from './inputs/create-user.input' import { ChangePasswordInput } from '@/src/module/auth/account/inputs/change-password.input';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql' import { Authorization } from '@/src/shared/decorators/auth.decorator';
import { AccountService } from './account.service' import { Authorized } from '@/src/shared/decorators/authorized.decorator';
import { UserModel } from './models/user.model' import { User } from '@prisma/generated';
import { AccountService } from './account.service';
import { CreateUserInput } from './inputs/create-user.input';
import { UserModel } from './models/user.model';
@Resolver('Account') @Resolver('Account')
export class AccountResolver { export class AccountResolver {
@ -14,13 +18,13 @@ export class AccountResolver {
@Query(() => UserModel, { name: 'findProfile' }) @Query(() => UserModel, { name: 'findProfile' })
@Authorization() @Authorization()
public me(@Authorized('id') id: UserModel['id']) { public async me(@Authorized('id') id: UserModel['id']) {
return this.accountService.me(id) return this.accountService.me(id);
} }
@Mutation(() => UserModel, { name: 'createUser' }) @Mutation(() => UserModel, { name: 'createUser' })
public async create(@Args('data') input: CreateUserInput) { public async create(@Args('data') input: CreateUserInput) {
return this.accountService.create(input) return this.accountService.create(input);
} }
@Authorization() @Authorization()
@ -29,7 +33,7 @@ export class AccountResolver {
@Args('data') input: ChangeEmailInput, @Args('data') input: ChangeEmailInput,
@Authorized() user: User, @Authorized() user: User,
) { ) {
return this.accountService.changeEmail(user, input) return this.accountService.changeEmail(user, input);
} }
@Authorization() @Authorization()
@ -38,6 +42,6 @@ export class AccountResolver {
@Args('data') input: ChangePasswordInput, @Args('data') input: ChangePasswordInput,
@Authorized() user: User, @Authorized() user: User,
) { ) {
return this.accountService.changePassword(user, input) return this.accountService.changePassword(user, input);
} }
} }

View File

@ -1,11 +1,12 @@
import { User } from '@/prisma/generated' import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { PrismaService } from '@/src/core/prisma/prisma.service' import { hash, verify } from 'argon2';
import { ChangeEmailInput } from '@/src/module/auth/account/inputs/change-email.input'
import { ChangePasswordInput } from '@/src/module/auth/account/inputs/change-password.input' import { User } from '@/prisma/generated';
import { CreateUserInput } from '@/src/module/auth/account/inputs/create-user.input' import { PrismaService } from '@/src/core/prisma/prisma.service';
import { VerificationService } from '@/src/module/auth/verification/verification.service' import { ChangeEmailInput } from '@/src/module/auth/account/inputs/change-email.input';
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common' import { ChangePasswordInput } from '@/src/module/auth/account/inputs/change-password.input';
import { hash, verify } from 'argon2' import { CreateUserInput } from '@/src/module/auth/account/inputs/create-user.input';
import { VerificationService } from '@/src/module/auth/verification/verification.service';
@Injectable() @Injectable()
export class AccountService { export class AccountService {
@ -20,29 +21,29 @@ export class AccountService {
where: { where: {
id, id,
}, },
}) });
} }
public async create(input: CreateUserInput) { public async create(input: CreateUserInput) {
const { email, name, password } = input const { email, name, password } = input;
const isUserNameExists = await this.prismaService.user.findUnique({ const isUserNameExists = await this.prismaService.user.findUnique({
where: { where: {
name, name,
}, },
}) });
if (isUserNameExists) { if (isUserNameExists) {
throw new ConflictException('Пользователь с таким именем уже существует') throw new ConflictException('Пользователь с таким именем уже существует');
} }
const isUserEmailExists = await this.prismaService.user.findUnique({ const isUserEmailExists = await this.prismaService.user.findUnique({
where: { where: {
email, email,
}, },
}) });
if (isUserEmailExists) { if (isUserEmailExists) {
throw new ConflictException('Пользователь с такоей почтой уже существует') throw new ConflictException('Пользователь с такоей почтой уже существует');
} }
const user = await this.prismaService.user.create({ const user = await this.prismaService.user.create({
@ -57,15 +58,15 @@ export class AccountService {
}, },
}, },
}, },
}) });
await this.verificationService.sendVerificationToken(user) await this.verificationService.sendVerificationToken(user);
return user return user;
} }
public async changeEmail(user: User, input: ChangeEmailInput) { public async changeEmail(user: User, input: ChangeEmailInput) {
const { email } = input const { email } = input;
return this.prismaService.user.update({ return this.prismaService.user.update({
where: { where: {
@ -74,15 +75,15 @@ export class AccountService {
data: { data: {
email, email,
}, },
}) });
} }
public async changePassword(user: User, input: ChangePasswordInput) { public async changePassword(user: User, input: ChangePasswordInput) {
const { newPassword, oldPassword } = input const { newPassword, oldPassword } = input;
const isCorrectPassword = await verify(user.password, oldPassword) const isCorrectPassword = await verify(user.password, oldPassword);
if (!isCorrectPassword) { if (!isCorrectPassword) {
throw new BadRequestException('Неверный пароль') throw new BadRequestException('Неверный пароль');
} }
return this.prismaService.user.update({ return this.prismaService.user.update({
@ -92,6 +93,6 @@ export class AccountService {
data: { data: {
password: await hash(newPassword), password: await hash(newPassword),
}, },
}) });
} }
} }

View File

@ -1,5 +1,5 @@
import { Field, InputType } from '@nestjs/graphql' import { Field, InputType } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator' import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
@InputType() @InputType()
export class ChangeEmailInput { export class ChangeEmailInput {
@ -7,5 +7,5 @@ export class ChangeEmailInput {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@IsEmail() @IsEmail()
email: string email: string;
} }

View File

@ -1,5 +1,5 @@
import { Field, InputType } from '@nestjs/graphql' import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString, MinLength } from 'class-validator' import { IsNotEmpty, IsString, MinLength } from 'class-validator';
@InputType() @InputType()
export class ChangePasswordInput { export class ChangePasswordInput {
@ -7,11 +7,11 @@ export class ChangePasswordInput {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MinLength(8) @MinLength(8)
oldPassword: string oldPassword: string;
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MinLength(8) @MinLength(8)
newPassword: string newPassword: string;
} }

View File

@ -1,5 +1,7 @@
import { Field, InputType } from '@nestjs/graphql' import { Field, InputType } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, IsString, Matches, MinLength } from 'class-validator' import {
IsEmail, IsNotEmpty, IsString, Matches, MinLength,
} from 'class-validator';
@InputType() @InputType()
export class CreateUserInput { export class CreateUserInput {
@ -7,17 +9,17 @@ export class CreateUserInput {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@Matches(/^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/) @Matches(/^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/)
name: string name: string;
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@IsEmail() @IsEmail()
email: string email: string;
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MinLength(8) @MinLength(8)
password: string password: string;
} }

View File

@ -1,11 +1,12 @@
import { UserModel } from '@/src/module/auth/account/models/user.model' import { Field, ObjectType } from '@nestjs/graphql';
import { Field, ObjectType } from '@nestjs/graphql'
import { UserModel } from './user.model';
@ObjectType() @ObjectType()
export class AuthModel { export class AuthModel {
@Field(() => UserModel, { nullable: true }) @Field(() => UserModel, { nullable: true })
public user?: UserModel public user?: UserModel;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
public message: string public message: string;
} }

View File

@ -1,76 +1,77 @@
import { SocialLinkModel } from '@/src/module/auth/profile/inputs/models/social-link.model' import { Field, ID, ObjectType } from '@nestjs/graphql';
import { FollowModel } from '@/src/module/follow/model/follow.model'
import { NotificationSettingsModel } from '@/src/module/notification/models/notification-settings.model' import { FollowModel } from '@/src/module/follow';
import { NotificationModel } from '@/src/module/notification/models/notification.model' import { NotificationModel, NotificationSettingsModel } from '@/src/module/notification';
import { StreamModel } from '@/src/module/stream/models/stream.model' import { StreamModel } from '@/src/module/stream/models/stream.model';
import { Field, ID, ObjectType } from '@nestjs/graphql' import { User } from '@prisma/generated';
import { User } from '@prisma/generated'
import { SocialLinkModel } from '../../profile/models/social-link.model';
@ObjectType() @ObjectType()
export class UserModel implements User { export class UserModel implements User {
@Field(() => ID) @Field(() => ID)
id: string id: string;
@Field(() => String) @Field(() => String)
email: string email: string;
@Field(() => String) @Field(() => String)
password: string password: string;
@Field(() => String) @Field(() => String)
name: string name: string;
@Field(() => String) @Field(() => String)
displayName: string displayName: string;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
avatar: string avatar: string;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
bio: string bio: string;
@Field(() => Boolean) @Field(() => Boolean)
isEmailVerified: boolean isEmailVerified: boolean;
@Field(() => Boolean) @Field(() => Boolean)
isVerified: boolean isVerified: boolean;
@Field(() => Boolean) @Field(() => Boolean)
isTotpEnabled: boolean isTotpEnabled: boolean;
@Field(() => Boolean) @Field(() => Boolean)
isDeactivated: boolean isDeactivated: boolean;
@Field(() => Date, { nullable: true }) @Field(() => Date, { nullable: true })
deactivatedAt: Date deactivatedAt: Date;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
totpSecret: string totpSecret: string;
@Field(() => [SocialLinkModel]) @Field(() => [SocialLinkModel])
socialLink: SocialLinkModel[] socialLink: SocialLinkModel[];
@Field(() => StreamModel) @Field(() => StreamModel)
stream: StreamModel stream: StreamModel;
@Field(() => [FollowModel]) @Field(() => [FollowModel])
followers: FollowModel[] followers: FollowModel[];
@Field(() => [FollowModel]) @Field(() => [FollowModel])
followings: FollowModel[] followings: FollowModel[];
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
telegramId: string telegramId: string;
@Field(() => [NotificationModel]) @Field(() => [NotificationModel])
notification: NotificationModel[] notification: NotificationModel[];
@Field(() => NotificationSettingsModel) @Field(() => NotificationSettingsModel)
notificationSettings: NotificationSettingsModel notificationSettings: NotificationSettingsModel;
@Field(() => Date) @Field(() => Date)
createdAt: Date createdAt: Date;
@Field(() => Date) @Field(() => Date)
updatedAt: Date updatedAt: Date;
} }

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DeactivateService } from './deactivate.service';
import { DeactivateResolver } from './deactivate.resolver'; import { DeactivateResolver } from './deactivate.resolver';
import { DeactivateService } from './deactivate.service';
@Module({ @Module({
providers: [DeactivateResolver, DeactivateService], providers: [DeactivateResolver, DeactivateService],

View File

@ -1,12 +1,16 @@
import { AuthModel } from '@/src/module/auth/account/models/auth.model' import {
import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input' Args, Context, Mutation, Resolver,
import { Authorization } from '@/src/shared/decorators/auth.decorator' } from '@nestjs/graphql';
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
import { UserAgent } from '@/src/shared/decorators/user-agent.decorator' import { AuthModel } from '@/src/module/auth/account/models/auth.model';
import { GqlContext } from '@/src/shared/types/gql-context.types' import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input';
import { Args, Context, Mutation, Resolver } from '@nestjs/graphql' import { Authorization } from '@/src/shared/decorators/auth.decorator';
import { User } from '@prisma/generated' import { Authorized } from '@/src/shared/decorators/authorized.decorator';
import { DeactivateService } from './deactivate.service' import { UserAgent } from '@/src/shared/decorators/user-agent.decorator';
import { GqlContext } from '@/src/shared/types/gql-context.types';
import { User } from '@prisma/generated';
import { DeactivateService } from './deactivate.service';
@Resolver('Deactivate') @Resolver('Deactivate')
export class DeactivateResolver { export class DeactivateResolver {
@ -20,6 +24,6 @@ export class DeactivateResolver {
@UserAgent() userAgent: string, @UserAgent() userAgent: string,
@Authorized() user: User, @Authorized() user: User,
) { ) {
return this.deactivateService.deactivate(req, input, user, userAgent) return this.deactivateService.deactivate(req, input, user, userAgent);
} }
} }

View File

@ -1,16 +1,19 @@
import { PrismaService } from '@/src/core/prisma/prisma.service' import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input' import { ConfigService } from '@nestjs/config';
import { MailService } from '@/src/module/libs/mail/mail.service' import { verify } from 'argon2';
import { PrismaService } from '@/src/core/prisma/prisma.service';
import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input';
import { MailService } from '@/src/module/libs/mail/mail.service';
import { TelegramService } from '@/src/module/libs/telegram/telegram.service'; import { TelegramService } from '@/src/module/libs/telegram/telegram.service';
import { generateToken } from '@/src/shared/util/generate-token.util' import { generateToken } from '@/src/shared/util/generate-token.util';
import { getSessionMetadata } from '@/src/shared/util/session-metadata.util' import { getSessionMetadata } from '@/src/shared/util/session-metadata.util';
import { destroySession } from '@/src/shared/util/session.util' import { destroySession } from '@/src/shared/util/session.util';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' import { TokenType, User } from '@prisma/generated';
import { ConfigService } from '@nestjs/config'
import { TokenType, User } from '@prisma/generated' import { ProcessEnv } from '../../../shared/types/env';
import { verify } from 'argon2'
import { Request } from 'express' import type { Request } from 'express';
import { ProcessEnv } from '../../../shared/types/env'
@Injectable() @Injectable()
export class DeactivateService { export class DeactivateService {
@ -18,66 +21,32 @@ export class DeactivateService {
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly configService: ConfigService<ProcessEnv>, private readonly configService: ConfigService<ProcessEnv>,
private readonly mailService: MailService, private readonly mailService: MailService,
private readonly telegramService: TelegramService private readonly telegramService: TelegramService,
) { ) {
} }
public async deactivate(req: Request, input: DeactivateAccountInput, user: User, userAgent: string) { public async deactivate(req: Request, input: DeactivateAccountInput, user: User, userAgent: string) {
const { email, password, pin } = input const { email, password, pin } = input;
if (email !== user.email) { if (email !== user.email) {
throw new BadRequestException('Неверная почта') throw new BadRequestException('Неверная почта');
} }
const isValidPassword = await verify(user.password, password) const isValidPassword = await verify(user.password, password);
if (!isValidPassword) { if (!isValidPassword) {
throw new BadRequestException('Неверный пароль') throw new BadRequestException('Неверный пароль');
} }
if (!pin) { if (!pin) {
await this.sendDeactivationToken(req, user, userAgent) await this.sendDeactivationToken(req, user, userAgent);
return { message: 'Требуется ввести код подтверждения' }
return { message: 'Требуется ввести код подтверждения' };
} }
await this.validateDeactivateToken(req, pin) await this.validateDeactivateToken(req, pin);
return { user } return { user };
}
private async validateDeactivateToken(req: Request, token: string) {
const existingToken = await this.prismaService.token.findUnique({
where: { token, type: TokenType.DEACTIVATE_ACCOUNT },
})
if (!existingToken) {
throw new NotFoundException('Токен не найден')
}
const hasExpired = new Date(existingToken.expiresIn) < new Date()
if (hasExpired) {
throw new BadRequestException('Токен истек')
}
await this.prismaService.user.update({
where: {
id: existingToken.userId!, // todo fixme
},
data: {
isDeactivated: true,
deactivatedAt: new Date(),
},
})
await this.prismaService.token.delete({
where: {
id: existingToken.id,
type: TokenType.DEACTIVATE_ACCOUNT,
},
})
return destroySession(req, this.configService)
} }
public async sendDeactivationToken(req: Request, user: User, userAgent: string) { public async sendDeactivationToken(req: Request, user: User, userAgent: string) {
@ -86,14 +55,48 @@ export class DeactivateService {
user, user,
TokenType.DEACTIVATE_ACCOUNT, TokenType.DEACTIVATE_ACCOUNT,
false, false,
) );
const metadata = getSessionMetadata(req, userAgent) const metadata = getSessionMetadata(req, userAgent);
await this.mailService.sendDeactivateToken(user.email, deactivateToken.token, metadata) await this.mailService.sendDeactivateToken(user.email, deactivateToken.token, metadata);
if (deactivateToken.user?.notificationSettings?.telegramNotifications && deactivateToken.user?.telegramId) { if (deactivateToken.user?.notificationSettings?.telegramNotifications && deactivateToken.user.telegramId) {
await this.telegramService.sendDeactivateToken(deactivateToken.user.telegramId, deactivateToken.token, metadata) await this.telegramService.sendDeactivateToken(deactivateToken.user.telegramId, deactivateToken.token, metadata);
} }
return true
return true;
}
private async validateDeactivateToken(req: Request, token: string) {
const existingToken = await this.prismaService.token.findUnique({
where: { token, type: TokenType.DEACTIVATE_ACCOUNT },
});
if (!existingToken?.userId) {
throw new NotFoundException('Токен не найден');
}
const hasExpired = new Date(existingToken.expiresIn) < new Date();
if (hasExpired) {
throw new BadRequestException('Токен истек');
}
await this.prismaService.user.update({
where: { id: existingToken.userId },
data: {
isDeactivated: true,
deactivatedAt: new Date(),
},
});
await this.prismaService.token.delete({
where: {
id: existingToken.id,
type: TokenType.DEACTIVATE_ACCOUNT,
},
});
return destroySession(req, this.configService);
} }
} }

View File

@ -1,5 +1,7 @@
import { Field, InputType } from '@nestjs/graphql' import { Field, InputType } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, IsOptional, IsString, Length, MinLength } from 'class-validator' import {
IsEmail, IsNotEmpty, IsOptional, IsString, Length, MinLength,
} from 'class-validator';
@InputType() @InputType()
export class DeactivateAccountInput { export class DeactivateAccountInput {
@ -7,18 +9,18 @@ export class DeactivateAccountInput {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@IsEmail() @IsEmail()
email: string email: string;
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MinLength(8) @MinLength(8)
password: string password: string;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@IsOptional() @IsOptional()
@Length(6, 6) @Length(6, 6)
pin: string pin: string;
} }

View File

@ -0,0 +1,7 @@
export { TotpModel } from './totp/models/totp.model';
export { UserModel } from './account/models/user.model';
export { AuthModel } from './account/models/auth.model';
export {
DeviceModel, LocationModel, SessionMetadataModel, SessionModel,
} from './session/models/session.model';
export { NewPasswordInput } from './password-recovery/inputs/new-password.input';

View File

@ -1,8 +1,11 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsNotEmpty, IsString, IsUUID, MinLength, Validate,
} from 'class-validator';
import { import {
IsPasswordMatchingConstraintDecorator, IsPasswordMatchingConstraintDecorator,
} from '@/src/shared/decorators/is-password-matching-constraint.decorator' } from '@/src/shared/decorators/is-password-matching-constraint.decorator';
import { Field, InputType } from '@nestjs/graphql'
import { IsNotEmpty, IsString, IsUUID, MinLength, Validate } from 'class-validator'
@InputType() @InputType()
export class NewPasswordInput { export class NewPasswordInput {
@ -10,17 +13,17 @@ export class NewPasswordInput {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MinLength(8) @MinLength(8)
password: string password: string;
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MinLength(8) @MinLength(8)
@Validate(IsPasswordMatchingConstraintDecorator) @Validate(IsPasswordMatchingConstraintDecorator)
passwordRepeat: string passwordRepeat: string;
@Field(() => String) @Field(() => String)
@IsUUID('4') @IsUUID('4')
@IsNotEmpty() @IsNotEmpty()
token: string token: string;
} }

View File

@ -3,9 +3,8 @@ import { IsEmail, IsNotEmpty } from 'class-validator';
@InputType() @InputType()
export class ResetPasswordInput { export class ResetPasswordInput {
@Field(() => String) @Field(() => String)
@IsNotEmpty() @IsNotEmpty()
@IsEmail() @IsEmail()
email: string email: string;
} }

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common';
import { PasswordRecoveryService } from './password-recovery.service'
import { PasswordRecoveryResolver } from './password-recovery.resolver' import { PasswordRecoveryResolver } from './password-recovery.resolver';
import { PasswordRecoveryService } from './password-recovery.service';
@Module({ @Module({
providers: [PasswordRecoveryResolver, PasswordRecoveryService], providers: [PasswordRecoveryResolver, PasswordRecoveryService],

View File

@ -1,9 +1,13 @@
import { NewPasswordInput } from '@/src/module/auth/password-recovery/inputs/new-password.input' import {
import { ResetPasswordInput } from '@/src/module/auth/password-recovery/inputs/reset-password.input' Args, Context, Mutation, Resolver,
import { UserAgent } from '@/src/shared/decorators/user-agent.decorator' } from '@nestjs/graphql';
import { GqlContext } from '@/src/shared/types/gql-context.types'
import { Args, Context, Mutation, Resolver } from '@nestjs/graphql' import { NewPasswordInput } from '@/src/module/auth/password-recovery/inputs/new-password.input';
import { PasswordRecoveryService } from './password-recovery.service' import { ResetPasswordInput } from '@/src/module/auth/password-recovery/inputs/reset-password.input';
import { UserAgent } from '@/src/shared/decorators/user-agent.decorator';
import { GqlContext } from '@/src/shared/types/gql-context.types';
import { PasswordRecoveryService } from './password-recovery.service';
@Resolver('PasswordRecovery') @Resolver('PasswordRecovery')
export class PasswordRecoveryResolver { export class PasswordRecoveryResolver {
@ -15,11 +19,11 @@ export class PasswordRecoveryResolver {
@Args('data') input: ResetPasswordInput, @Args('data') input: ResetPasswordInput,
@UserAgent() userAgent: string, @UserAgent() userAgent: string,
) { ) {
return this.passwordRecoveryService.resetPassword(req, input, userAgent) return this.passwordRecoveryService.resetPassword(req, input, userAgent);
} }
@Mutation(() => Boolean, { name: 'setNewPassword' }) @Mutation(() => Boolean, { name: 'setNewPassword' })
public async setNewPassword(@Args('data') input: NewPasswordInput) { public async setNewPassword(@Args('data') input: NewPasswordInput) {
return this.passwordRecoveryService.setNewPassword(input) return this.passwordRecoveryService.setNewPassword(input);
} }
} }

View File

@ -1,59 +1,61 @@
import { PrismaService } from '@/src/core/prisma/prisma.service' import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { NewPasswordInput } from '@/src/module/auth/password-recovery/inputs/new-password.input' import { hash } from 'argon2';
import { ResetPasswordInput } from '@/src/module/auth/password-recovery/inputs/reset-password.input'
import { MailService } from '@/src/module/libs/mail/mail.service' import { PrismaService } from '@/src/core/prisma/prisma.service';
import { NewPasswordInput } from '@/src/module/auth/password-recovery/inputs/new-password.input';
import { ResetPasswordInput } from '@/src/module/auth/password-recovery/inputs/reset-password.input';
import { MailService } from '@/src/module/libs/mail/mail.service';
import { TelegramService } from '@/src/module/libs/telegram/telegram.service'; import { TelegramService } from '@/src/module/libs/telegram/telegram.service';
import { generateToken } from '@/src/shared/util/generate-token.util' import { generateToken } from '@/src/shared/util/generate-token.util';
import { getSessionMetadata } from '@/src/shared/util/session-metadata.util' import { getSessionMetadata } from '@/src/shared/util/session-metadata.util';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' import { TokenType } from '@prisma/generated';
import { TokenType } from '@prisma/generated'
import { hash } from 'argon2' import type { Request } from 'express';
import { Request } from 'express'
@Injectable() @Injectable()
export class PasswordRecoveryService { export class PasswordRecoveryService {
constructor( constructor(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly mailService: MailService, private readonly mailService: MailService,
private readonly telegramService: TelegramService private readonly telegramService: TelegramService,
) { ) {
} }
public async resetPassword(req: Request, input: ResetPasswordInput, userAgent: string) { public async resetPassword(req: Request, input: ResetPasswordInput, userAgent: string) {
const { email } = input const { email } = input;
const user = await this.prismaService.user.findFirst({ const user = await this.prismaService.user.findFirst({
where: { email }, where: { email },
include: { notificationSettings: true }, include: { notificationSettings: true },
}) });
if (!user) { if (!user) {
throw new NotFoundException('Пользователь с такой почтой не найден') throw new NotFoundException('Пользователь с такой почтой не найден');
} }
const resetToken = await generateToken(this.prismaService, user, TokenType.PASSWORD_RESET) const resetToken = await generateToken(this.prismaService, user, TokenType.PASSWORD_RESET);
const metadata = getSessionMetadata(req, userAgent) const metadata = getSessionMetadata(req, userAgent);
await this.mailService.sendPasswordResetToken(user.email, resetToken.token, metadata) await this.mailService.sendPasswordResetToken(user.email, resetToken.token, metadata);
if (resetToken.user?.notificationSettings?.telegramNotifications && resetToken?.user.telegramId) { if (resetToken.user?.notificationSettings?.telegramNotifications && resetToken?.user.telegramId) {
await this.telegramService.sendPasswordResetToken(resetToken.user.telegramId, resetToken.token, metadata) await this.telegramService.sendPasswordResetToken(resetToken.user.telegramId, resetToken.token, metadata);
} }
return true return true;
} }
public async setNewPassword(input: NewPasswordInput) { public async setNewPassword(input: NewPasswordInput) {
const { password, token } = input const { password, token } = input;
const existingToken = await this.prismaService.token.findUnique({ const existingToken = await this.prismaService.token.findUnique({
where: { token, type: TokenType.PASSWORD_RESET }, where: { token, type: TokenType.PASSWORD_RESET },
}) });
if (!existingToken) { if (!existingToken) {
throw new NotFoundException('Токен не найден') throw new NotFoundException('Токен не найден');
} }
const hasExpired = new Date(existingToken.expiresIn) < new Date() const hasExpired = new Date(existingToken.expiresIn) < new Date();
if (hasExpired) { if (hasExpired) {
throw new BadRequestException('Токен истек') throw new BadRequestException('Токен истек');
} }
await this.prismaService.user.update({ await this.prismaService.user.update({
@ -63,15 +65,15 @@ export class PasswordRecoveryService {
data: { data: {
password: await hash(password), password: await hash(password),
}, },
}) });
await this.prismaService.token.delete({ await this.prismaService.token.delete({
where: { where: {
id: existingToken.id, id: existingToken.id,
type: TokenType.PASSWORD_RESET, type: TokenType.PASSWORD_RESET,
}, },
}) });
return true return true;
} }
} }

View File

@ -1,5 +1,7 @@
import { Field, InputType } from '@nestjs/graphql' import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator' import {
IsNotEmpty, IsString, Matches, MaxLength,
} from 'class-validator';
@InputType() @InputType()
export class ChangeProfileInfoInput { export class ChangeProfileInfoInput {
@ -7,16 +9,16 @@ export class ChangeProfileInfoInput {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@Matches(/^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/) @Matches(/^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/)
name: string name: string;
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
displayName: string displayName: string;
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MaxLength(300) @MaxLength(300)
bio: string bio: string;
} }

View File

@ -1,27 +1,28 @@
import { UserModel } from '@/src/module/auth/account/models/user.model' import { Field, ID, ObjectType } from '@nestjs/graphql';
import { Field, ID, ObjectType } from '@nestjs/graphql'
import { SocialLink } from '@prisma/generated' import { UserModel } from '@/src/module/auth/account/models/user.model';
import { SocialLink } from '@prisma/generated';
@ObjectType() @ObjectType()
export class SocialLinkModel implements SocialLink { export class SocialLinkModel implements SocialLink {
@Field(() => ID) @Field(() => ID)
id: string id: string;
@Field(() => ID) @Field(() => ID)
userId: UserModel['id'] userId: UserModel['id'];
@Field(() => String) @Field(() => String)
title: string title: string;
@Field(() => String) @Field(() => String)
url: string url: string;
@Field(() => Number) @Field(() => Number)
position: number position: number;
@Field(() => Date) @Field(() => Date)
updatedAt: Date updatedAt: Date;
@Field(() => Date) @Field(() => Date)
createdAt: Date createdAt: Date;
} }

View File

@ -1,18 +1,20 @@
import { Field, InputType } from '@nestjs/graphql' import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsNumber, IsString, IsUrl } from 'class-validator' import {
IsNotEmpty, IsNumber, IsString, IsUrl,
} from 'class-validator';
@InputType() @InputType()
export class SocialLinkInput { export class SocialLinkInput {
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
title: string title: string;
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@IsUrl() @IsUrl()
url: string url: string;
} }
@InputType() @InputType()
@ -20,10 +22,10 @@ export class SocialLinkOrderInput {
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
id: string id: string;
@Field(() => Number) @Field(() => Number)
@IsNumber() @IsNumber()
@IsNotEmpty() @IsNotEmpty()
position: number position: number;
} }

View File

@ -0,0 +1,29 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { SocialLink } from '@prisma/generated';
import { UserModel } from '../../account/models/user.model';
@ObjectType()
export class SocialLinkModel implements SocialLink {
@Field(() => ID)
id: string;
@Field(() => ID)
userId: UserModel['id'];
@Field(() => String)
title: string;
@Field(() => String)
url: string;
@Field(() => Number)
position: number;
@Field(() => Date)
updatedAt: Date;
@Field(() => Date)
createdAt: Date;
}

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common';
import { ProfileService } from './profile.service'
import { ProfileResolver } from './profile.resolver' import { ProfileResolver } from './profile.resolver';
import { ProfileService } from './profile.service';
@Module({ @Module({
providers: [ProfileResolver, ProfileService], providers: [ProfileResolver, ProfileService],

View File

@ -1,18 +1,22 @@
import { UserModel } from '@/src/module/auth/account/models/user.model' import {
import { ChangeProfileInfoInput } from '@/src/module/auth/profile/inputs/change-profile-info.input' Args, Mutation, Query, Resolver,
import { SocialLinkModel } from '@/src/module/auth/profile/inputs/models/social-link.model' } from '@nestjs/graphql';
import * as GraphQLUpload from 'graphql-upload/GraphQLUpload.js';
import * as Upload from 'graphql-upload/Upload.js';
import { User } from '@/prisma/generated';
import { UserModel } from '@/src/module/auth/account/models/user.model';
import { ChangeProfileInfoInput } from '@/src/module/auth/profile/inputs/change-profile-info.input';
import { import {
SocialLinkInput, SocialLinkInput,
SocialLinkOrderInput, SocialLinkOrderInput,
} from '@/src/module/auth/profile/inputs/social-link.input' } from '@/src/module/auth/profile/inputs/social-link.input';
import { Authorization } from '@/src/shared/decorators/auth.decorator' import { Authorization } from '@/src/shared/decorators/auth.decorator';
import { Authorized } from '@/src/shared/decorators/authorized.decorator' import { Authorized } from '@/src/shared/decorators/authorized.decorator';
import { FileValidationPipe } from '@/src/shared/pipes/file-validation.pipe' import { FileValidationPipe } from '@/src/shared/pipes/file-validation.pipe';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'
import { ProfileService } from './profile.service' import { SocialLinkModel } from './models/social-link.model';
import { User } from '@/prisma/generated' import { ProfileService } from './profile.service';
import * as Upload from 'graphql-upload/Upload.js'
import * as GraphQLUpload from 'graphql-upload/GraphQLUpload.js'
@Resolver('Profile') @Resolver('Profile')
export class ProfileResolver { export class ProfileResolver {
@ -24,13 +28,13 @@ export class ProfileResolver {
@Authorized() user: User, @Authorized() user: User,
@Args('avatar', { type: () => GraphQLUpload }, FileValidationPipe) file: Upload, @Args('avatar', { type: () => GraphQLUpload }, FileValidationPipe) file: Upload,
) { ) {
return this.profileService.changeAvatar(user, file) return this.profileService.changeAvatar(user, file);
} }
@Authorization() @Authorization()
@Mutation(() => Boolean, { name: 'removeProfileAvatar' }) @Mutation(() => Boolean, { name: 'removeProfileAvatar' })
public async removeAvatar(@Authorized() user: User) { public async removeAvatar(@Authorized() user: User) {
return this.profileService.removeAvatar(user) return this.profileService.removeAvatar(user);
} }
@Authorization() @Authorization()
@ -39,7 +43,7 @@ export class ProfileResolver {
@Authorized() user: User, @Authorized() user: User,
@Args('data') input: ChangeProfileInfoInput, @Args('data') input: ChangeProfileInfoInput,
) { ) {
return this.profileService.changeInfo(user, input) return this.profileService.changeInfo(user, input);
} }
@Authorization() @Authorization()
@ -48,7 +52,7 @@ export class ProfileResolver {
@Authorized() user: User, @Authorized() user: User,
@Args('data') input: SocialLinkInput, @Args('data') input: SocialLinkInput,
) { ) {
return this.profileService.createSocialLink(user, input) return this.profileService.createSocialLink(user, input);
} }
@Authorization() @Authorization()
@ -56,7 +60,7 @@ export class ProfileResolver {
public async reorderSocialLinks( public async reorderSocialLinks(
@Args('list', { type: () => [SocialLinkOrderInput] }) list: SocialLinkOrderInput[], @Args('list', { type: () => [SocialLinkOrderInput] }) list: SocialLinkOrderInput[],
) { ) {
return this.profileService.reorderSocialLinks(list) return this.profileService.reorderSocialLinks(list);
} }
@Authorization() @Authorization()
@ -65,7 +69,7 @@ export class ProfileResolver {
@Args('id') id: string, @Args('id') id: string,
@Args('data') input: SocialLinkInput, @Args('data') input: SocialLinkInput,
) { ) {
return this.profileService.updateSocialLink(id, input) return this.profileService.updateSocialLink(id, input);
} }
@Authorization() @Authorization()
@ -73,12 +77,12 @@ export class ProfileResolver {
public async removeSocialLink( public async removeSocialLink(
@Args('id') id: string, @Args('id') id: string,
) { ) {
return this.profileService.removeSocialLink(id) return this.profileService.removeSocialLink(id);
} }
@Authorization() @Authorization()
@Query(() => [SocialLinkModel], { name: 'findSocialLinks' }) @Query(() => [SocialLinkModel], { name: 'findSocialLinks' })
public async findSocialLink(@Authorized() user: User) { public async findSocialLink(@Authorized() user: User) {
return this.profileService.findSocialLink(user) return this.profileService.findSocialLink(user);
} }
} }

View File

@ -1,14 +1,16 @@
import { PrismaService } from '@/src/core/prisma/prisma.service' import { ConflictException, Injectable } from '@nestjs/common';
import { ChangeProfileInfoInput } from '@/src/module/auth/profile/inputs/change-profile-info.input' import * as Upload from 'graphql-upload/Upload.js';
import sharp from 'sharp';
import { SocialLink, User } from '@/prisma/generated';
import { PrismaService } from '@/src/core/prisma/prisma.service';
import { ChangeProfileInfoInput } from '@/src/module/auth/profile/inputs/change-profile-info.input';
import { import {
SocialLinkInput, SocialLinkInput,
SocialLinkOrderInput, SocialLinkOrderInput,
} from '@/src/module/auth/profile/inputs/social-link.input' } from '@/src/module/auth/profile/inputs/social-link.input';
import { ConflictException, Injectable } from '@nestjs/common'
import sharp from 'sharp' import { StorageService } from '../../libs/storage/storage.service';
import { StorageService } from '../../libs/storage/storage.service'
import { SocialLink, User } from '@/prisma/generated'
import * as Upload from 'graphql-upload/Upload.js'
@Injectable() @Injectable()
export class ProfileService { export class ProfileService {
@ -20,70 +22,71 @@ export class ProfileService {
public async changeAvatar(user: User, file: Upload) { public async changeAvatar(user: User, file: Upload) {
if (user.avatar) { if (user.avatar) {
await this.storageService.remove(user.avatar) await this.storageService.remove(user.avatar);
} }
const chunks: Buffer[] = [] const chunks: Buffer[] = [];
for await (const chunk of file.createReadStream()) { for await (const chunk of file.createReadStream()) {
chunks.push(chunk) chunks.push(chunk);
} }
const buffer = Buffer.concat(chunks) const buffer = Buffer.concat(chunks);
const fileName = `/channels/${user.name}.webp` const fileName = `/channels/${user.name}.webp`;
const processedBuffer = await sharp(buffer, { animated: file.filename.endsWith('.gif') }) const processedBuffer = await sharp(buffer, { animated: file.filename.endsWith('.gif') })
.resize(512, 512) .resize(512, 512)
.webp() .webp()
.toBuffer() .toBuffer();
await this.storageService.upload(processedBuffer, fileName, 'image/webp') await this.storageService.upload(processedBuffer, fileName, 'image/webp');
await this.prismaService.user.update({ await this.prismaService.user.update({
where: { id: user.id }, where: { id: user.id },
data: { avatar: fileName }, data: { avatar: fileName },
}) });
return true return true;
} }
public async removeAvatar(user: User) { public async removeAvatar(user: User) {
if (!user.avatar) { if (!user.avatar) {
return true return true;
} }
await this.storageService.remove(user.avatar) await this.storageService.remove(user.avatar);
await this.prismaService.user.update({ await this.prismaService.user.update({
where: { id: user.id }, where: { id: user.id },
data: { avatar: null }, data: { avatar: null },
}) });
return true
return true;
} }
public async changeInfo(user: User, input: ChangeProfileInfoInput) { public async changeInfo(user: User, input: ChangeProfileInfoInput) {
const { bio = user.bio, displayName = user.displayName, name = user.name } = input const { bio = user.bio, displayName = user.displayName, name = user.name } = input;
const existingUser = await this.prismaService.user.findUnique({ const existingUser = await this.prismaService.user.findUnique({
where: { name }, where: { name },
}) });
if (existingUser && user.name !== name) { if (existingUser && user.name !== name) {
throw new ConflictException('Пользователь с таким именем уже существует') throw new ConflictException('Пользователь с таким именем уже существует');
} }
return this.prismaService.user.update({ return this.prismaService.user.update({
where: { id: user.id }, where: { id: user.id },
data: { bio, displayName, name }, data: { bio, displayName, name },
}) });
} }
public async createSocialLink(user: User, input: SocialLinkInput) { public async createSocialLink(user: User, input: SocialLinkInput) {
const { title, url } = input const { title, url } = input;
const lastSocialLink = await this.prismaService.socialLink.findFirst({ const lastSocialLink = await this.prismaService.socialLink.findFirst({
where: { userId: user.id }, where: { userId: user.id },
orderBy: { position: 'desc' }, orderBy: { position: 'desc' },
}) });
return this.prismaService.socialLink.create({ return this.prismaService.socialLink.create({
data: { data: {
@ -96,45 +99,44 @@ export class ProfileService {
}, },
}, },
}, },
}) });
} }
public async findSocialLink(user: User) { public async findSocialLink(user: User) {
return this.prismaService.socialLink.findMany({ return this.prismaService.socialLink.findMany({
where: { userId: user.id }, where: { userId: user.id },
}) });
} }
public async reorderSocialLinks(list: SocialLinkOrderInput[]) { public async reorderSocialLinks(list: SocialLinkOrderInput[]) {
if (list.length === 0) { if (list.length === 0) {
return return;
} }
const updatePromises = list.map((socialLink) => { const updatePromises = list.map(async (socialLink) => this.prismaService.socialLink.update({
return this.prismaService.socialLink.update({
where: { id: socialLink.id }, where: { id: socialLink.id },
data: { position: Number(socialLink.position) }, data: { position: Number(socialLink.position) },
}) }));
})
await Promise.all(updatePromises) await Promise.all(updatePromises);
return true return true;
} }
public async updateSocialLink(id: SocialLink['id'], input: SocialLinkInput) { public async updateSocialLink(id: SocialLink['id'], input: SocialLinkInput) {
const { title, url } = input const { title, url } = input;
return this.prismaService.socialLink.update({ return this.prismaService.socialLink.update({
where: { id }, where: { id },
data: { title, url }, data: { title, url },
}) });
} }
public async removeSocialLink(id: SocialLink['id']) { public async removeSocialLink(id: SocialLink['id']) {
await this.prismaService.socialLink.delete({ await this.prismaService.socialLink.delete({
where: { id }, where: { id },
}) });
return true return true;
} }
} }

View File

@ -1,23 +1,25 @@
import { Field, InputType } from '@nestjs/graphql' import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString, Length, MinLength } from 'class-validator' import {
IsNotEmpty, IsOptional, IsString, Length, MinLength,
} from 'class-validator';
@InputType() @InputType()
export class LoginInput { export class LoginInput {
@Field() @Field()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
login: string login: string;
@Field() @Field()
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@MinLength(8) @MinLength(8)
password: string password: string;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@IsOptional() @IsOptional()
@Length(6, 6) @Length(6, 6)
pin?: string pin?: string;
} }

View File

@ -1,56 +1,57 @@
import { DeviceInfo, LocationInfo, SessionInfo } from '@/src/shared/types/session-metadata.types' import { Field, ID, ObjectType } from '@nestjs/graphql';
import { Field, ID, ObjectType } from '@nestjs/graphql'
import { DeviceInfo, LocationInfo, SessionInfo } from '@/src/shared/types/session-metadata.types';
@ObjectType() @ObjectType()
export class LocationModel implements LocationInfo { export class LocationModel implements LocationInfo {
@Field(() => String) @Field(() => String)
country: string country: string;
@Field(() => String) @Field(() => String)
city: string city: string;
@Field(() => Number) @Field(() => Number)
latitude: number latitude: number;
@Field(() => Number) @Field(() => Number)
longitude: number longitude: number;
} }
@ObjectType() @ObjectType()
export class DeviceModel implements DeviceInfo { export class DeviceModel implements DeviceInfo {
@Field(() => String) @Field(() => String)
browser: string browser: string;
@Field(() => String) @Field(() => String)
os: string os: string;
@Field(() => String) @Field(() => String)
type: string type: string;
} }
@ObjectType() @ObjectType()
export class SessionMetadataModel implements SessionInfo { export class SessionMetadataModel implements SessionInfo {
@Field(() => LocationModel) @Field(() => LocationModel)
location: LocationModel location: LocationModel;
@Field(() => DeviceModel) @Field(() => DeviceModel)
device: DeviceModel device: DeviceModel;
@Field(() => String) @Field(() => String)
ip: string ip: string;
} }
@ObjectType() @ObjectType()
export class SessionModel { export class SessionModel {
@Field(() => ID) @Field(() => ID)
id: string id: string;
@Field(() => String) @Field(() => String)
userId: string userId: string;
@Field(() => String) @Field(() => String)
createdAt: string createdAt: string;
@Field(() => SessionMetadataModel) @Field(() => SessionMetadataModel)
metadata: SessionMetadataModel metadata: SessionMetadataModel;
} }

View File

@ -1,7 +1,9 @@
import { Module } from '@nestjs/common';
import { VerificationService } from '@/src/module/auth/verification/verification.service'; import { VerificationService } from '@/src/module/auth/verification/verification.service';
import { Module } from '@nestjs/common'
import { SessionService } from './session.service' import { SessionResolver } from './session.resolver';
import { SessionResolver } from './session.resolver' import { SessionService } from './session.service';
@Module({ @Module({
providers: [SessionResolver, SessionService, VerificationService], providers: [SessionResolver, SessionService, VerificationService],

View File

@ -1,12 +1,15 @@
import {
Args, Context, Mutation, Query, Resolver,
} from '@nestjs/graphql';
import { AuthModel } from '@/src/module/auth/account/models/auth.model'; import { AuthModel } from '@/src/module/auth/account/models/auth.model';
import { UserModel } from '@/src/module/auth/account/models/user.model' import { LoginInput } from '@/src/module/auth/session/inputs/login.input';
import { LoginInput } from '@/src/module/auth/session/inputs/login.input' import { Authorization } from '@/src/shared/decorators/auth.decorator';
import { Authorization } from '@/src/shared/decorators/auth.decorator' import { UserAgent } from '@/src/shared/decorators/user-agent.decorator';
import { UserAgent } from '@/src/shared/decorators/user-agent.decorator' import { GqlContext } from '@/src/shared/types/gql-context.types';
import { GqlContext } from '@/src/shared/types/gql-context.types'
import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql' import { SessionModel } from './models/session.model';
import { SessionService } from './session.service' import { SessionService } from './session.service';
import { SessionModel } from './models/session.model'
@Resolver('Session') @Resolver('Session')
export class SessionResolver { export class SessionResolver {
@ -17,7 +20,7 @@ export class SessionResolver {
public async findByUser( public async findByUser(
@Context() { req }: GqlContext, @Context() { req }: GqlContext,
) { ) {
return this.sessionService.findByUser(req) return this.sessionService.findByUser(req);
} }
@Authorization() @Authorization()
@ -25,7 +28,7 @@ export class SessionResolver {
public async findCurrent( public async findCurrent(
@Context() { req }: GqlContext, @Context() { req }: GqlContext,
) { ) {
return this.sessionService.findCurrentSession(req) return this.sessionService.findCurrentSession(req);
} }
@Mutation(() => AuthModel, { name: 'loginUser' }) @Mutation(() => AuthModel, { name: 'loginUser' })
@ -34,26 +37,26 @@ export class SessionResolver {
@Args('data') input: LoginInput, @Args('data') input: LoginInput,
@UserAgent() userAgent: string, @UserAgent() userAgent: string,
) { ) {
return this.sessionService.login(req, input, userAgent) return this.sessionService.login(req, input, userAgent);
} }
@Authorization() @Authorization()
@Mutation(() => Boolean, { name: 'logoutUser' }) @Mutation(() => Boolean, { name: 'logoutUser' })
public async logout(@Context() { req }: GqlContext) { public async logout(@Context() { req }: GqlContext) {
return this.sessionService.logout(req) return this.sessionService.logout(req);
} }
@Mutation(() => Boolean, { name: 'clearSessionCookie' }) @Mutation(() => Boolean, { name: 'clearSessionCookie' })
public clearSession(@Context() { req }: GqlContext) { public clearSession(@Context() { req }: GqlContext) {
return this.sessionService.clearSession(req) return this.sessionService.clearSession(req);
} }
@Authorization() @Authorization()
@Mutation(() => Boolean, { name: 'removeSession' }) @Mutation(() => Boolean, { name: 'removeSession' })
public remove( public async remove(
@Context() { req }: GqlContext, @Context() { req }: GqlContext,
@Args('id') id: string, @Args('id') id: string,
) { ) {
return this.sessionService.remove(req, id) return this.sessionService.remove(req, id);
} }
} }

View File

@ -1,22 +1,25 @@
import { PrismaService } from '@/src/core/prisma/prisma.service'
import { RedisService } from '@/src/core/redis/redis.service'
import { LoginInput } from '@/src/module/auth/session/inputs/login.input'
import { VerificationService } from '@/src/module/auth/verification/verification.service'
import { getSessionMetadata } from '@/src/shared/util/session-metadata.util'
import { destroySession, saveSession } from '@/src/shared/util/session.util'
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
UnauthorizedException, UnauthorizedException,
} from '@nestjs/common' } from '@nestjs/common';
import { ConfigService } from '@nestjs/config' import { ConfigService } from '@nestjs/config';
import { verify } from 'argon2' import { verify } from 'argon2';
import { Request } from 'express' import { SessionData } from 'express-session';
import { SessionData } from 'express-session' import { TOTP } from 'otpauth';
import { TOTP } from 'otpauth'
import { ProcessEnv } from '../../../shared/types/env' import { PrismaService } from '@/src/core/prisma/prisma.service';
import { RedisService } from '@/src/core/redis/redis.service';
import { LoginInput } from '@/src/module/auth/session/inputs/login.input';
import { VerificationService } from '@/src/module/auth/verification/verification.service';
import { getSessionMetadata } from '@/src/shared/util/session-metadata.util';
import { destroySession, saveSession } from '@/src/shared/util/session.util';
import { ProcessEnv } from '../../../shared/types/env';
import type { Request } from 'express';
@Injectable() @Injectable()
export class SessionService { export class SessionService {
@ -28,82 +31,84 @@ export class SessionService {
) {} ) {}
public async findByUser(req: Request) { public async findByUser(req: Request) {
const userId = req.user?.id const userId = req.user?.id;
if (!userId) { if (!userId) {
throw new NotFoundException('Пользователь не найден') throw new NotFoundException('Пользователь не найден');
} }
const keys = await this.redisService.keys('*') const keys = await this.redisService.keys('*');
const userSessions: Request['session'][] = [] const userSessions: Request['session'][] = [];
for (const key of keys) { for (const key of keys) {
const sessionData = await this.redisService.get(key) const sessionData = await this.redisService.get(key);
if (sessionData) { if (sessionData) {
const session = JSON.parse(sessionData) as Request['session'] const session = JSON.parse(sessionData) as Request['session'];
if (session.userId === userId) { if (session.userId === userId) {
userSessions.push({ userSessions.push({
...session, ...session,
id: key.split(':')[1], id: key.split(':')[1],
} as Request['session']) } as Request['session']);
} }
} }
} }
// eslint-disable-next-line @typescript-eslint/ban-ts-comment -- todo fixme
// @ts-expect-error // @ts-expect-error
userSessions.sort((a, b) => b.createdAt - a.createdAt) userSessions.sort((a, b) => b.createdAt - a.createdAt);
return userSessions.filter(session => session.id !== req.session.id) return userSessions.filter((session) => session.id !== req.session.id);
} }
public async findCurrentSession(req: Request) { public async findCurrentSession(req: Request) {
const sessionId = req.session.id const sessionId = req.session.id;
const key = `${this.configService.getOrThrow<string>('SESSION_FOLDER')}${sessionId}` const key = `${this.configService.getOrThrow<string>('SESSION_FOLDER')}${sessionId}`;
const sessionData = await this.redisService.get(key) const sessionData = await this.redisService.get(key);
if (!sessionData) { if (!sessionData) {
throw new NotFoundException('Сессия не найдена') throw new NotFoundException('Сессия не найдена');
} }
const session = JSON.parse(sessionData) as SessionData const session = JSON.parse(sessionData) as SessionData;
return { return {
...session, ...session,
id: sessionId, id: sessionId,
} };
} }
public async login(req: Request, input: LoginInput, userAgent: string) { public async login(req: Request, input: LoginInput, userAgent: string) {
const { login, password, pin } = input const { login, password, pin } = input;
const user = await this.prismaService.user.findFirst({ where: { const user = await this.prismaService.user.findFirst({
where: {
OR: [ OR: [
{ name: { equals: login } }, { name: { equals: login } },
{ email: { equals: login } }, { email: { equals: login } },
], ],
} }) },
});
if (!user) { if (!user) {
throw new NotFoundException(`Пользователь не найден`) throw new NotFoundException('Пользователь не найден');
} }
const isValidPassword = await verify(user.password, password) const isValidPassword = await verify(user.password, password);
if (!isValidPassword) { if (!isValidPassword) {
throw new UnauthorizedException('Логин или пароль неверный') throw new UnauthorizedException('Логин или пароль неверный');
} }
if (!user.isEmailVerified) { if (!user.isEmailVerified) {
await this.verificationService.sendVerificationToken(user) await this.verificationService.sendVerificationToken(user);
throw new BadRequestException('Аккаунт не верифицирован. Проверьте свою почту для подтверждения')
throw new BadRequestException('Аккаунт не верифицирован. Проверьте свою почту для подтверждения');
} }
if (user.isTotpEnabled) { if (user.isTotpEnabled) {
if (!pin) { if (!pin) {
return { return {
message: 'Необходимо ввести пин-код для завершения операции', message: 'Необходимо ввести пин-код для завершения операции',
} };
} }
const totp = new TOTP({ const totp = new TOTP({
@ -112,36 +117,36 @@ export class SessionService {
algorithm: 'SHA-1', algorithm: 'SHA-1',
digits: 6, digits: 6,
secret: user.totpSecret!, secret: user.totpSecret!,
}) });
const delta = totp.validate({ token: pin }) const delta = totp.validate({ token: pin });
if (delta === null) { if (delta === null) {
throw new BadRequestException('Невереый код') throw new BadRequestException('Невереый код');
} }
} }
return saveSession(req, user, getSessionMetadata(req, userAgent)) return saveSession(req, user, getSessionMetadata(req, userAgent));
} }
public async logout(req: Request) { public async logout(req: Request) {
return destroySession(req, this.configService) return destroySession(req, this.configService);
} }
public clearSession(req: Request) { public clearSession(req: Request) {
req.res?.clearCookie(this.configService.getOrThrow('SESSION_NAME')) req.res?.clearCookie(this.configService.getOrThrow('SESSION_NAME'));
return true return true;
} }
public async remove(req: Request, id: string) { public async remove(req: Request, id: string) {
if (req.session.id === id) { if (req.session.id === id) {
throw new ConflictException('Текущую сессию удалить нельзя') throw new ConflictException('Текущую сессию удалить нельзя');
} }
const key = `${this.configService.getOrThrow<string>('SESSION_FOLDER')}${id}` const key = `${this.configService.getOrThrow<string>('SESSION_FOLDER')}${id}`;
await this.redisService.del(key) await this.redisService.del(key);
return true return true;
} }
} }

View File

@ -1,16 +1,16 @@
import { Field, InputType } from '@nestjs/graphql' import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString, Length } from 'class-validator' import { IsNotEmpty, IsString, Length } from 'class-validator';
@InputType() @InputType()
export class EnableTotpInput { export class EnableTotpInput {
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
secret: string secret: string;
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@Length(6, 6) @Length(6, 6)
pin: string pin: string;
} }

View File

@ -3,8 +3,8 @@ import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType() @ObjectType()
export class TotpModel { export class TotpModel {
@Field(() => String) @Field(() => String)
public qrcodeUrl: string public qrcodeUrl: string;
@Field(() => String) @Field(() => String)
public secret: string public secret: string;
} }

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TotpService } from './totp.service';
import { TotpResolver } from './totp.resolver'; import { TotpResolver } from './totp.resolver';
import { TotpService } from './totp.service';
@Module({ @Module({
providers: [TotpResolver, TotpService], providers: [TotpResolver, TotpService],

View File

@ -1,10 +1,14 @@
import { EnableTotpInput } from '@/src/module/auth/totp/inputs/enable-totp.input' import {
import { TotpModel } from '@/src/module/auth/totp/models/totp.model' Args, Mutation, Query, Resolver,
import { Authorization } from '@/src/shared/decorators/auth.decorator' } from '@nestjs/graphql';
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql' import { EnableTotpInput } from '@/src/module/auth/totp/inputs/enable-totp.input';
import { User } from '@prisma/generated' import { TotpModel } from '@/src/module/auth/totp/models/totp.model';
import { TotpService } from './totp.service' import { Authorization } from '@/src/shared/decorators/auth.decorator';
import { Authorized } from '@/src/shared/decorators/authorized.decorator';
import { User } from '@prisma/generated';
import { TotpService } from './totp.service';
@Resolver('Totp') @Resolver('Totp')
export class TotpResolver { export class TotpResolver {
@ -13,7 +17,7 @@ export class TotpResolver {
@Authorization() @Authorization()
@Query(() => TotpModel, { name: 'generateTotpSecret' }) @Query(() => TotpModel, { name: 'generateTotpSecret' })
public async generate(@Authorized() user: User) { public async generate(@Authorized() user: User) {
return this.totpService.generate(user) return this.totpService.generate(user);
} }
@Authorization() @Authorization()
@ -22,12 +26,12 @@ export class TotpResolver {
@Authorized() user: User, @Authorized() user: User,
@Args('data') input: EnableTotpInput, @Args('data') input: EnableTotpInput,
) { ) {
return this.totpService.enable(user, input) return this.totpService.enable(user, input);
} }
@Authorization() @Authorization()
@Mutation(() => Boolean, { name: 'disableTotp' }) @Mutation(() => Boolean, { name: 'disableTotp' })
public async disable(@Authorized() user: User) { public async disable(@Authorized() user: User) {
return this.totpService.disable(user) return this.totpService.disable(user);
} }
} }

View File

@ -1,11 +1,13 @@
import { PrismaService } from '@/src/core/prisma/prisma.service' import { randomBytes } from 'node:crypto';
import { EnableTotpInput } from '@/src/module/auth/totp/inputs/enable-totp.input'
import { BadRequestException, Injectable } from '@nestjs/common' import { BadRequestException, Injectable } from '@nestjs/common';
import { User } from '@prisma/generated' import { encode } from 'hi-base32';
import { encode } from 'hi-base32' import { TOTP } from 'otpauth';
import { randomBytes } from 'node:crypto' import * as QRCode from 'qrcode';
import { TOTP } from 'otpauth'
import * as QRCode from 'qrcode' import { PrismaService } from '@/src/core/prisma/prisma.service';
import { EnableTotpInput } from '@/src/module/auth/totp/inputs/enable-totp.input';
import { User } from '@prisma/generated';
@Injectable() @Injectable()
export class TotpService { export class TotpService {
@ -17,7 +19,7 @@ export class TotpService {
async generate(user: User) { async generate(user: User) {
const secret = encode(randomBytes(15)) const secret = encode(randomBytes(15))
.replace(/=/g, '') .replace(/=/g, '')
.substring(0, 24) .substring(0, 24);
const totp = new TOTP({ const totp = new TOTP({
issuer: 'TeaStream', issuer: 'TeaStream',
@ -25,18 +27,18 @@ export class TotpService {
algorithm: 'SHA-1', algorithm: 'SHA-1',
digits: 6, digits: 6,
secret, secret,
}) });
const qrcodeUrl = await QRCode.toDataURL(totp.toString()) const qrcodeUrl = await QRCode.toDataURL(totp.toString());
return { return {
qrcodeUrl, qrcodeUrl,
secret, secret,
} };
} }
async enable(user: User, input: EnableTotpInput) { async enable(user: User, input: EnableTotpInput) {
const { pin, secret } = input const { pin, secret } = input;
const totp = new TOTP({ const totp = new TOTP({
issuer: 'TeaStream', issuer: 'TeaStream',
@ -44,12 +46,12 @@ export class TotpService {
algorithm: 'SHA-1', algorithm: 'SHA-1',
digits: 6, digits: 6,
secret, secret,
}) });
const delta = totp.validate({ token: pin }) const delta = totp.validate({ token: pin });
if (delta === null) { if (delta === null) {
throw new BadRequestException('Невереый код') throw new BadRequestException('Невереый код');
} }
await this.prismaService.user.update({ await this.prismaService.user.update({
@ -58,9 +60,9 @@ export class TotpService {
isTotpEnabled: true, isTotpEnabled: true,
totpSecret: secret, totpSecret: secret,
}, },
}) });
return true return true;
} }
async disable(user: User) { async disable(user: User) {
@ -70,8 +72,8 @@ export class TotpService {
isTotpEnabled: false, isTotpEnabled: false,
totpSecret: null, totpSecret: null,
}, },
}) });
return true return true;
} }
} }

View File

@ -1,10 +1,10 @@
import { Field, InputType } from '@nestjs/graphql' import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsUUID } from 'class-validator' import { IsNotEmpty, IsUUID } from 'class-validator';
@InputType() @InputType()
export class VerificationInput { export class VerificationInput {
@Field(() => String) @Field(() => String)
@IsUUID('4') @IsUUID('4')
@IsNotEmpty() @IsNotEmpty()
public token: string public token: string;
} }

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common';
import { VerificationService } from './verification.service'
import { VerificationResolver } from './verification.resolver' import { VerificationResolver } from './verification.resolver';
import { VerificationService } from './verification.service';
@Module({ @Module({
providers: [VerificationResolver, VerificationService], providers: [VerificationResolver, VerificationService],

View File

@ -1,9 +1,13 @@
import { AuthModel } from '@/src/module/auth/account/models/auth.model' import {
import { VerificationInput } from '@/src/module/auth/verification/inputs/verification.input' Args, Context, Mutation, Resolver,
import { UserAgent } from '@/src/shared/decorators/user-agent.decorator' } from '@nestjs/graphql';
import { GqlContext } from '@/src/shared/types/gql-context.types'
import { Args, Context, Mutation, Resolver } from '@nestjs/graphql' import { AuthModel } from '@/src/module/auth/account/models/auth.model';
import { VerificationService } from './verification.service' import { VerificationInput } from '@/src/module/auth/verification/inputs/verification.input';
import { UserAgent } from '@/src/shared/decorators/user-agent.decorator';
import { GqlContext } from '@/src/shared/types/gql-context.types';
import { VerificationService } from './verification.service';
@Resolver('Verification') @Resolver('Verification')
export class VerificationResolver { export class VerificationResolver {
@ -15,6 +19,6 @@ export class VerificationResolver {
@Args('data') input: VerificationInput, @Args('data') input: VerificationInput,
@UserAgent() userAgent: string, @UserAgent() userAgent: string,
) { ) {
return this.verificationService.verify(req, input, userAgent) return this.verificationService.verify(req, input, userAgent);
} }
} }

View File

@ -1,12 +1,13 @@
import { PrismaService } from '@/src/core/prisma/prisma.service' import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { VerificationInput } from '@/src/module/auth/verification/inputs/verification.input' import { Request } from 'express';
import { MailService } from '@/src/module/libs/mail/mail.service'
import { generateToken } from '@/src/shared/util/generate-token.util' import { PrismaService } from '@/src/core/prisma/prisma.service';
import { getSessionMetadata } from '@/src/shared/util/session-metadata.util' import { VerificationInput } from '@/src/module/auth/verification/inputs/verification.input';
import { saveSession } from '@/src/shared/util/session.util' import { MailService } from '@/src/module/libs/mail/mail.service';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' import { generateToken } from '@/src/shared/util/generate-token.util';
import { TokenType, User } from '@prisma/generated' import { getSessionMetadata } from '@/src/shared/util/session-metadata.util';
import { Request } from 'express' import { saveSession } from '@/src/shared/util/session.util';
import { TokenType, User } from '@prisma/generated';
@Injectable() @Injectable()
export class VerificationService { export class VerificationService {
@ -17,19 +18,19 @@ export class VerificationService {
} }
public async verify(req: Request, input: VerificationInput, userAgent: string) { public async verify(req: Request, input: VerificationInput, userAgent: string) {
const { token } = input const { token } = input;
const existingToken = await this.prismaService.token.findUnique({ const existingToken = await this.prismaService.token.findUnique({
where: { token, type: TokenType.EMAIL_VERIFY }, where: { token, type: TokenType.EMAIL_VERIFY },
}) });
if (!existingToken) { if (!existingToken) {
throw new NotFoundException('Токен не найден') throw new NotFoundException('Токен не найден');
} }
const hasExpired = new Date(existingToken.expiresIn) < new Date() const hasExpired = new Date(existingToken.expiresIn) < new Date();
if (hasExpired) { if (hasExpired) {
throw new BadRequestException('Токен истек') throw new BadRequestException('Токен истек');
} }
const user = await this.prismaService.user.update({ const user = await this.prismaService.user.update({
@ -39,16 +40,16 @@ export class VerificationService {
data: { data: {
isEmailVerified: true, isEmailVerified: true,
}, },
}) });
await this.prismaService.token.delete({ await this.prismaService.token.delete({
where: { where: {
id: existingToken.id, id: existingToken.id,
type: TokenType.EMAIL_VERIFY, type: TokenType.EMAIL_VERIFY,
}, },
}) });
return saveSession(req, user, getSessionMetadata(req, userAgent)) return saveSession(req, user, getSessionMetadata(req, userAgent));
} }
public async sendVerificationToken(user: User) { public async sendVerificationToken(user: User) {
@ -56,10 +57,10 @@ export class VerificationService {
this.prismaService, this.prismaService,
user, user,
TokenType.EMAIL_VERIFY, TokenType.EMAIL_VERIFY,
) );
await this.mailService.sendVerificationToken(user.email, verificationToken.token); await this.mailService.sendVerificationToken(user.email, verificationToken.token);
return true return true;
} }
} }

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CategoryService } from './category.service';
import { CategoryResolver } from './category.resolver'; import { CategoryResolver } from './category.resolver';
import { CategoryService } from './category.service';
@Module({ @Module({
providers: [CategoryResolver, CategoryService], providers: [CategoryResolver, CategoryService],

View File

@ -1,6 +1,8 @@
import { CategoryModel } from '@/src/module/category/models/category.model' import { Args, Query, Resolver } from '@nestjs/graphql';
import { Args, Query, Resolver } from '@nestjs/graphql'
import { CategoryService } from './category.service' import { CategoryModel } from '@/src/module/category/models/category.model';
import { CategoryService } from './category.service';
@Resolver('Category') @Resolver('Category')
export class CategoryResolver { export class CategoryResolver {
@ -8,16 +10,16 @@ export class CategoryResolver {
@Query(() => [CategoryModel], { name: 'findAllCategories' }) @Query(() => [CategoryModel], { name: 'findAllCategories' })
public async findAll() { public async findAll() {
return this.categoryService.findAll() return this.categoryService.findAll();
} }
@Query(() => [CategoryModel], { name: 'findRandomCategories' }) @Query(() => [CategoryModel], { name: 'findRandomCategories' })
public async findRandom() { public async findRandom() {
return this.categoryService.findRandom() return this.categoryService.findRandom();
} }
@Query(() => CategoryModel, { name: 'findCategoryBySlug' }) @Query(() => CategoryModel, { name: 'findCategoryBySlug' })
public async findByhSlug(@Args('slug') slug: string) { public async findByhSlug(@Args('slug') slug: string) {
return this.categoryService.findBySlug(slug) return this.categoryService.findBySlug(slug);
} }
} }

View File

@ -1,5 +1,6 @@
import { PrismaService } from '@/src/core/prisma/prisma.service' import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common'
import { PrismaService } from '@/src/core/prisma/prisma.service';
@Injectable() @Injectable()
export class CategoryService { export class CategoryService {
@ -19,16 +20,16 @@ export class CategoryService {
}, },
}, },
}, },
}) });
} }
public async findRandom() { public async findRandom() {
const total = await this.prismaService.category.count({}) const total = await this.prismaService.category.count({});
const randomIndexes = new Set<number>() const randomIndexes = new Set<number>();
while (randomIndexes.size < 7) { while (randomIndexes.size < 7) {
const randomIndex = Math.floor(Math.random() * total) const randomIndex = Math.floor(Math.random() * total);
randomIndexes.add(randomIndex) randomIndexes.add(randomIndex);
} }
const categories = await this.prismaService.category.findMany({ const categories = await this.prismaService.category.findMany({
@ -42,9 +43,9 @@ export class CategoryService {
}, },
}, },
}, },
}) });
return Array.from(randomIndexes).map(index => categories[index]) return Array.from(randomIndexes).map((index) => categories[index]);
} }
public async findBySlug(slug: string) { public async findBySlug(slug: string) {
@ -60,12 +61,12 @@ export class CategoryService {
}, },
}, },
}, },
}) });
if (!category) { if (!category) {
throw new NotFoundException('Категория не найдена') throw new NotFoundException('Категория не найдена');
} }
return category return category;
} }
} }

View File

@ -0,0 +1 @@
export { CategoryModel } from './models/category.model';

View File

@ -1,32 +1,32 @@
import { Field, ID, ObjectType } from '@nestjs/graphql' import { Field, ID, ObjectType } from '@nestjs/graphql';
import type { Category } from '@/prisma/generated' import { StreamModel } from '@/src/module/stream';
import { StreamModel } from '../../stream/models/stream.model' import type { Category } from '@/prisma/generated';
@ObjectType() @ObjectType()
export class CategoryModel implements Category { export class CategoryModel implements Category {
@Field(() => ID) @Field(() => ID)
public id: string public id: string;
@Field(() => String) @Field(() => String)
public title: string public title: string;
@Field(() => String) @Field(() => String)
public slug: string public slug: string;
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
public description: string public description: string;
@Field(() => String) @Field(() => String)
public thumbnailUrl: string public thumbnailUrl: string;
@Field(() => [StreamModel]) @Field(() => [StreamModel])
public streams: StreamModel[] public streams: StreamModel[];
@Field(() => Date) @Field(() => Date)
public createdAt: Date public createdAt: Date;
@Field(() => Date) @Field(() => Date)
public updatedAt: Date public updatedAt: Date;
} }

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common';
import { ChannelService } from './channel.service'
import { ChannelResolver } from './channel.resolver' import { ChannelResolver } from './channel.resolver';
import { ChannelService } from './channel.service';
@Module({ @Module({
providers: [ChannelResolver, ChannelService], providers: [ChannelResolver, ChannelService],

View File

@ -1,7 +1,9 @@
import { UserModel } from '@/src/module/auth/account/models/user.model' import { Args, Query, Resolver } from '@nestjs/graphql';
import { SubscriptionModel } from '@/src/module/sponsorship/subscription/model/subscription.model'
import { Args, Query, Resolver } from '@nestjs/graphql' import { UserModel } from '@/src/module/auth/account/models/user.model';
import { ChannelService } from './channel.service' import { SubscriptionModel } from '@/src/module/sponsorship/subscription/model/subscription.model';
import { ChannelService } from './channel.service';
@Resolver('Channel') @Resolver('Channel')
export class ChannelResolver { export class ChannelResolver {
@ -9,21 +11,21 @@ export class ChannelResolver {
@Query(() => [UserModel], { name: 'findRecommendedChannels' }) @Query(() => [UserModel], { name: 'findRecommendedChannels' })
public async findRecommended() { public async findRecommended() {
return this.channelService.findRecommendedChannel() return this.channelService.findRecommendedChannel();
} }
@Query(() => UserModel, { name: 'findChannelByUsername' }) @Query(() => UserModel, { name: 'findChannelByUsername' })
public async findByUsername(@Args('name') name: string) { public async findByUsername(@Args('name') name: string) {
return this.channelService.findByUsername(name) return this.channelService.findByUsername(name);
} }
@Query(() => Number, { name: 'findChannelFollowersCount' }) @Query(() => Number, { name: 'findChannelFollowersCount' })
public async findFollowersCount(@Args('channelId') channelId: string) { public async findFollowersCount(@Args('channelId') channelId: string) {
return this.channelService.findFollowersCountByChannel(channelId) return this.channelService.findFollowersCountByChannel(channelId);
} }
@Query(() => [SubscriptionModel], { name: 'findSponsorsByChannel' }) @Query(() => [SubscriptionModel], { name: 'findSponsorsByChannel' })
public async findSponsorsByChannel(@Args('channelId') channelId: string) { public async findSponsorsByChannel(@Args('channelId') channelId: string) {
return this.channelService.findSponsorsByChannel(channelId) return this.channelService.findSponsorsByChannel(channelId);
} }
} }

View File

@ -1,5 +1,6 @@
import { PrismaService } from '@/src/core/prisma/prisma.service' import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common'
import { PrismaService } from '@/src/core/prisma/prisma.service';
@Injectable() @Injectable()
export class ChannelService { export class ChannelService {
@ -14,7 +15,7 @@ export class ChannelService {
orderBy: { followings: { _count: 'desc' } }, orderBy: { followings: { _count: 'desc' } },
include: { stream: true }, include: { stream: true },
take: 7, take: 7,
}) });
} }
public async findByUsername(name: string) { public async findByUsername(name: string) {
@ -25,28 +26,28 @@ export class ChannelService {
stream: { include: { category: true } }, stream: { include: { category: true } },
followings: true, followings: true,
}, },
}) });
if (!channel) { if (!channel) {
throw new NotFoundException('Канал не найден') throw new NotFoundException('Канал не найден');
} }
return channel return channel;
} }
public async findFollowersCountByChannel(channelId: string) { public async findFollowersCountByChannel(channelId: string) {
return this.prismaService.follow.count({ return this.prismaService.follow.count({
where: { following: { id: channelId } }, where: { following: { id: channelId } },
}) });
} }
public async findSponsorsByChannel(channelId: string) { public async findSponsorsByChannel(channelId: string) {
const channel = await this.prismaService.user.findUnique({ const channel = await this.prismaService.user.findUnique({
where: { id: channelId, isDeactivated: false }, where: { id: channelId, isDeactivated: false },
}) });
if (!channel) { if (!channel) {
throw new NotFoundException('Канал не найден') throw new NotFoundException('Канал не найден');
} }
return this.prismaService.sponsorshipSubscription.findMany({ return this.prismaService.sponsorshipSubscription.findMany({
@ -55,6 +56,6 @@ export class ChannelService {
include: { include: {
user: true, user: true,
}, },
}) });
} }
} }

View File

@ -1,6 +1,7 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common';
import { ChatService } from './chat.service'
import { ChatResolver } from './chat.resolver' import { ChatResolver } from './chat.resolver';
import { ChatService } from './chat.service';
@Module({ @Module({
providers: [ChatResolver, ChatService], providers: [ChatResolver, ChatService],

View File

@ -1,24 +1,29 @@
import { ChangeChatSettingsInput } from '@/src/module/chat/input/change-chat-settings.input' import {
import { SendMessageInput } from '@/src/module/chat/input/send-message.input' Args, Mutation, Query, Resolver, Subscription,
import { StreamModel } from '@/src/module/stream/models/stream.model' } from '@nestjs/graphql';
import { Authorization } from '@/src/shared/decorators/auth.decorator' import { PubSub } from 'graphql-subscriptions';
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
import { Args, Mutation, Query, Resolver, Subscription } from '@nestjs/graphql' import { ChangeChatSettingsInput } from '@/src/module/chat/input/change-chat-settings.input';
import { ChatMessage, User } from '@prisma/generated' import { SendMessageInput } from '@/src/module/chat/input/send-message.input';
import { PubSub } from 'graphql-subscriptions' import { StreamModel } from '@/src/module/stream/models/stream.model';
import { ChatService } from './chat.service' import { Authorization } from '@/src/shared/decorators/auth.decorator';
import { ChatMessageModel } from './models/chat-message.model' import { Authorized } from '@/src/shared/decorators/authorized.decorator';
import { ChatMessage, User } from '@prisma/generated';
import { ChatService } from './chat.service';
import { ChatMessageModel } from './models/chat-message.model';
@Resolver('Chat') @Resolver('Chat')
export class ChatResolver { export class ChatResolver {
public pubSub: PubSub public pubSub: PubSub;
constructor(private readonly chatService: ChatService) { constructor(private readonly chatService: ChatService) {
this.pubSub = new PubSub() this.pubSub = new PubSub();
} }
@Query(() => [ChatMessageModel], { name: 'findMessagesByStream' }) @Query(() => [ChatMessageModel], { name: 'findMessagesByStream' })
public async findMessagesByStream(@Args('streamId') streamId: string) { public async findMessagesByStream(@Args('streamId') streamId: string) {
return this.chatService.findMessagesByStream(streamId) return this.chatService.findMessagesByStream(streamId);
} }
@Authorization() @Authorization()
@ -27,15 +32,15 @@ export class ChatResolver {
@Authorized('id') userId: User['id'], @Authorized('id') userId: User['id'],
@Args('data') input: SendMessageInput, @Args('data') input: SendMessageInput,
) { ) {
const message = this.chatService.sendMessage(userId, input) const message = this.chatService.sendMessage(userId, input);
void this.pubSub.publish('CHAT_MESSAGE_ADDED', { message }) void this.pubSub.publish('CHAT_MESSAGE_ADDED', { message });
return message return message;
} }
@Subscription(() => ChatMessageModel, { name: 'chatMessageAdded', filter: (payload: { message: ChatMessage }, variables: ChatMessageModel) => payload.message.streamId === variables.streamId }) @Subscription(() => ChatMessageModel, { name: 'chatMessageAdded', filter: (payload: { message: ChatMessage }, variables: ChatMessageModel) => payload.message.streamId === variables.streamId })
public async chatMessageAdded(@Args('streamId') streamId: string) { public async chatMessageAdded(@Args('streamId') streamId: string) {
return this.pubSub.asyncIterableIterator('CHAT_MESSAGE_ADDED') return this.pubSub.asyncIterableIterator('CHAT_MESSAGE_ADDED');
} }
@Authorization() @Authorization()
@ -44,6 +49,6 @@ export class ChatResolver {
@Args('data') input: ChangeChatSettingsInput, @Args('data') input: ChangeChatSettingsInput,
@Authorized() user: User, @Authorized() user: User,
) { ) {
return this.chatService.changeSettings(user, input) return this.chatService.changeSettings(user, input);
} }
} }

View File

@ -1,8 +1,9 @@
import { PrismaService } from '@/src/core/prisma/prisma.service' import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ChangeChatSettingsInput } from '@/src/module/chat/input/change-chat-settings.input'
import { SendMessageInput } from '@/src/module/chat/input/send-message.input' import { PrismaService } from '@/src/core/prisma/prisma.service';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' import { ChangeChatSettingsInput } from '@/src/module/chat/input/change-chat-settings.input';
import { Stream, User } from '@prisma/generated' import { SendMessageInput } from '@/src/module/chat/input/send-message.input';
import { Stream, User } from '@prisma/generated';
@Injectable() @Injectable()
export class ChatService { export class ChatService {
@ -16,27 +17,27 @@ export class ChatService {
where: { streamId }, where: { streamId },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { user: true }, include: { user: true },
}) });
} }
public async sendMessage(userId: User['id'], input: SendMessageInput) { public async sendMessage(userId: User['id'], input: SendMessageInput) {
const { text, streamId } = input const { text, streamId } = input;
const stream = await this.prismaService.stream.findUnique({ const stream = await this.prismaService.stream.findUnique({
where: { id: streamId }, where: { id: streamId },
}) });
if (!stream) { if (!stream) {
throw new NotFoundException('Стрим не найден') throw new NotFoundException('Стрим не найден');
} }
if (!stream.isLive) { if (!stream.isLive) {
throw new BadRequestException('Стрим не запущен') throw new BadRequestException('Стрим не запущен');
} }
const user = await this.prismaService.user.findUnique({ const user = await this.prismaService.user.findUnique({
where: { id: userId }, where: { id: userId },
}) });
if (!user) { if (!user) {
throw new NotFoundException('Пользователь не найден') throw new NotFoundException('Пользователь не найден');
} }
return this.prismaService.chatMessage.create({ return this.prismaService.chatMessage.create({
@ -56,15 +57,15 @@ export class ChatService {
include: { include: {
stream: true, stream: true,
}, },
}) });
} }
public async changeSettings(user: User, input: ChangeChatSettingsInput) { public async changeSettings(user: User, input: ChangeChatSettingsInput) {
const { isChatEnable, isChatFollowersOnly, isChatPremiumFollowersOnly } = input const { isChatEnable, isChatFollowersOnly, isChatPremiumFollowersOnly } = input;
return this.prismaService.stream.update({ return this.prismaService.stream.update({
where: { userId: user.id }, where: { userId: user.id },
data: { isChatEnable, isChatFollowersOnly, isChatPremiumFollowersOnly }, data: { isChatEnable, isChatFollowersOnly, isChatPremiumFollowersOnly },
}) });
} }
} }

View File

@ -0,0 +1 @@
export { ChatMessageModel } from './models/chat-message.model';

View File

@ -1,17 +1,17 @@
import { Field, InputType } from '@nestjs/graphql' import { Field, InputType } from '@nestjs/graphql';
import { IsBoolean } from 'class-validator' import { IsBoolean } from 'class-validator';
@InputType() @InputType()
export class ChangeChatSettingsInput { export class ChangeChatSettingsInput {
@Field(() => Boolean) @Field(() => Boolean)
@IsBoolean() @IsBoolean()
public isChatEnable: boolean public isChatEnable: boolean;
@Field(() => Boolean) @Field(() => Boolean)
@IsBoolean() @IsBoolean()
public isChatFollowersOnly: boolean public isChatFollowersOnly: boolean;
@Field(() => Boolean) @Field(() => Boolean)
@IsBoolean() @IsBoolean()
public isChatPremiumFollowersOnly: boolean public isChatPremiumFollowersOnly: boolean;
} }

View File

@ -1,16 +1,17 @@
import { Field, InputType } from '@nestjs/graphql' import { Field, InputType } from '@nestjs/graphql';
import { Stream } from '@prisma/generated' import { IsNotEmpty, IsString } from 'class-validator';
import { IsNotEmpty, IsString } from 'class-validator'
import { Stream } from '@prisma/generated';
@InputType() @InputType()
export class SendMessageInput { export class SendMessageInput {
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
public text: string public text: string;
@Field(() => String) @Field(() => String)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
streamId: Stream['id'] streamId: Stream['id'];
} }

View File

@ -1,25 +1,26 @@
import { UserModel } from '@/src/module/auth/account/models/user.model' import { Field, ID, ObjectType } from '@nestjs/graphql';
import { StreamModel } from '@/src/module/stream/models/stream.model'
import { Field, ID, ObjectType } from '@nestjs/graphql' import { UserModel } from '@/src/module/auth/account/models/user.model';
import { ChatMessage } from '@prisma/generated' import { StreamModel } from '@/src/module/stream/models/stream.model';
import { ChatMessage } from '@prisma/generated';
@ObjectType() @ObjectType()
export class ChatMessageModel implements ChatMessage { export class ChatMessageModel implements ChatMessage {
@Field(() => ID) @Field(() => ID)
public id: string public id: string;
@Field(() => String) @Field(() => String)
text: string text: string;
@Field(() => ID) @Field(() => ID)
streamId: StreamModel['id'] streamId: StreamModel['id'];
@Field(() => ID) @Field(() => ID)
userId: UserModel['id'] userId: UserModel['id'];
@Field(() => Date) @Field(() => Date)
public createdAt: Date public createdAt: Date;
@Field(() => Date) @Field(() => Date)
public updatedAt: Date public updatedAt: Date;
} }

View File

@ -1,7 +1,9 @@
import { NotificationService } from '@/src/module/notification/notification.service' import { Module } from '@nestjs/common';
import { Module } from '@nestjs/common' import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleModule } from '@nestjs/schedule'
import { CronService } from './cron.service' import { NotificationService } from '@/src/module/notification/notification.service';
import { CronService } from './cron.service';
@Module({ @Module({
imports: [ScheduleModule.forRoot()], imports: [ScheduleModule.forRoot()],

View File

@ -1,10 +1,11 @@
import { PrismaService } from '@/src/core/prisma/prisma.service' import { Injectable } from '@nestjs/common';
import { MailService } from '@/src/module/libs/mail/mail.service' import { Cron, CronExpression } from '@nestjs/schedule';
import { StorageService } from '@/src/module/libs/storage/storage.service'
import { TelegramService } from '@/src/module/libs/telegram/telegram.service' import { PrismaService } from '@/src/core/prisma/prisma.service';
import { NotificationService } from '@/src/module/notification/notification.service' import { MailService } from '@/src/module/libs/mail/mail.service';
import { Injectable } from '@nestjs/common' import { StorageService } from '@/src/module/libs/storage/storage.service';
import { Cron, CronExpression } from '@nestjs/schedule' import { TelegramService } from '@/src/module/libs/telegram/telegram.service';
import { NotificationService } from '@/src/module/notification/notification.service';
@Injectable() @Injectable()
export class CronService { export class CronService {
@ -19,8 +20,8 @@ export class CronService {
@Cron(CronExpression.EVERY_DAY_AT_NOON) @Cron(CronExpression.EVERY_DAY_AT_NOON)
private async deleteDeactivatedAccounts() { private async deleteDeactivatedAccounts() {
const sevenDayAgo = new Date() const sevenDayAgo = new Date();
sevenDayAgo.setDate(sevenDayAgo.getDay() - 7) sevenDayAgo.setDate(sevenDayAgo.getDay() - 7);
const deactivatedAccounts = await this.prismaService.user.findMany({ const deactivatedAccounts = await this.prismaService.user.findMany({
where: { where: {
@ -32,17 +33,16 @@ export class CronService {
include: { include: {
notificationSettings: true, notificationSettings: true,
}, },
}) });
for (const user of deactivatedAccounts) { for (const user of deactivatedAccounts) {
console.log('Deactivate user', user.name, user.email) await this.mailService.sendAccountDeletion(user.email);
await this.mailService.sendAccountDeletion(user.email)
if (user?.telegramId) { if (user?.telegramId) {
await this.telegramService.sendAccountDeletionToken(user?.telegramId) await this.telegramService.sendAccountDeletionToken(user?.telegramId);
} }
if (user.avatar) { if (user.avatar) {
await this.storageService.remove(user.avatar) await this.storageService.remove(user.avatar);
} }
} }
@ -53,7 +53,7 @@ export class CronService {
lte: sevenDayAgo, lte: sevenDayAgo,
}, },
}, },
}) });
} }
@Cron('0 0 */4 * *') @Cron('0 0 */4 * *')
@ -61,21 +61,21 @@ export class CronService {
const users = await this.prismaService.user.findMany({ const users = await this.prismaService.user.findMany({
where: { isTotpEnabled: false }, where: { isTotpEnabled: false },
include: { notificationSettings: true }, include: { notificationSettings: true },
}) });
for (const user of users) { for (const user of users) {
if (!user) { if (!user) {
continue continue;
} }
await this.mailService.sendEnableTwoFactor(user.email) await this.mailService.sendEnableTwoFactor(user.email);
if (user.notificationSettings?.siteNotifications) { if (user.notificationSettings?.siteNotifications) {
await this.notificationService.createEnableTwoFactor(user.id) await this.notificationService.createEnableTwoFactor(user.id);
} }
if (user.notificationSettings?.telegramNotifications && user.telegramId) { if (user.notificationSettings?.telegramNotifications && user.telegramId) {
await this.telegramService.sendEnableTwoFactor(user.telegramId) await this.telegramService.sendEnableTwoFactor(user.telegramId);
} }
} }
} }
@ -84,27 +84,27 @@ export class CronService {
public async verifyChannels() { public async verifyChannels() {
const users = await this.prismaService.user.findMany({ const users = await this.prismaService.user.findMany({
include: { notificationSettings: true }, include: { notificationSettings: true },
}) });
for (const user of users) { for (const user of users) {
const followersCount = await this.prismaService.follow.count({ const followersCount = await this.prismaService.follow.count({
where: { followingId: user.id }, where: { followingId: user.id },
}) });
if (followersCount > 10 && !user.isVerified) { if (followersCount > 10 && !user.isVerified) {
await this.prismaService.user.update({ await this.prismaService.user.update({
where: { id: user.id }, where: { id: user.id },
data: { isVerified: true }, data: { isVerified: true },
}) });
await this.mailService.sendVerifyChannel(user.email) await this.mailService.sendVerifyChannel(user.email);
if (user.notificationSettings?.siteNotifications) { if (user.notificationSettings?.siteNotifications) {
await this.notificationService.createVerifyChannel(user.id) await this.notificationService.createVerifyChannel(user.id);
} }
if (user.notificationSettings?.telegramNotifications && user.telegramId) { if (user.notificationSettings?.telegramNotifications && user.telegramId) {
await this.telegramService.sendVerifyChannel(user.telegramId) await this.telegramService.sendVerifyChannel(user.telegramId);
} }
} }
} }
@ -112,8 +112,8 @@ export class CronService {
@Cron(CronExpression.EVERY_DAY_AT_1AM) @Cron(CronExpression.EVERY_DAY_AT_1AM)
public async deleteOldNotifications() { public async deleteOldNotifications() {
const sevenDaysAgo = new Date() const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7) sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
await this.prismaService.notification.deleteMany({ await this.prismaService.notification.deleteMany({
where: { where: {
@ -121,6 +121,6 @@ export class CronService {
lte: sevenDaysAgo, lte: sevenDaysAgo,
}, },
}, },
}) });
} }
} }

View File

@ -1,7 +1,9 @@
import { NotificationService } from '@/src/module/notification/notification.service' import { Module } from '@nestjs/common';
import { Module } from '@nestjs/common'
import { FollowService } from './follow.service' import { NotificationService } from '@/src/module/notification/notification.service';
import { FollowResolver } from './follow.resolver'
import { FollowResolver } from './follow.resolver';
import { FollowService } from './follow.service';
@Module({ @Module({
providers: [FollowResolver, FollowService, NotificationService], providers: [FollowResolver, FollowService, NotificationService],

View File

@ -1,9 +1,13 @@
import { FollowModel } from '@/src/module/follow/model/follow.model' import {
import { Authorization } from '@/src/shared/decorators/auth.decorator' Args, Mutation, Query, Resolver,
import { Authorized } from '@/src/shared/decorators/authorized.decorator' } from '@nestjs/graphql';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'
import { User } from '@prisma/generated' import { FollowModel } from '@/src/module/follow/model/follow.model';
import { FollowService } from './follow.service' import { Authorization } from '@/src/shared/decorators/auth.decorator';
import { Authorized } from '@/src/shared/decorators/authorized.decorator';
import { User } from '@prisma/generated';
import { FollowService } from './follow.service';
@Resolver('Follow') @Resolver('Follow')
export class FollowResolver { export class FollowResolver {
@ -12,24 +16,24 @@ export class FollowResolver {
@Authorization() @Authorization()
@Query(() => [FollowModel], { name: 'findMyFollowers' }) @Query(() => [FollowModel], { name: 'findMyFollowers' })
public async findMyFollowers(@Authorized() user: User) { public async findMyFollowers(@Authorized() user: User) {
return this.followService.findMyFollowers(user) return this.followService.findMyFollowers(user);
} }
@Authorization() @Authorization()
@Query(() => [FollowModel], { name: 'findMyFollowings' }) @Query(() => [FollowModel], { name: 'findMyFollowings' })
public async findMyFollowings(@Authorized() user: User) { public async findMyFollowings(@Authorized() user: User) {
return this.followService.findMyFollowings(user) return this.followService.findMyFollowings(user);
} }
@Authorization() @Authorization()
@Mutation(() => FollowModel, { name: 'followChannel' }) @Mutation(() => FollowModel, { name: 'followChannel' })
public async follow(@Authorized() user: User, @Args('channelId') channelId: string) { public async follow(@Authorized() user: User, @Args('channelId') channelId: string) {
return this.followService.follow(user, channelId) return this.followService.follow(user, channelId);
} }
@Authorization() @Authorization()
@Mutation(() => FollowModel, { name: 'unfollowChannel' }) @Mutation(() => FollowModel, { name: 'unfollowChannel' })
public async unfollow(@Authorized() user: User, @Args('channelId') channelId: string) { public async unfollow(@Authorized() user: User, @Args('channelId') channelId: string) {
return this.followService.unfollow(user, channelId) return this.followService.unfollow(user, channelId);
} }
} }

View File

@ -1,8 +1,9 @@
import { PrismaService } from '@/src/core/prisma/prisma.service' import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '@/src/core/prisma/prisma.service';
import { TelegramService } from '@/src/module/libs/telegram/telegram.service'; import { TelegramService } from '@/src/module/libs/telegram/telegram.service';
import { NotificationService } from '@/src/module/notification/notification.service' import { NotificationService } from '@/src/module/notification/notification.service';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common' import { User } from '@prisma/generated';
import { User } from '@prisma/generated'
@Injectable() @Injectable()
export class FollowService { export class FollowService {
@ -18,7 +19,7 @@ export class FollowService {
where: { followingId: user.id }, where: { followingId: user.id },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { follower: true, following: true }, include: { follower: true, following: true },
}) });
} }
public async findMyFollowings(user: User) { public async findMyFollowings(user: User) {
@ -26,19 +27,19 @@ export class FollowService {
where: { followerId: user.id }, where: { followerId: user.id },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
include: { follower: true, following: true }, include: { follower: true, following: true },
}) });
} }
public async follow(user: User, channelId: string) { public async follow(user: User, channelId: string) {
const channel = await this.prismaService.user.findUnique({ const channel = await this.prismaService.user.findUnique({
where: { id: channelId }, where: { id: channelId },
}) });
if (!channel) { if (!channel) {
throw new NotFoundException('Пользователь не найден') throw new NotFoundException('Пользователь не найден');
} }
if (channel.id === user.id) { if (channel.id === user.id) {
throw new ConflictException('Нельзя подписаться на себя') throw new ConflictException('Нельзя подписаться на себя');
} }
const existingFollow = await this.prismaService.follow.findFirst({ const existingFollow = await this.prismaService.follow.findFirst({
@ -46,10 +47,10 @@ export class FollowService {
followerId: user.id, followerId: user.id,
followingId: channel.id, followingId: channel.id,
}, },
}) });
if (existingFollow) { if (existingFollow) {
throw new ConflictException('Подписка уже существует') throw new ConflictException('Подписка уже существует');
} }
const follow = await this.prismaService.follow.create({ const follow = await this.prismaService.follow.create({
@ -65,27 +66,28 @@ export class FollowService {
}, },
follower: true, follower: true,
}, },
}) });
if (follow.following.notificationSettings?.siteNotifications) { if (follow.following.notificationSettings?.siteNotifications) {
await this.notificationService.createNewFollowing(follow.following.id, follow.follower) await this.notificationService.createNewFollowing(follow.following.id, follow.follower);
} }
if (follow.following.notificationSettings?.telegramNotifications && follow.following.telegramId) { if (follow.following.notificationSettings?.telegramNotifications && follow.following.telegramId) {
await this.telegramService.sendNewFollowing(follow.following.telegramId, follow.follower) await this.telegramService.sendNewFollowing(follow.following.telegramId, follow.follower);
} }
return follow
return follow;
} }
public async unfollow(user: User, channelId: string) { public async unfollow(user: User, channelId: string) {
const channel = await this.prismaService.user.findUnique({ const channel = await this.prismaService.user.findUnique({
where: { id: channelId }, where: { id: channelId },
}) });
if (!channel) { if (!channel) {
throw new NotFoundException('Пользователь не найден') throw new NotFoundException('Пользователь не найден');
} }
if (channel.id === user.id) { if (channel.id === user.id) {
throw new ConflictException('Нельзя отписаться от себя') throw new ConflictException('Нельзя отписаться от себя');
} }
const existingFollow = await this.prismaService.follow.findFirst({ const existingFollow = await this.prismaService.follow.findFirst({
@ -93,10 +95,10 @@ export class FollowService {
followerId: user.id, followerId: user.id,
followingId: channel.id, followingId: channel.id,
}, },
}) });
if (!existingFollow) { if (!existingFollow) {
throw new ConflictException('Подписки не существует') throw new ConflictException('Подписки не существует');
} }
return this.prismaService.follow.delete({ return this.prismaService.follow.delete({
@ -105,6 +107,6 @@ export class FollowService {
following: true, following: true,
follower: true, follower: true,
}, },
}) });
} }
} }

View File

@ -0,0 +1 @@
export { FollowModel } from './model/follow.model';

View File

@ -1,27 +1,28 @@
import { UserModel } from '@/src/module/auth/account/models/user.model' import { Field, ID, ObjectType } from '@nestjs/graphql';
import { Field, ID, ObjectType } from '@nestjs/graphql'
import { Follow } from '@prisma/generated' import { UserModel } from '@/src/module/auth/account/models/user.model';
import { Follow } from '@prisma/generated';
@ObjectType() @ObjectType()
export class FollowModel implements Follow { export class FollowModel implements Follow {
@Field(() => ID) @Field(() => ID)
public id: string public id: string;
@Field(() => UserModel) @Field(() => UserModel)
public following: UserModel public following: UserModel;
@Field(() => ID) @Field(() => ID)
public followingId: string public followingId: string;
@Field(() => UserModel) @Field(() => UserModel)
public follower: UserModel public follower: UserModel;
@Field(() => ID) @Field(() => ID)
public followerId: string public followerId: string;
@Field(() => Date) @Field(() => Date)
public createdAt: Date public createdAt: Date;
@Field(() => Date) @Field(() => Date)
public updatedAt: Date public updatedAt: Date;
} }

View File

@ -1,10 +1,11 @@
import { LiveKitService } from '@/src/module/libs/livekit/livekit.service' import { DynamicModule, Module } from '@nestjs/common';
import { LiveKitService } from '@/src/module/libs/livekit/livekit.service';
import { import {
LiveKitOptionSymbol, LiveKitOptionSymbol,
TypeLiveKitAsyncOptions, TypeLiveKitAsyncOptions,
TypeLiveKitOptions, TypeLiveKitOptions,
} from '@/src/module/libs/livekit/type/livekit.type' } from '@/src/module/libs/livekit/type/livekit.type';
import { DynamicModule, Module } from '@nestjs/common'
@Module({}) @Module({})
export class LiveKitModule { export class LiveKitModule {
@ -20,7 +21,7 @@ export class LiveKitModule {
], ],
exports: [LiveKitService], exports: [LiveKitService],
global: true, global: true,
} };
} }
public static registerAsync(options: TypeLiveKitAsyncOptions): DynamicModule { public static registerAsync(options: TypeLiveKitAsyncOptions): DynamicModule {
@ -37,6 +38,6 @@ export class LiveKitModule {
], ],
exports: [LiveKitService], exports: [LiveKitService],
global: true, global: true,
} };
} }
} }

View File

@ -1,44 +1,47 @@
import { LiveKitOptionSymbol, TypeLiveKitOptions } from '@/src/module/libs/livekit/type/livekit.type' import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common' import { IngressClient, RoomServiceClient, WebhookReceiver } from 'livekit-server-sdk';
import { IngressClient, RoomServiceClient, WebhookReceiver } from 'livekit-server-sdk'
import { LiveKitOptionSymbol, TypeLiveKitOptions } from '@/src/module/libs/livekit/type/livekit.type';
@Injectable() @Injectable()
export class LiveKitService { export class LiveKitService {
private roomService: RoomServiceClient private readonly roomService: RoomServiceClient;
private ingressClient: IngressClient
private webhookReceiver: WebhookReceiver private readonly ingressClient: IngressClient;
private readonly webhookReceiver: WebhookReceiver;
constructor( constructor(
@Inject(LiveKitOptionSymbol) private readonly options: TypeLiveKitOptions, @Inject(LiveKitOptionSymbol) private readonly options: TypeLiveKitOptions,
) { ) {
this.roomService = new RoomServiceClient(options.apiUrl, options.apiKey, options.apiSecret) this.roomService = new RoomServiceClient(options.apiUrl, options.apiKey, options.apiSecret);
this.ingressClient = new IngressClient(options.apiUrl) this.ingressClient = new IngressClient(options.apiUrl);
this.webhookReceiver = new WebhookReceiver(options.apiKey, options.apiSecret) this.webhookReceiver = new WebhookReceiver(options.apiKey, options.apiSecret);
} }
public get ingress(): IngressClient { public get ingress(): IngressClient {
return this.createProxy(this.ingressClient) return this.createProxy(this.ingressClient);
} }
public get room(): RoomServiceClient { public get room(): RoomServiceClient {
return this.createProxy(this.roomService) return this.createProxy(this.roomService);
} }
public get webhook(): WebhookReceiver { public get webhook(): WebhookReceiver {
return this.createProxy(this.webhookReceiver) return this.createProxy(this.webhookReceiver);
} }
private createProxy<Target extends object, Prop extends keyof Target>(target: Target) { private createProxy<Target extends object, Prop extends keyof Target>(target: Target) {
return new Proxy(target, { return new Proxy(target, {
get: (obj, prop) => { get: (obj, prop) => {
const value = obj[prop as Prop] const value = obj[prop as Prop];
if (typeof value === 'function') { if (typeof value === 'function') {
return value.bind(obj) return value.bind(obj);
} }
return value return value;
}, },
}) });
} }
} }

View File

@ -1,12 +1,11 @@
import { FactoryProvider, ModuleMetadata } from '@nestjs/common' import type { FactoryProvider, ModuleMetadata } from '@nestjs/common';
export const LiveKitOptionSymbol = Symbol('LivekitOptionSymbol') export const LiveKitOptionSymbol = Symbol('LivekitOptionSymbol');
export type TypeLiveKitOptions = { export type TypeLiveKitOptions = {
apiUrl: string apiUrl: string;
apiKey: string apiKey: string;
apiSecret: string apiSecret: string;
} };
export type TypeLiveKitAsyncOptions = Pick<ModuleMetadata, 'imports'> export type TypeLiveKitAsyncOptions = Pick<FactoryProvider<TypeLiveKitOptions>, 'inject' | 'useFactory'> & Pick<ModuleMetadata, 'imports'>;
& Pick<FactoryProvider<TypeLiveKitOptions>, 'useFactory' | 'inject'>

View File

@ -1,8 +1,10 @@
import { getMailConfig } from '@/src/core/config/mailer.config' import { Global, Module } from '@nestjs/common';
import { MailerModule } from '@nestjs-modules/mailer' import { ConfigModule, ConfigService } from '@nestjs/config';
import { Global, Module } from '@nestjs/common' import { MailerModule } from '@nestjs-modules/mailer';
import { ConfigModule, ConfigService } from '@nestjs/config'
import { MailService } from './mail.service' import { getMailConfig } from '@/src/core/config/mailer.config';
import { MailService } from './mail.service';
@Global() @Global()
@Module({ @Module({

View File

@ -1,16 +1,15 @@
import { SessionInfo } from '@/src/shared/types/session-metadata.types' import { Injectable } from '@nestjs/common';
import { Token } from '@prisma/generated' import { ConfigService } from '@nestjs/config';
import { ProcessEnv } from '@/src/shared/types/env' import { MailerService } from '@nestjs-modules/mailer';
import { MailerService } from '@nestjs-modules/mailer' import { render } from '@react-email/components';
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config' import { ProcessEnv } from '@/src/shared/types/env';
import { render } from '@react-email/components' import { SessionInfo } from '@/src/shared/types/session-metadata.types';
import { EnableTwoFactorTemplate } from './templates' import { Token } from '@prisma/generated';
import { VerifyChannelTemplate } from './templates'
import { VerificationTemplate } from './templates' import {
import { PasswordRecoveryTemplate } from './templates' EnableTwoFactorTemplate, VerifyChannelTemplate, VerificationTemplate, PasswordRecoveryTemplate, DeactivateTemplate, AccountDeletionTemplate,
import { DeactivateTemplate } from './templates' } from './templates';
import { AccountDeletionTemplate } from './templates'
@Injectable() @Injectable()
export class MailService { export class MailService {
@ -20,50 +19,50 @@ export class MailService {
) {} ) {}
public async sendVerificationToken(email: string, token: Token['token']) { public async sendVerificationToken(email: string, token: Token['token']) {
const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN') const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN');
const html = await render(VerificationTemplate({ domain, token })) const html = await render(VerificationTemplate({ domain, token }));
void this.sendMail(email, 'Верификация аккаунта', html) void this.sendMail(email, 'Верификация аккаунта', html);
} }
public async sendPasswordResetToken(email: string, token: Token['token'], metadata: SessionInfo) { public async sendPasswordResetToken(email: string, token: Token['token'], metadata: SessionInfo) {
const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN') const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN');
const html = await render(PasswordRecoveryTemplate({ domain, token, metadata })) const html = await render(PasswordRecoveryTemplate({ domain, token, metadata }));
void this.sendMail(email, 'Сброс пароля', html) void this.sendMail(email, 'Сброс пароля', html);
} }
public async sendDeactivateToken(email: string, token: Token['token'], metadata: SessionInfo) { public async sendDeactivateToken(email: string, token: Token['token'], metadata: SessionInfo) {
const html = await render(DeactivateTemplate({ token, metadata })) const html = await render(DeactivateTemplate({ token, metadata }));
void this.sendMail(email, 'Деактивация аккаунта', html) void this.sendMail(email, 'Деактивация аккаунта', html);
} }
public async sendAccountDeletion(email: string) { public async sendAccountDeletion(email: string) {
const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN') const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN');
const html = await render(AccountDeletionTemplate({ domain })) const html = await render(AccountDeletionTemplate({ domain }));
void this.sendMail(email, 'Удаление аккаунта', html) void this.sendMail(email, 'Удаление аккаунта', html);
} }
public async sendEnableTwoFactor(email: string) { public async sendEnableTwoFactor(email: string) {
const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN') const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN');
const html = await render(EnableTwoFactorTemplate({ domain })) const html = await render(EnableTwoFactorTemplate({ domain }));
void this.sendMail(email, 'Обеспечьте свою безопасность', html) void this.sendMail(email, 'Обеспечьте свою безопасность', html);
} }
public async sendVerifyChannel(email: string) { public async sendVerifyChannel(email: string) {
const html = await render(VerifyChannelTemplate()) const html = await render(VerifyChannelTemplate());
void this.sendMail(email, 'Ваш канал верифицирован', html) void this.sendMail(email, 'Ваш канал верифицирован', html);
} }
private sendMail(email: string, subject: string, html: string) { private async sendMail(email: string, subject: string, html: string) {
return this.mailerService.sendMail({ return this.mailerService.sendMail({
to: email, to: email,
subject, subject,
html, html,
}) });
} }
} }

View File

@ -8,26 +8,29 @@ import {
Section, Section,
Tailwind, Tailwind,
Text, Text,
} from '@react-email/components' } from '@react-email/components';
import * as React from 'react' import * as React from 'react';
interface AccountDeletionTemplateProps { type AccountDeletionTemplateProps = {
domain: string domain: string;
} };
export function AccountDeletionTemplate({ domain }: AccountDeletionTemplateProps) { export const AccountDeletionTemplate = ({ domain }: AccountDeletionTemplateProps) => {
const registerLink = `${domain}/account/create` const registerLink = `${domain}/account/create`;
return ( return (
<Html> <Html>
<Head /> <Head />
<Preview>Аккаунт удалён</Preview> <Preview>Аккаунт удалён</Preview>
<Tailwind> <Tailwind>
<Body className="max-w-2xl mx-auto p-6 bg-slate-50"> <Body className="max-w-2xl mx-auto p-6 bg-slate-50">
<Section className="text-center"> <Section className="text-center">
<Heading className="text-3xl text-black font-bold"> <Heading className="text-3xl text-black font-bold">
Ваш аккаунт был полностью удалён Ваш аккаунт был полностью удалён
</Heading> </Heading>
<Text className="text-base text-black mt-2"> <Text className="text-base text-black mt-2">
Ваш аккаунт был полностью стерт из базы данных TeaStream. Все ваши данные и информация были удалены безвозвратно. Ваш аккаунт был полностью стерт из базы данных TeaStream. Все ваши данные и информация были удалены безвозвратно.
</Text> </Text>
@ -37,12 +40,14 @@ export function AccountDeletionTemplate({ domain }: AccountDeletionTemplateProps
<Text> <Text>
Вы больше не будете получать уведомления в Telegram и на почту. Вы больше не будете получать уведомления в Telegram и на почту.
</Text> </Text>
<Text> <Text>
Если вы захотите вернуться на платформу, вы можете зарегистрироваться по следующей ссылке: Если вы захотите вернуться на платформу, вы можете зарегистрироваться по следующей ссылке:
</Text> </Text>
<Link <Link
href={registerLink}
className="inline-flex justify-center items-center rounded-md mt-2 text-sm font-medium text-white bg-[#18B9AE] px-5 py-2 rounded-full" className="inline-flex justify-center items-center rounded-md mt-2 text-sm font-medium text-white bg-[#18B9AE] px-5 py-2 rounded-full"
href={registerLink}
> >
Зарегистрироваться на Teastream Зарегистрироваться на Teastream
</Link> </Link>
@ -56,5 +61,5 @@ export function AccountDeletionTemplate({ domain }: AccountDeletionTemplateProps
</Body> </Body>
</Tailwind> </Tailwind>
</Html> </Html>
) );
} };

View File

@ -1,27 +1,33 @@
import type { SessionInfo } from '@/src/shared/types/session-metadata.types' import {
import { Body, Head, Heading, Link, Preview, Section, Tailwind, Text } from '@react-email/components' Body, Head, Heading, Link, Preview, Section, Tailwind, Text,
import { Html } from '@react-email/html' } from '@react-email/components';
import * as React from 'react' import { Html } from '@react-email/html';
import * as React from 'react';
interface DeactivateTemplateProps { import type { SessionInfo } from '@/src/shared/types/session-metadata.types';
token: string
metadata: SessionInfo
}
export function DeactivateTemplate({ token, metadata }: DeactivateTemplateProps) { type DeactivateTemplateProps = {
return ( token: string;
metadata: SessionInfo;
};
export const DeactivateTemplate = ({ token, metadata }: DeactivateTemplateProps) => (
<Html> <Html>
<Head /> <Head />
<Preview>Деактивация аккаунта</Preview> <Preview>Деактивация аккаунта</Preview>
<Tailwind> <Tailwind>
<Body className="max-w-2xl mx-auto p-6 bg-slate-50"> <Body className="max-w-2xl mx-auto p-6 bg-slate-50">
<Section className="text-center mb-8"> <Section className="text-center mb-8">
<Heading className="text-3xl text-black font-bold"> <Heading className="text-3xl text-black font-bold">
Запрос на деактивацию аккаунта Запрос на деактивацию аккаунта
</Heading> </Heading>
<Text className="text-black text-base mt-2"> <Text className="text-black text-base mt-2">
Вы инициировали процесс деактивации вашего аккаунта на платформе Вы инициировали процесс деактивации вашего аккаунта на платформе
{' '} {' '}
<b>TeaStream</b> <b>TeaStream</b>
. .
</Text> </Text>
@ -31,9 +37,11 @@ export function DeactivateTemplate({ token, metadata }: DeactivateTemplateProps)
<Heading className="text-2xl text-black font-semibold"> <Heading className="text-2xl text-black font-semibold">
Код подтверждения: Код подтверждения:
</Heading> </Heading>
<Heading className="text-3xl text-black font-semibold"> <Heading className="text-3xl text-black font-semibold">
{token} {token}
</Heading> </Heading>
<Text className="text-black"> <Text className="text-black">
Этот код действителен в течение 5 минут. Этот код действителен в течение 5 минут.
</Text> </Text>
@ -45,26 +53,32 @@ export function DeactivateTemplate({ token, metadata }: DeactivateTemplateProps)
> >
Информация о запросе: Информация о запросе:
</Heading> </Heading>
<ul className="list-disc list-inside text-black mt-2"> <ul className="list-disc list-inside text-black mt-2">
<li> <li>
🌍 Расположение: 🌍 Расположение:
{metadata.location.country} {metadata.location.country}
, ,
{metadata.location.city} {metadata.location.city}
</li> </li>
<li> <li>
📱 Операционная система: 📱 Операционная система:
{metadata.device.os} {metadata.device.os}
</li> </li>
<li> <li>
🌐 Браузер: 🌐 Браузер:
{metadata.device.browser} {metadata.device.browser}
</li> </li>
<li> <li>
💻 IP-адрес: 💻 IP-адрес:
{metadata.ip} {metadata.ip}
</li> </li>
</ul> </ul>
<Text className="text-gray-600 mt-2"> <Text className="text-gray-600 mt-2">
Если вы не инициировали этот запрос, пожалуйста, игнорируйте это сообщение. Если вы не инициировали этот запрос, пожалуйста, игнорируйте это сообщение.
</Text> </Text>
@ -74,9 +88,10 @@ export function DeactivateTemplate({ token, metadata }: DeactivateTemplateProps)
<Text className="text-gray-600"> <Text className="text-gray-600">
Если у вас есть вопросы или вы столкнулись с трудностями, не стесняйтесь обращаться в нашу службу поддержки по адресу Если у вас есть вопросы или вы столкнулись с трудностями, не стесняйтесь обращаться в нашу службу поддержки по адресу
{' '} {' '}
<Link <Link
href="mailto:help@teastream.ru"
className="text-[#18b9ae] underline" className="text-[#18b9ae] underline"
href="mailto:help@teastream.ru"
> >
help@teastream.ru help@teastream.ru
</Link> </Link>
@ -86,5 +101,4 @@ export function DeactivateTemplate({ token, metadata }: DeactivateTemplateProps)
</Body> </Body>
</Tailwind> </Tailwind>
</Html> </Html>
) );
}

View File

@ -8,26 +8,29 @@ import {
Section, Section,
Tailwind, Tailwind,
Text, Text,
} from '@react-email/components' } from '@react-email/components';
import * as React from 'react' import * as React from 'react';
interface EnableTwoFactorTemplateProps { type EnableTwoFactorTemplateProps = {
domain: string domain: string;
} };
export function EnableTwoFactorTemplate({ domain }: EnableTwoFactorTemplateProps) { export const EnableTwoFactorTemplate = ({ domain }: EnableTwoFactorTemplateProps) => {
const settingsLink = `${domain}/dashboard/settings` const settingsLink = `${domain}/dashboard/settings`;
return ( return (
<Html> <Html>
<Head /> <Head />
<Preview>Обеспечьте свою безопасность</Preview> <Preview>Обеспечьте свою безопасность</Preview>
<Tailwind> <Tailwind>
<Body className="max-w-2xl mx-auto p-6 bg-slate-50"> <Body className="max-w-2xl mx-auto p-6 bg-slate-50">
<Section className="text-center mb-8"> <Section className="text-center mb-8">
<Heading className="text-3xl text-black font-bold"> <Heading className="text-3xl text-black font-bold">
Защитите свой аккаунт с двухфакторной аутентификацией Защитите свой аккаунт с двухфакторной аутентификацией
</Heading> </Heading>
<Text className="text-black text-base mt-2"> <Text className="text-black text-base mt-2">
Включите двухфакторную аутентификацию, чтобы повысить безопасность вашего аккаунта. Включите двухфакторную аутентификацию, чтобы повысить безопасность вашего аккаунта.
</Text> </Text>
@ -37,12 +40,14 @@ export function EnableTwoFactorTemplate({ domain }: EnableTwoFactorTemplateProps
<Heading className="text-2xl text-black font-semibold"> <Heading className="text-2xl text-black font-semibold">
Почему это важно? Почему это важно?
</Heading> </Heading>
<Text className="text-base text-black mt-2"> <Text className="text-base text-black mt-2">
Двухфакторная аутентификация добавляет дополнительный уровень защиты, требуя код, который известен только вам. Двухфакторная аутентификация добавляет дополнительный уровень защиты, требуя код, который известен только вам.
</Text> </Text>
<Link <Link
href={settingsLink}
className="inline-flex justify-center items-center rounded-md text-sm font-medium text-white bg-[#18B9AE] px-5 py-2 rounded-full" className="inline-flex justify-center items-center rounded-md text-sm font-medium text-white bg-[#18B9AE] px-5 py-2 rounded-full"
href={settingsLink}
> >
Перейти в настройки аккаунта Перейти в настройки аккаунта
</Link> </Link>
@ -52,9 +57,10 @@ export function EnableTwoFactorTemplate({ domain }: EnableTwoFactorTemplateProps
<Text className="text-gray-600"> <Text className="text-gray-600">
Если у вас возникли вопросы, обращайтесь в службу поддержки по адресу Если у вас возникли вопросы, обращайтесь в службу поддержки по адресу
{' '} {' '}
<Link <Link
href="mailto:help@teastream.ru"
className="text-[#18b9ae] underline" className="text-[#18b9ae] underline"
href="mailto:help@teastream.ru"
> >
help@teastream.ru help@teastream.ru
</Link> </Link>
@ -64,5 +70,5 @@ export function EnableTwoFactorTemplate({ domain }: EnableTwoFactorTemplateProps
</Body> </Body>
</Tailwind> </Tailwind>
</Html> </Html>
) );
} };

View File

@ -1,6 +1,6 @@
export { default as VerificationTemplate } from './verification.template' export { default as VerificationTemplate } from './verification.template';
export { AccountDeletionTemplate } from './account-deletion.template' export { AccountDeletionTemplate } from './account-deletion.template';
export { DeactivateTemplate } from './deactivate.template' export { DeactivateTemplate } from './deactivate.template';
export { EnableTwoFactorTemplate } from './enable-two-factor.template' export { EnableTwoFactorTemplate } from './enable-two-factor.template';
export { default as PasswordRecoveryTemplate } from './password-recovery.template' export { default as PasswordRecoveryTemplate } from './password-recovery.template';
export { VerifyChannelTemplate } from './verify-channel.template' export { VerifyChannelTemplate } from './verify-channel.template';

View File

@ -1,35 +1,43 @@
import * as React from 'react' import {
import { Body, Head, Heading, Preview, Section, Tailwind, Text, Link } from '@react-email/components' Body, Head, Heading, Preview, Section, Tailwind, Text, Link,
import { SessionInfo } from '@/src/shared/types/session-metadata.types' } from '@react-email/components';
import { Html } from '@react-email/html' import { Html } from '@react-email/html';
import * as React from 'react';
import type { SessionInfo } from '@/src/shared/types/session-metadata.types';
type PasswordRecoveryTemplateProps = { type PasswordRecoveryTemplateProps = {
domain: string domain: string;
token: string token: string;
metadata: SessionInfo metadata: SessionInfo;
} };
const PasswordRecoveryTemplate = (props: PasswordRecoveryTemplateProps) => { const PasswordRecoveryTemplate = (props: PasswordRecoveryTemplateProps) => {
const { domain, metadata, token } = props const { domain, metadata, token } = props;
const resetLink = `${domain}/account/recovery/${token}` const resetLink = `${domain}/account/recovery/${token}`;
return ( return (
<Html> <Html>
<Head /> <Head />
<Preview>Сброс пароля</Preview> <Preview>Сброс пароля</Preview>
<Tailwind> <Tailwind>
<Body className="max-w-2xl mx-auto p-6 bg-slate-50"> <Body className="max-w-2xl mx-auto p-6 bg-slate-50">
<Section className="text-center mb-8"> <Section className="text-center mb-8">
<Heading className="text-3xl text-black font-bold"> <Heading className="text-3xl text-black font-bold">
Сброс пароля Сброс пароля
</Heading> </Heading>
<Text className="text-black text-base mt-2"> <Text className="text-black text-base mt-2">
Вы запросили сброс пароля для вашей учетной записи. Вы запросили сброс пароля для вашей учетной записи.
</Text> </Text>
<Text className="text-black text-base mt-2"> <Text className="text-black text-base mt-2">
Чтобы создать новый пароль, нажмите на ссылку ниже: Чтобы создать новый пароль, нажмите на ссылку ниже:
</Text> </Text>
<Link href={resetLink} className="inline-flex justify-center items-center rounded-full text-sm font-medium text-white bg-[#18B9AE] px-5 py-2">
<Link className="inline-flex justify-center items-center rounded-full text-sm font-medium text-white bg-[#18B9AE] px-5 py-2" href={resetLink}>
Сбросить пароль Сбросить пароль
</Link> </Link>
</Section> </Section>
@ -40,26 +48,32 @@ const PasswordRecoveryTemplate = (props: PasswordRecoveryTemplateProps) => {
> >
Информация о запросе: Информация о запросе:
</Heading> </Heading>
<ul className="list-disc list-inside text-black mt-2"> <ul className="list-disc list-inside text-black mt-2">
<li> <li>
🌍 Расположение: 🌍 Расположение:
{metadata.location.country} {metadata.location.country}
, ,
{metadata.location.city} {metadata.location.city}
</li> </li>
<li> <li>
📱 Операционная система: 📱 Операционная система:
{metadata.device.os} {metadata.device.os}
</li> </li>
<li> <li>
🌐 Браузер: 🌐 Браузер:
{metadata.device.browser} {metadata.device.browser}
</li> </li>
<li> <li>
💻 IP-адрес: 💻 IP-адрес:
{metadata.ip} {metadata.ip}
</li> </li>
</ul> </ul>
<Text className="text-gray-600 mt-2"> <Text className="text-gray-600 mt-2">
Если вы не инициировали этот запрос, пожалуйста, игнорируйте это сообщение. Если вы не инициировали этот запрос, пожалуйста, игнорируйте это сообщение.
</Text> </Text>
@ -69,9 +83,10 @@ const PasswordRecoveryTemplate = (props: PasswordRecoveryTemplateProps) => {
<Text className="text-gray-600"> <Text className="text-gray-600">
Если у вас есть вопросы или вы столкнулись с трудностями, не стесняйтесь обращаться в нашу службу поддержки по адресу Если у вас есть вопросы или вы столкнулись с трудностями, не стесняйтесь обращаться в нашу службу поддержки по адресу
{' '} {' '}
<Link <Link
href="mailto:help@teastream.ru"
className="text-[#18b9ae] underline" className="text-[#18b9ae] underline"
href="mailto:help@teastream.ru"
> >
help@teastream.ru help@teastream.ru
</Link> </Link>
@ -81,7 +96,7 @@ const PasswordRecoveryTemplate = (props: PasswordRecoveryTemplateProps) => {
</Body> </Body>
</Tailwind> </Tailwind>
</Html> </Html>
) );
} };
export default PasswordRecoveryTemplate export default PasswordRecoveryTemplate;

View File

@ -1,15 +1,17 @@
import * as React from 'react' import {
import { Body, Head, Heading, Link, Preview, Section, Tailwind, Text } from '@react-email/components' Body, Head, Heading, Link, Preview, Section, Tailwind, Text,
import { Html } from '@react-email/html' } from '@react-email/components';
import { Html } from '@react-email/html';
import * as React from 'react';
type VerificationTemplateProps = { type VerificationTemplateProps = {
domain: string domain: string;
token: string token: string;
} };
const VerificationTemplate = (props: VerificationTemplateProps) => { const VerificationTemplate = (props: VerificationTemplateProps) => {
const { domain, token } = props const { domain, token } = props;
const verificationLink = `${domain}/account/verify?token=${token}` const verificationLink = `${domain}/account/verify?token=${token}`;
return ( return (
<Html> <Html>
@ -31,7 +33,7 @@ const VerificationTemplate = (props: VerificationTemplateProps) => {
Чтобы подтвердить свой адрес электронной почты, перейдите по следующей ссылке Чтобы подтвердить свой адрес электронной почты, перейдите по следующей ссылке
</Text> </Text>
<Link href={verificationLink} className="inline-flex justify-center items-center rounded-full text-sm font-medium text-white bg-[#18B9AE] px-5 py-2"> <Link className="inline-flex justify-center items-center rounded-full text-sm font-medium text-white bg-[#18B9AE] px-5 py-2" href={verificationLink}>
Подтвердить почту Подтвердить почту
</Link> </Link>
</Section> </Section>
@ -40,9 +42,10 @@ const VerificationTemplate = (props: VerificationTemplateProps) => {
<Text className="text-gray-600"> <Text className="text-gray-600">
Если у вас есть вопросы или вы столкнулись с трудностями, не стесняйтесь обращаться в нашу службу поддержки по адресу Если у вас есть вопросы или вы столкнулись с трудностями, не стесняйтесь обращаться в нашу службу поддержки по адресу
{' '} {' '}
<Link <Link
href="mailto:help@teastream.ru"
className="text-[#18b9ae] underline" className="text-[#18b9ae] underline"
href="mailto:help@teastream.ru"
> >
help@teastream.ru help@teastream.ru
</Link> </Link>
@ -53,7 +56,7 @@ const VerificationTemplate = (props: VerificationTemplateProps) => {
</Tailwind> </Tailwind>
</Html> </Html>
) );
} };
export default VerificationTemplate export default VerificationTemplate;

View File

@ -8,20 +8,22 @@ import {
Section, Section,
Tailwind, Tailwind,
Text, Text,
} from '@react-email/components' } from '@react-email/components';
import * as React from 'react' import * as React from 'react';
export function VerifyChannelTemplate() { export const VerifyChannelTemplate = () => (
return (
<Html> <Html>
<Head /> <Head />
<Preview>Ваш канал верифицирован</Preview> <Preview>Ваш канал верифицирован</Preview>
<Tailwind> <Tailwind>
<Body className="max-w-2xl mx-auto p-6 bg-slate-50"> <Body className="max-w-2xl mx-auto p-6 bg-slate-50">
<Section className="text-center mb-8"> <Section className="text-center mb-8">
<Heading className="text-3xl text-black font-bold"> <Heading className="text-3xl text-black font-bold">
Поздравляем! Ваш канал верифицирован Поздравляем! Ваш канал верифицирован
</Heading> </Heading>
<Text className="text-black text-base mt-2"> <Text className="text-black text-base mt-2">
Мы рады сообщить, что ваш канал теперь верифицирован, и вы получили официальный значок. Мы рады сообщить, что ваш канал теперь верифицирован, и вы получили официальный значок.
</Text> </Text>
@ -31,6 +33,7 @@ export function VerifyChannelTemplate() {
<Heading className="text-2xl text-black font-semibold"> <Heading className="text-2xl text-black font-semibold">
Что это значит? Что это значит?
</Heading> </Heading>
<Text className="text-base text-black mt-2"> <Text className="text-base text-black mt-2">
Значок верификации подтверждает подлинность вашего канала и улучшает доверие зрителей. Значок верификации подтверждает подлинность вашего канала и улучшает доверие зрителей.
</Text> </Text>
@ -40,9 +43,10 @@ export function VerifyChannelTemplate() {
<Text className="text-gray-600"> <Text className="text-gray-600">
Если у вас есть вопросы, напишите нам на Если у вас есть вопросы, напишите нам на
{' '} {' '}
<Link <Link
href="mailto:help@teastream.ru"
className="text-[#18b9ae] underline" className="text-[#18b9ae] underline"
href="mailto:help@teastream.ru"
> >
help@teastream.ru help@teastream.ru
</Link> </Link>
@ -52,5 +56,4 @@ export function VerifyChannelTemplate() {
</Body> </Body>
</Tailwind> </Tailwind>
</Html> </Html>
) );
}

View File

@ -1,5 +1,6 @@
import { Global, Module } from '@nestjs/common' import { Global, Module } from '@nestjs/common';
import { StorageService } from './storage.service'
import { StorageService } from './storage.service';
@Global() @Global()
@Module({ @Module({

View File

@ -4,15 +4,17 @@ import {
PutObjectCommand, PutObjectCommand,
PutObjectCommandInput, PutObjectCommandInput,
S3Client, S3Client,
} from '@aws-sdk/client-s3' } from '@aws-sdk/client-s3';
import { BadRequestException, Injectable } from '@nestjs/common' import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config' import { ConfigService } from '@nestjs/config';
import { ProcessEnv } from '../../../shared/types/env'; import { ProcessEnv } from '../../../shared/types/env';
@Injectable() @Injectable()
export class StorageService { export class StorageService {
private readonly client: S3Client private readonly client: S3Client;
private readonly bucket: string
private readonly bucket: string;
constructor( constructor(
private readonly configService: ConfigService<ProcessEnv>, private readonly configService: ConfigService<ProcessEnv>,
@ -24,9 +26,9 @@ export class StorageService {
accessKeyId: this.configService.getOrThrow('S3_ACCESS_KEY_ID'), accessKeyId: this.configService.getOrThrow('S3_ACCESS_KEY_ID'),
secretAccessKey: this.configService.getOrThrow('S3_SECRET_KEY_ID'), secretAccessKey: this.configService.getOrThrow('S3_SECRET_KEY_ID'),
}, },
}) });
this.bucket = this.configService.getOrThrow('S3_BUCKET_NAME') this.bucket = this.configService.getOrThrow('S3_BUCKET_NAME');
} }
public async upload(buffer: Buffer, key: string, mimetype: string) { public async upload(buffer: Buffer, key: string, mimetype: string) {
@ -35,28 +37,25 @@ export class StorageService {
Key: String(key), Key: String(key),
Body: buffer, Body: buffer,
ContentType: mimetype, ContentType: mimetype,
} };
try { try {
await this.client.send(new PutObjectCommand(command)) await this.client.send(new PutObjectCommand(command));
} } catch {
catch (e) { throw new BadRequestException('Ошибка при загрузке файла');
throw new BadRequestException('Ошибка при загрузке файла')
} }
} }
public async remove(key: string) { public async remove(key: string) {
const command: DeleteObjectCommandInput = { const command: DeleteObjectCommandInput = {
Bucket: this.bucket, Bucket: this.bucket,
Key: String(key), Key: key,
} };
try { try {
await this.client.send(new DeleteObjectCommand(command)) await this.client.send(new DeleteObjectCommand(command));
} } catch {
catch (e) { throw new BadRequestException('Ошибка при удалении файла');
console.log('e', e);
throw new BadRequestException('Ошибка при удалении файла')
} }
} }
} }

View File

@ -1,6 +1,7 @@
import { DynamicModule, Module } from '@nestjs/common' import { DynamicModule, Module } from '@nestjs/common';
import { StripeOptionSymbol, TypeStripeAsyncOptions, TypeStripeOptions } from './types/stripe.type'
import { StripeService } from './stripe.service' import { StripeService } from './stripe.service';
import { StripeOptionSymbol, TypeStripeAsyncOptions, TypeStripeOptions } from './types/stripe.type';
@Module({}) @Module({})
export class StripeModule { export class StripeModule {
@ -16,7 +17,7 @@ export class StripeModule {
], ],
exports: [StripeService], exports: [StripeService],
global: true, global: true,
} };
} }
public static registerAsync(options: TypeStripeAsyncOptions): DynamicModule { public static registerAsync(options: TypeStripeAsyncOptions): DynamicModule {
@ -33,6 +34,6 @@ export class StripeModule {
], ],
exports: [StripeService], exports: [StripeService],
global: true, global: true,
} };
} }
} }

View File

@ -1,6 +1,7 @@
import { StripeOptionSymbol, TypeStripeOptions } from '@/src/module/libs/stripe/types/stripe.type' import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common' import Stripe from 'stripe';
import Stripe from 'stripe'
import { StripeOptionSymbol, TypeStripeOptions } from '@/src/module/libs/stripe/types/stripe.type';
@Injectable() @Injectable()
export class StripeService extends Stripe { export class StripeService extends Stripe {
@ -8,6 +9,6 @@ export class StripeService extends Stripe {
@Inject(StripeOptionSymbol) @Inject(StripeOptionSymbol)
private readonly options: TypeStripeOptions, private readonly options: TypeStripeOptions,
) { ) {
super(options.apiKey, options.config) super(options.apiKey, options.config);
} }
} }

View File

@ -1,12 +1,11 @@
import { FactoryProvider, ModuleMetadata } from '@nestjs/common' import type { FactoryProvider, ModuleMetadata } from '@nestjs/common';
import Stripe from 'stripe' import type Stripe from 'stripe';
export const StripeOptionSymbol = Symbol('StripeOptionSymbol') export const StripeOptionSymbol = Symbol('StripeOptionSymbol');
export type TypeStripeOptions = { export type TypeStripeOptions = {
apiKey: string apiKey: string;
config?: Stripe.StripeConfig config?: Stripe.StripeConfig;
} };
export type TypeStripeAsyncOptions = Pick<ModuleMetadata, 'imports'> export type TypeStripeAsyncOptions = Pick<FactoryProvider<TypeStripeOptions>, 'inject' | 'useFactory'> & Pick<ModuleMetadata, 'imports'>;
& Pick<FactoryProvider<TypeStripeOptions>, 'useFactory' | 'inject'>

View File

@ -1,4 +1,4 @@
import { Markup } from 'telegraf' import { Markup } from 'telegraf';
export const BUTTONS = { export const BUTTONS = {
authSuccess: Markup.inlineKeyboard([ authSuccess: Markup.inlineKeyboard([
@ -14,4 +14,4 @@ export const BUTTONS = {
'https://teastream.ru/dashboard/settings', 'https://teastream.ru/dashboard/settings',
), ),
]), ]),
} };

View File

@ -1,5 +1,5 @@
import { SessionInfo } from '@/src/shared/types/session-metadata.types' import type { SessionInfo } from '@/src/shared/types/session-metadata.types';
import type { SponsorshipPlan, User } from '@prisma/generated' import type { SponsorshipPlan, User } from '@prisma/generated';
export const MESSAGES = { export const MESSAGES = {
welcome: welcome:
@ -14,68 +14,61 @@ export const MESSAGES = {
+ `👥 Количество подписчиков: <b>${followersCount}</b>\n` + `👥 Количество подписчиков: <b>${followersCount}</b>\n`
+ `📝 О себе: <b>${user.bio ?? 'Не указано'}</b>\n\n` + `📝 О себе: <b>${user.bio ?? 'Не указано'}</b>\n\n`
+ '🔧 Нажмите на кнопку ниже, чтобы перейти к настройкам профиля.', + '🔧 Нажмите на кнопку ниже, чтобы перейти к настройкам профиля.',
follows: (user: User) => follows: (user: User) => `📺 <a href="https://teastream.ru/${user.name}">${user.name}</a>`,
`📺 <a href="https://teastream.ru/${user.name}">${user.name}</a>`, resetPassword: (token: string, metadata: SessionInfo) => '<b>🔒 Сброс пароля</b>\n\n'
resetPassword: (token: string, metadata: SessionInfo) => + 'Вы запросили сброс пароля для вашей учетной записи на платформе <b>TeaStream</b>.\n\n'
`<b>🔒 Сброс пароля</b>\n\n` + 'Чтобы создать новый пароль, пожалуйста, перейдите по следующей ссылке:\n\n'
+ `Вы запросили сброс пароля для вашей учетной записи на платформе <b>TeaStream</b>.\n\n`
+ `Чтобы создать новый пароль, пожалуйста, перейдите по следующей ссылке:\n\n`
+ `<b><a href="https://teastream.ru/account/recovery/${token}">Сбросить пароль</a></b>\n\n` + `<b><a href="https://teastream.ru/account/recovery/${token}">Сбросить пароль</a></b>\n\n`
+ `📅 <b>Дата запроса:</b> ${new Date().toLocaleDateString()} в ${new Date().toLocaleTimeString()}\n\n` + `📅 <b>Дата запроса:</b> ${new Date().toLocaleDateString()} в ${new Date().toLocaleTimeString()}\n\n`
+ `🖥️ <b>Информация о запросе:</b>\n\n` + '🖥️ <b>Информация о запросе:</b>\n\n'
+ `🌍 <b>Расположение:</b> ${metadata.location.country}, ${metadata.location.city}\n` + `🌍 <b>Расположение:</b> ${metadata.location.country}, ${metadata.location.city}\n`
+ `📱 <b>Операционная система:</b> ${metadata.device.os}\n` + `📱 <b>Операционная система:</b> ${metadata.device.os}\n`
+ `🌐 <b>Браузер:</b> ${metadata.device.browser}\n` + `🌐 <b>Браузер:</b> ${metadata.device.browser}\n`
+ `💻 <b>IP-адрес:</b> ${metadata.ip}\n\n` + `💻 <b>IP-адрес:</b> ${metadata.ip}\n\n`
+ `Если вы не делали этот запрос, просто проигнорируйте это сообщение.\n\n` + 'Если вы не делали этот запрос, просто проигнорируйте это сообщение.\n\n'
+ `Спасибо за использование <b>TeaStream</b>! 🚀`, + 'Спасибо за использование <b>TeaStream</b>! 🚀',
deactivate: (token: string, metadata: SessionInfo) => deactivate: (token: string, metadata: SessionInfo) => '<b>⚠️ Запрос на деактивацию аккаунта</b>\n\n'
`<b>⚠️ Запрос на деактивацию аккаунта</b>\n\n` + 'Вы инициировали процесс деактивации вашего аккаунта на платформе <b>Teastream</b>.\n\n'
+ `Вы инициировали процесс деактивации вашего аккаунта на платформе <b>Teastream</b>.\n\n` + 'Для завершения операции, пожалуйста, подтвердите свой запрос, введя следующий код подтверждения:\n\n'
+ `Для завершения операции, пожалуйста, подтвердите свой запрос, введя следующий код подтверждения:\n\n`
+ `<b>Код подтверждения: ${token}</b>\n\n` + `<b>Код подтверждения: ${token}</b>\n\n`
+ `📅 <b>Дата запроса:</b> ${new Date().toLocaleDateString()} в ${new Date().toLocaleTimeString()}\n\n` + `📅 <b>Дата запроса:</b> ${new Date().toLocaleDateString()} в ${new Date().toLocaleTimeString()}\n\n`
+ `🖥️ <b>Информация о запросе:</b>\n\n` + '🖥️ <b>Информация о запросе:</b>\n\n'
+ `• 🌍 <b>Расположение:</b> ${metadata.location.country}, ${metadata.location.city}\n` + `• 🌍 <b>Расположение:</b> ${metadata.location.country}, ${metadata.location.city}\n`
+ `• 📱 <b>Операционная система:</b> ${metadata.device.os}\n` + `• 📱 <b>Операционная система:</b> ${metadata.device.os}\n`
+ `• 🌐 <b>Браузер:</b> ${metadata.device.browser}\n` + `• 🌐 <b>Браузер:</b> ${metadata.device.browser}\n`
+ `• 💻 <b>IP-адрес:</b> ${metadata.ip}\n\n` + `• 💻 <b>IP-адрес:</b> ${metadata.ip}\n\n`
+ `<b>Что произойдет после деактивации?</b>\n\n` + '<b>Что произойдет после деактивации?</b>\n\n'
+ `1. Вы автоматически выйдете из системы и потеряете доступ к аккаунту.\n` + '1. Вы автоматически выйдете из системы и потеряете доступ к аккаунту.\n'
+ `2. Если вы не отмените деактивацию в течение 7 дней, ваш аккаунт будет <b>безвозвратно удален</b> со всей вашей информацией, данными и подписками.\n\n` + '2. Если вы не отмените деактивацию в течение 7 дней, ваш аккаунт будет <b>безвозвратно удален</b> со всей вашей информацией, данными и подписками.\n\n'
+ `<b>⏳ Обратите внимание:</b> Если в течение 7 дней вы передумаете, вы можете обратиться в нашу поддержку для восстановления доступа к вашему аккаунту до момента его полного удаления.\n\n` + '<b>⏳ Обратите внимание:</b> Если в течение 7 дней вы передумаете, вы можете обратиться в нашу поддержку для восстановления доступа к вашему аккаунту до момента его полного удаления.\n\n'
+ `После удаления аккаунта восстановить его будет невозможно, и все данные будут потеряны без возможности восстановления.\n\n` + 'После удаления аккаунта восстановить его будет невозможно, и все данные будут потеряны без возможности восстановления.\n\n'
+ `Если вы передумали, просто проигнорируйте это сообщение. Ваш аккаунт останется активным.\n\n` + 'Если вы передумали, просто проигнорируйте это сообщение. Ваш аккаунт останется активным.\n\n'
+ `Спасибо, что пользуетесь <b>TeaStream</b>! Мы всегда рады видеть вас на нашей платформе и надеемся, что вы останетесь с нами. 🚀\n\n` + 'Спасибо, что пользуетесь <b>TeaStream</b>! Мы всегда рады видеть вас на нашей платформе и надеемся, что вы останетесь с нами. 🚀\n\n'
+ `С уважением,\n` + 'С уважением,\n'
+ `Команда TeaStream`, + 'Команда TeaStream',
accountDeleted: accountDeleted:
`<b>⚠️ Ваш аккаунт был полностью удалён.</b>\n\n` '<b>⚠️ Ваш аккаунт был полностью удалён.</b>\n\n'
+ `Ваш аккаунт был полностью стерт из базы данных Teastream. Все ваши данные и информация были удалены безвозвратно. ❌\n\n` + 'Ваш аккаунт был полностью стерт из базы данных Teastream. Все ваши данные и информация были удалены безвозвратно. ❌\n\n'
+ `🔒 Вы больше не будете получать уведомления в Telegram и на почту.\n\n` + '🔒 Вы больше не будете получать уведомления в Telegram и на почту.\n\n'
+ `Если вы захотите вернуться на платформу, вы можете зарегистрироваться по следующей ссылке:\n` + 'Если вы захотите вернуться на платформу, вы можете зарегистрироваться по следующей ссылке:\n'
+ `<b><a href="https://teastream.ru/account/create">Зарегистрироваться на Teastream</a></b>\n\n` + '<b><a href="https://teastream.ru/account/create">Зарегистрироваться на Teastream</a></b>\n\n'
+ `Спасибо, что были с нами! Мы всегда будем рады видеть вас на платформе. 🚀\n\n` + 'Спасибо, что были с нами! Мы всегда будем рады видеть вас на платформе. 🚀\n\n'
+ `С уважением,\n` + 'С уважением,\n'
+ `Команда TeaStream`, + 'Команда TeaStream',
streamStart: (channel: User) => streamStart: (channel: User) => `<b>📡 На канале ${channel.displayName} началась трансляция!</b>\n\n`
`<b>📡 На канале ${channel.displayName} началась трансляция!</b>\n\n`
+ `Смотрите здесь: <a href="https://teastream.ru/${channel.name}">Перейти к трансляции</a>`, + `Смотрите здесь: <a href="https://teastream.ru/${channel.name}">Перейти к трансляции</a>`,
newFollowing: (follower: User, followersCount: number) => newFollowing: (follower: User, followersCount: number) => `<b>У вас новый подписчик!</b>\n\nЭто пользователь <a href="https://teastream.ru/${follower.name}">${follower.displayName}</a>\n\nИтоговое количество подписчиков на вашем канале: ${followersCount}`,
`<b>У вас новый подписчик!</b>\n\nЭто пользователь <a href="https://teastream.ru/${follower.name}">${follower.displayName}</a>\n\nИтоговое количество подписчиков на вашем канале: ${followersCount}`,
enableTwoFactor: enableTwoFactor:
`🔐 Обеспечьте свою безопасность!\n\n` '🔐 Обеспечьте свою безопасность!\n\n'
+ `Включите двухфакторную аутентификацию в <a href="https://teastream.ru/dashboard/settings">настройках аккаунта</a>.`, + 'Включите двухфакторную аутентификацию в <a href="https://teastream.ru/dashboard/settings">настройках аккаунта</a>.',
verifyChannel: verifyChannel:
`<b>🎉 Поздравляем! Ваш канал верифицирован</b>\n\n` '<b>🎉 Поздравляем! Ваш канал верифицирован</b>\n\n'
+ `Мы рады сообщить, что ваш канал теперь верифицирован, и вы получили официальный значок.\n\n` + 'Мы рады сообщить, что ваш канал теперь верифицирован, и вы получили официальный значок.\n\n'
+ `Значок верификации подтверждает подлинность вашего канала и улучшает доверие зрителей.\n\n` + 'Значок верификации подтверждает подлинность вашего канала и улучшает доверие зрителей.\n\n'
+ `Спасибо, что вы с нами и продолжаете развивать свой канал вместе с TeaStream!`, + 'Спасибо, что вы с нами и продолжаете развивать свой канал вместе с TeaStream!',
newSponsorship: (plan: SponsorshipPlan, sponsor: User) => newSponsorship: (plan: SponsorshipPlan, sponsor: User) => '<b>🎉 Новое спонсор!</b>\n\n'
`<b>🎉 Новое спонсор!</b>\n\n`
+ `Вы получили новое спонсорство на план <b>${plan.title}</b>.\n` + `Вы получили новое спонсорство на план <b>${plan.title}</b>.\n`
+ `💰 Сумма: <b>${plan.price} ₽</b>\n` + `💰 Сумма: <b>${plan.price} ₽</b>\n`
+ `👤 Спонсор: <a href="https://teastream.ru/${sponsor.name}">${sponsor.displayName}</a>\n` + `👤 Спонсор: <a href="https://teastream.ru/${sponsor.name}">${sponsor.displayName}</a>\n`
+ `📅 Дата оформления: <b>${new Date().toLocaleDateString()} в ${new Date().toLocaleTimeString()}</b>`, + `📅 Дата оформления: <b>${new Date().toLocaleDateString()} в ${new Date().toLocaleTimeString()}</b>`,
} };

View File

@ -1,8 +1,10 @@
import { getTelegrafOptions } from '@/src/core/config/telegraf.config' import { Global, Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common' import { ConfigModule, ConfigService } from '@nestjs/config';
import { ConfigModule, ConfigService } from '@nestjs/config' import { TelegrafModule } from 'nestjs-telegraf';
import { TelegrafModule } from 'nestjs-telegraf'
import { TelegramService } from './telegram.service' import { getTelegrafOptions } from '@/src/core/config/telegraf.config';
import { TelegramService } from './telegram.service';
@Global() @Global()
@Module({ @Module({

View File

@ -1,37 +1,37 @@
import { SessionInfo } from '@/src/shared/types/session-metadata.types' import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common' import { ConfigService } from '@nestjs/config';
import { ConfigService } from '@nestjs/config'
import { import {
Action, Action,
Command, Ctx, Start, Update, Command, Ctx, Start, Update,
} from 'nestjs-telegraf' } from 'nestjs-telegraf';
import { Context, Telegraf } from 'telegraf' import { Context, Telegraf } from 'telegraf';
import { PrismaService } from '@/src/core/prisma/prisma.service' import { PrismaService } from '@/src/core/prisma/prisma.service';
import { BUTTONS } from '@/src/module/libs/telegram/telegram.button' import { BUTTONS } from '@/src/module/libs/telegram/telegram.button';
import { MESSAGES } from '@/src/module/libs/telegram/telegram.message' import { MESSAGES } from '@/src/module/libs/telegram/telegram.message';
import { SponsorshipPlan, TokenType, User } from '@prisma/generated' import { SessionInfo } from '@/src/shared/types/session-metadata.types';
import { SponsorshipPlan, TokenType, User } from '@prisma/generated';
import { ProcessEnv } from '../../../shared/types/env' import { ProcessEnv } from '../../../shared/types/env';
@Update() @Update()
@Injectable() @Injectable()
export class TelegramService extends Telegraf { export class TelegramService extends Telegraf {
private readonly _token: string private readonly _token: string;
constructor( constructor(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly configService: ConfigService<ProcessEnv>, private readonly configService: ConfigService<ProcessEnv>,
) { ) {
super(configService.getOrThrow('TELEGRAM_BOT_TOKEN')) super(configService.getOrThrow('TELEGRAM_BOT_TOKEN'));
this._token = configService.getOrThrow('TELEGRAM_BOT_TOKEN') this._token = configService.getOrThrow('TELEGRAM_BOT_TOKEN');
} }
@Start() @Start()
public async onStart(@Ctx() ctx: Context) { public async onStart(@Ctx() ctx: Context) {
const chatId = ctx.chat?.id.toString() const chatId = ctx.chat?.id.toString();
// @ts-ignore // @ts-ignore
const token = ctx.message.text.split(' ')[1] as string const token = ctx.message.text.split(' ')[1] as string;
if (token) { if (token) {
const authToken = await this.prismaService.token.findUnique({ const authToken = await this.prismaService.token.findUnique({
@ -39,124 +39,130 @@ export class TelegramService extends Telegraf {
token, token,
type: TokenType.TELEGRAM_AUTH, type: TokenType.TELEGRAM_AUTH,
}, },
}) });
if (!authToken?.userId) { if (!authToken?.userId) {
await ctx.reply('Токен не найден') await ctx.reply('Токен не найден');
return
return;
} }
const hasExpired = new Date(authToken.expiresIn) < new Date() const hasExpired = new Date(authToken.expiresIn) < new Date();
if (hasExpired) { if (hasExpired) {
await ctx.reply(MESSAGES.invalidToken) await ctx.reply(MESSAGES.invalidToken);
return
return;
} }
await this.connectTelegram(authToken.userId, chatId!) await this.connectTelegram(authToken.userId, chatId!);
await this.prismaService.token.delete({ await this.prismaService.token.delete({
where: { id: authToken.id }, where: { id: authToken.id },
}) });
await ctx.replyWithHTML(MESSAGES.authSuccess, BUTTONS.authSuccess) await ctx.replyWithHTML(MESSAGES.authSuccess, BUTTONS.authSuccess);
return
return;
} }
const user = await this.findUserByChatId(chatId!) const user = await this.findUserByChatId(chatId!);
if (user) { if (user) {
return await this.onMe(ctx) await this.onMe(ctx);
return;
} }
await ctx.replyWithHTML(MESSAGES.welcome, BUTTONS.profile) await ctx.replyWithHTML(MESSAGES.welcome, BUTTONS.profile);
} }
@Command('me') @Command('me')
@Action('me') @Action('me')
public async onMe(@Ctx() ctx: Context) { public async onMe(@Ctx() ctx: Context) {
const chatId = ctx.chat?.id.toString() const chatId = ctx.chat?.id.toString();
if (typeof chatId === 'undefined') { if (typeof chatId === 'undefined') {
throw new NotFoundException('Пользователь не найден') throw new NotFoundException('Пользователь не найден');
} }
const user = await this.findUserByChatId(chatId) const user = await this.findUserByChatId(chatId);
if (!user) { if (!user) {
throw new NotFoundException('Пользователь не найден') throw new NotFoundException('Пользователь не найден');
} }
const followersCount = await this.prismaService.follow.count({ const followersCount = await this.prismaService.follow.count({
where: { followingId: user.id }, where: { followingId: user.id },
}) });
await ctx.replyWithHTML(MESSAGES.profile(user, followersCount), BUTTONS.profile) await ctx.replyWithHTML(MESSAGES.profile(user, followersCount), BUTTONS.profile);
} }
@Command('follows') @Command('follows')
@Action('follows') @Action('follows')
public async onFollow(@Ctx() ctx: Context) { public async onFollow(@Ctx() ctx: Context) {
const chatId = ctx.chat?.id const chatId = ctx.chat?.id;
if (typeof chatId === 'undefined') { if (typeof chatId === 'undefined') {
throw new NotFoundException('Пользователь не найден') throw new NotFoundException('Пользователь не найден');
} }
const user = await this.findUserByChatId(chatId.toString()) const user = await this.findUserByChatId(chatId.toString());
if (!user) { if (!user) {
throw new NotFoundException('Пользователь не найден') throw new NotFoundException('Пользователь не найден');
} }
const follows = await this.prismaService.follow.findMany({ const follows = await this.prismaService.follow.findMany({
where: { followerId: user.id }, where: { followerId: user.id },
include: { following: true }, include: { following: true },
}) });
if (follows.length > 0) { if (follows.length > 0) {
const followList = follows.map(follow => MESSAGES.follows(follow.following)).join('\n') const followList = follows.map((follow) => MESSAGES.follows(follow.following)).join('\n');
const message = `<b>Каналы, на которые Вы подписаны</b>\n\n${followList}` const message = `<b>Каналы, на которые Вы подписаны</b>\n\n${followList}`;
await ctx.replyWithHTML(message) await ctx.replyWithHTML(message);
return
return;
} }
await ctx.replyWithHTML('❌ <b>У Вас нет подписок</b>') await ctx.replyWithHTML('❌ <b>У Вас нет подписок</b>');
} }
public async sendPasswordResetToken(chatId: string, token: string, metadata: SessionInfo) { public async sendPasswordResetToken(chatId: string, token: string, metadata: SessionInfo) {
await this.telegram.sendMessage(chatId, MESSAGES.resetPassword(token, metadata), { parse_mode: 'HTML' }) await this.telegram.sendMessage(chatId, MESSAGES.resetPassword(token, metadata), { parse_mode: 'HTML' });
} }
public async sendDeactivateToken(chatId: string, token: string, metadata: SessionInfo) { public async sendDeactivateToken(chatId: string, token: string, metadata: SessionInfo) {
await this.telegram.sendMessage(chatId, MESSAGES.deactivate(token, metadata), { parse_mode: 'HTML' }) await this.telegram.sendMessage(chatId, MESSAGES.deactivate(token, metadata), { parse_mode: 'HTML' });
} }
public async sendAccountDeletionToken(chatId: string) { public async sendAccountDeletionToken(chatId: string) {
await this.telegram.sendMessage(chatId, MESSAGES.accountDeleted, { parse_mode: 'HTML' }) await this.telegram.sendMessage(chatId, MESSAGES.accountDeleted, { parse_mode: 'HTML' });
} }
public async sendStreamStart(chatId: string, channel: User) { public async sendStreamStart(chatId: string, channel: User) {
await this.telegram.sendMessage(chatId, MESSAGES.streamStart(channel), { parse_mode: 'HTML' }) await this.telegram.sendMessage(chatId, MESSAGES.streamStart(channel), { parse_mode: 'HTML' });
} }
public async sendNewSponsorship(chatId: string, plan: SponsorshipPlan, sponsor: User) { public async sendNewSponsorship(chatId: string, plan: SponsorshipPlan, sponsor: User) {
await this.telegram.sendMessage(chatId, MESSAGES.newSponsorship(plan, sponsor), { parse_mode: 'HTML' }) await this.telegram.sendMessage(chatId, MESSAGES.newSponsorship(plan, sponsor), { parse_mode: 'HTML' });
} }
public async sendNewFollowing(chatId: string, follower: User) { public async sendNewFollowing(chatId: string, follower: User) {
const user = await this.findUserByChatId(chatId) const user = await this.findUserByChatId(chatId);
if (!user) { if (!user) {
throw new NotFoundException('Пользователь не найден') throw new NotFoundException('Пользователь не найден');
} }
await this.telegram.sendMessage(chatId, MESSAGES.newFollowing(follower, user.followings.length), { parse_mode: 'HTML' }) await this.telegram.sendMessage(chatId, MESSAGES.newFollowing(follower, user.followings.length), { parse_mode: 'HTML' });
} }
public async sendEnableTwoFactor(chatId: string) { public async sendEnableTwoFactor(chatId: string) {
return this.telegram.sendMessage(chatId, MESSAGES.enableTwoFactor, { parse_mode: 'HTML' }) return this.telegram.sendMessage(chatId, MESSAGES.enableTwoFactor, { parse_mode: 'HTML' });
} }
public async sendVerifyChannel(chatId: string) { public async sendVerifyChannel(chatId: string) {
return this.telegram.sendMessage(chatId, MESSAGES.verifyChannel, { parse_mode: 'HTML' }) return this.telegram.sendMessage(chatId, MESSAGES.verifyChannel, { parse_mode: 'HTML' });
} }
private async connectTelegram(userId: string, chatId: string) { private async connectTelegram(userId: string, chatId: string) {
return this.prismaService.user.update({ return this.prismaService.user.update({
where: { id: userId }, where: { id: userId },
data: { telegramId: chatId }, data: { telegramId: chatId },
}) });
} }
private async findUserByChatId(chatId: string) { private async findUserByChatId(chatId: string) {
@ -166,6 +172,6 @@ export class TelegramService extends Telegraf {
followings: true, followings: true,
followers: true, followers: true,
}, },
}) });
} }
} }

View File

@ -0,0 +1,2 @@
export { NotificationSettingsModel, ChangeNotificationsSettingsResponse } from './models/notification-settings.model';
export { NotificationModel } from './models/notification.model';

View File

@ -1,13 +1,13 @@
import { Field, InputType } from '@nestjs/graphql' import { Field, InputType } from '@nestjs/graphql';
import { IsBoolean } from 'class-validator' import { IsBoolean } from 'class-validator';
@InputType() @InputType()
export class ChangeNotificationSettingsInput { export class ChangeNotificationSettingsInput {
@Field(() => Boolean) @Field(() => Boolean)
@IsBoolean() @IsBoolean()
public siteNotifications: boolean public siteNotifications: boolean;
@Field(() => Boolean) @Field(() => Boolean)
@IsBoolean() @IsBoolean()
public telegramNotifications: boolean public telegramNotifications: boolean;
} }

Some files were not shown because too many files have changed in this diff Show More