Compare commits

..

No commits in common. "a1d244d9ccbfa3718738b1b9b17060c41163aec4" and "a0e4b57de72bd91eb571321d6d0196317de95950" have entirely different histories.

159 changed files with 1310 additions and 6096 deletions

40
backend/eslint.config.mjs Normal file
View File

@ -0,0 +1,40 @@
// @ts-check
import eslint from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import stylistic from '@stylistic/eslint-plugin'
export default tseslint.config(
{
ignores: [
'node_modules',
'dist'
],
},
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: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn'
},
},
);

View File

@ -1,110 +0,0 @@
import config from 'eslint-config-ksv741';
import type { Linter } from 'eslint';
export default [
...config,
{
name: 'my-rules',
files: [
'**/*.js',
'**/*.ts',
'**/*.tsx',
],
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/member-ordering': 'off',
'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',
},
},
] satisfies Linter.Config[];

View File

@ -11,8 +11,7 @@
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
"start:prod": "node dist/main", "start:prod": "node dist/main",
"lint": "eslint src", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint:inspect": "npx @eslint/config-inspector@latest",
"test": "jest", "test": "jest",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
@ -20,8 +19,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json",
"db:push": "prisma db push", "db:push": "prisma db push",
"db:view": "prisma studio", "db:view": "prisma studio",
"db:generate": "prisma generate", "db:generate": "prisma generate"
"db:seed": "ts-node src/core/prisma/prisma.seed.ts"
}, },
"dependencies": { "dependencies": {
"@apollo/server": "^4.12.2", "@apollo/server": "^4.12.2",
@ -46,18 +44,14 @@
"connect-redis": "^7.1.1", "connect-redis": "^7.1.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"device-detector-js": "^3.0.3", "device-detector-js": "^3.0.3",
"dotenv": "^17.2.1",
"express": "^5.1.0",
"express-session": "^1.18.1", "express-session": "^1.18.1",
"geoip-lite": "^1.4.10", "geoip-lite": "^1.4.10",
"graphql": "^16.11.0", "graphql": "^16.11.0",
"graphql-subscriptions": "^3.0.0",
"graphql-upload": "14", "graphql-upload": "14",
"hi-base32": "^0.5.1", "hi-base32": "^0.5.1",
"i18n-iso-countries": "^7.14.0", "i18n-iso-countries": "^7.14.0",
"ioredis": "^5.6.1", "ioredis": "^5.6.1",
"livekit-server-sdk": "1.2.7", "livekit-server-sdk": "1.2.7",
"nestjs-telegraf": "^2.9.1",
"otpauth": "^9.4.0", "otpauth": "^9.4.0",
"prisma": "^6.10.1", "prisma": "^6.10.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
@ -66,15 +60,15 @@
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.34.2", "sharp": "^0.34.2"
"stripe": "^18.3.0",
"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",
@ -87,7 +81,6 @@
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-ksv741": "0.2.0",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
@ -96,7 +89,8 @@
"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

@ -9,55 +9,21 @@ datasource db {
} }
model Stream { model Stream {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
thumbnailUrl String? @map("thumbnail_url") thumbnailUrl String? @map("thumbnail_url")
ingressId String? @unique @map("ingress_id") ingressId String? @unique @map("ingress_id")
serverUrl String? @map("server_url") serverUrl String? @map("server_url")
key String? @map("key") key String? @map("key")
isLive Boolean @default(false) @map("is_live") isLive Boolean @default(false) @map("is_live")
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String? @unique @map("user_id") userId String? @unique @map("user_id")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade)
categoryId String? @map("category_id")
chatMessages ChatMessage[]
isChatEnable Boolean @default(true) @map("is_chat_enable")
isChatFollowersOnly Boolean @default(false) @map("is_chat_followers_onlys")
isChatPremiumFollowersOnly Boolean @default(false) @map("is_chat_premium_followers_onlys")
@@map("stream") @@map("stream")
} }
model ChatMessage {
id String @id @default(uuid())
text String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @map("user_id")
stream Stream @relation(fields: [streamId], references: [id], onDelete: Cascade)
streamId String @map("stream_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("chat_messages")
}
model Follow {
id String @id @default(uuid())
follower User @relation(name: "followers", fields: [followerId], references: [id], onDelete: Cascade)
followerId String @map("follower_id")
following User @relation(name: "followings", fields: [followingId], references: [id], onDelete: Cascade)
followingId String @map("following_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([followingId, followerId])
@@index([followerId])
@@index([followingId])
@@map("follows")
}
model SocialLink { model SocialLink {
id String @id @default(uuid()) id String @id @default(uuid())
title String title String
@ -72,51 +38,28 @@ model SocialLink {
} }
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
password String password String
name String @unique name String @unique
displayName String @map("display_name") displayName String @map("display_name")
avatar String? avatar String?
bio String? bio String?
token Token[] token Token[]
isVerified Boolean @default(false) @map("is_verified") isVerified Boolean @default(false) @map("is_verified")
isEmailVerified Boolean @default(false) @map("is_email_verified") isEmailVerified Boolean @default(false) @map("is_email_verified")
isTotpEnabled Boolean @default(false) @map("is_totp_enabled") isTotpEnabled Boolean @default(false) @map("is_totp_enabled")
isDeactivated Boolean @default(false) @map("is_deactivated") isDeactivated Boolean @default(false) @map("is_deactivated")
deactivatedAt DateTime? @map("deactivated_at") deactivatedAt DateTime? @map("deactivated_at")
socialLink SocialLink[] socialLink SocialLink[]
totpSecret String? @map("totp_secret") totpSecret String? @map("totp_secret")
stream Stream? stream Stream?
chatMessages ChatMessage[] createdAt DateTime @default(now()) @map("created_at")
followers Follow[] @relation(name: "followers") updatedAt DateTime @updatedAt @map("updated_at")
followings Follow[] @relation(name: "followings")
notifications Notification[]
notificationSettings NotificationSettings?
telegramId String? @unique @map("telegram_id")
transactions Transaction[]
sponsorshipPlans SponsorshipPlan[]
sponsorshipSubscriptions SponsorshipSubscription[] @relation(name: "sponsorship_subscriptions")
sponsors SponsorshipSubscription[] @relation(name: "sponsors")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users") @@map("users")
} }
model Category {
id String @id @default(uuid())
title String
slug String @unique
description String?
thumbnailUrl String @map("thumbnail_url")
streams Stream[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("categories")
}
model Token { model Token {
id String @id @default(uuid()) id String @id @default(uuid())
token String @unique token String @unique
@ -130,100 +73,10 @@ model Token {
@@map("tokens") @@map("tokens")
} }
model Transaction {
id String @id @default(uuid())
amount Float
currency String
stripeSubscriptionId String? @map("stripe_subscription_id")
status TransactionStatus @default(PENDING)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("transactions")
}
model SponsorshipPlan {
id String @id @default(uuid())
title String
description String?
price Float
stripeProductId String @map("stripe_product_id")
stripePlanId String @map("stripe_plan_id")
channel User? @relation(fields: [channelId], references: [id], onDelete: Cascade)
channelId String? @map("channel_id")
sponsorshipSubscriptions SponsorshipSubscription[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("sponsorship_plans")
}
model SponsorshipSubscription {
id String @id @default(uuid())
expiresAt DateTime @map("expires_at")
plan SponsorshipPlan? @relation(fields: [planId], references: [id], onDelete: Cascade)
planId String? @map("plan_id")
user User? @relation(name: "sponsorship_subscriptions", fields: [userId], references: [id], onDelete: Cascade)
userId String? @map("user_id")
channel User? @relation(name: "sponsors", fields: [channelId], references: [id], onDelete: Cascade)
channelId String? @map("channel_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("sponsorship_subscriptions")
}
model Notification {
id String @id @default(uuid())
text String
type NotificationType
isRead Boolean @default(false) @map("is_read")
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String? @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("notifications")
}
model NotificationSettings {
id String @id @default(uuid())
siteNotifications Boolean @default(true) @map("site_notifications")
telegramNotifications Boolean @default(true) @map("telegram_notifications")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String @unique @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("notification_settings")
}
enum NotificationType {
STREAM_START
NEW_FOLLOWER
NEW_SPONSORSHIP
ENABLE_TWO_FACTOR
VERIFIED_CHANNEL
@@map("notification_types")
}
enum TokenType { enum TokenType {
EMAIL_VERIFY EMAIL_VERIFY
PASSWORD_RESET PASSWORD_RESET
DEACTIVATE_ACCOUNT DEACTIVATE_ACCOUNT
TELEGRAM_AUTH
@@map("token_types") @@map("token_types")
} }
enum TransactionStatus {
PENDING
SUCCESS
FAILED
EXPIRED
@@map("transaction_statuses")
}

View File

@ -1,24 +1,20 @@
import * as path from 'node:path'; import { isDev } from '@/src/shared/util/is-dev.util'
import { ApolloDriverConfig } from '@nestjs/apollo'
import { isDev } from '@/src/shared/util/is-dev.util'; import { ConfigService } from '@nestjs/config'
import { Request, Response } from 'express'
import type { ProcessEnv } from '../../shared/types/env'; import * as path from 'node:path'
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): ApolloDriverConfig {
return { return {
playground: isDev(configService), playground: isDev(configService),
path: configService.getOrThrow('GRAPHQL_PREFIX'), path: configService.getOrThrow('GRAPHQL_PREFIX'),
autoSchemaFile: path.join(process.cwd(), 'src', 'core', 'graphql', 'schema.gql'), autoSchemaFile: path.join(process.cwd(), 'src', 'core', 'graphql', 'schema.gql'),
sortSchema: true, sortSchema: true,
context: ({ req, res }: ContextType) => ({ req, res }), context: ({ req, res }: ContextType) => ({ req, res }),
installSubscriptionHandlers: true, }
};
} }

View File

@ -1,11 +1,10 @@
import type { ProcessEnv } from '../../shared/types/env'; import { TypeLiveKitOptions } from '@/src/module/libs/livekit/type/livekit.type'
import type { TypeLiveKitOptions } from '@/src/module/libs/livekit/type/livekit.type'; import { ConfigService } from '@nestjs/config'
import type { ConfigService } from '@nestjs/config';
export function getLiveKitConfig(configService: ConfigService<ProcessEnv>): TypeLiveKitOptions { export function getLiveKitConfig(configService: ConfigService): 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,8 +1,7 @@
import type { ProcessEnv } from '../../shared/types/env'; import { MailerOptions } from '@nestjs-modules/mailer'
import type { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config'
import type { MailerOptions } from '@nestjs-modules/mailer';
export function getMailConfig(configService: ConfigService<ProcessEnv>): MailerOptions { export function getMailConfig(configService: ConfigService): MailerOptions {
return { return {
transport: { transport: {
host: configService.getOrThrow<string>('MAIL_HOST'), host: configService.getOrThrow<string>('MAIL_HOST'),
@ -16,5 +15,5 @@ export function getMailConfig(configService: ConfigService<ProcessEnv>): MailerO
defaults: { defaults: {
from: `"TeaStream" ${configService.getOrThrow<string>('MAIL_LOGIN')}`, from: `"TeaStream" ${configService.getOrThrow<string>('MAIL_LOGIN')}`,
}, },
}; }
} }

View File

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

View File

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

View File

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

View File

@ -7,37 +7,10 @@ type AuthModel {
user: UserModel user: UserModel
} }
type CategoryModel {
createdAt: DateTime!
description: String
id: ID!
slug: String!
streams: [StreamModel!]!
thumbnailUrl: String!
title: String!
updatedAt: DateTime!
}
input ChangeChatSettingsInput {
isChatEnable: Boolean!
isChatFollowersOnly: Boolean!
isChatPremiumFollowersOnly: Boolean!
}
input ChangeEmailInput { input ChangeEmailInput {
email: String! email: String!
} }
input ChangeNotificationSettingsInput {
siteNotifications: Boolean!
telegramNotifications: Boolean!
}
type ChangeNotificationsSettingsResponse {
notificationSettings: NotificationSettingsModel!
telegramAuthToken: String
}
input ChangePasswordInput { input ChangePasswordInput {
newPassword: String! newPassword: String!
oldPassword: String! oldPassword: String!
@ -54,21 +27,6 @@ input ChangeStreamInfoInput {
title: String! title: String!
} }
type ChatMessageModel {
createdAt: DateTime!
id: ID!
streamId: ID!
text: String!
updatedAt: DateTime!
userId: ID!
}
input CreatePlanInput {
description: String
price: Float!
title: String!
}
input CreateUserInput { input CreateUserInput {
email: String! email: String!
name: String! name: String!
@ -103,16 +61,6 @@ input FilterInput {
take: Float take: Float
} }
type FollowModel {
createdAt: DateTime!
follower: UserModel!
followerId: ID!
following: UserModel!
followingId: ID!
id: ID!
updatedAt: DateTime!
}
input GenerateStreamTokenInput { input GenerateStreamTokenInput {
channelId: String! channelId: String!
userId: String! userId: String!
@ -135,14 +83,8 @@ input LoginInput {
pin: String pin: String
} }
type MakePaymentModel {
url: String!
}
type Mutation { type Mutation {
changeChatSettings(data: ChangeChatSettingsInput!): StreamModel!
changeEmail(data: ChangeEmailInput!): UserModel! changeEmail(data: ChangeEmailInput!): UserModel!
changeNotificationSettigs(data: ChangeNotificationSettingsInput!): ChangeNotificationsSettingsResponse!
changePassword(data: ChangePasswordInput!): UserModel! changePassword(data: ChangePasswordInput!): UserModel!
changeProfileAvatar(avatar: Upload!): Boolean! changeProfileAvatar(avatar: Upload!): Boolean!
changeProfileInfo(data: ChangeProfileInfoInput!): UserModel! changeProfileInfo(data: ChangeProfileInfoInput!): UserModel!
@ -151,26 +93,20 @@ type Mutation {
clearSessionCookie: Boolean! clearSessionCookie: Boolean!
createIngress(ingressType: Float!): Boolean! createIngress(ingressType: Float!): Boolean!
createSocialLink(data: SocialLinkInput!): SocialLinkModel! createSocialLink(data: SocialLinkInput!): SocialLinkModel!
createSponsorshipPlan(data: CreatePlanInput!): PlanModel!
createUser(data: CreateUserInput!): UserModel! createUser(data: CreateUserInput!): UserModel!
deactivateAccount(data: DeactivateAccountInput!): AuthModel! deactivateAccount(data: DeactivateAccountInput!): AuthModel!
disableTotp: Boolean! disableTotp: Boolean!
enableTotp(data: EnableTotpInput!): Boolean! enableTotp(data: EnableTotpInput!): Boolean!
followChannel(channelId: String!): FollowModel!
generateStreamToken(data: GenerateStreamTokenInput!): GenerateTokenModel! generateStreamToken(data: GenerateStreamTokenInput!): GenerateTokenModel!
loginUser(data: LoginInput!): AuthModel! loginUser(data: LoginInput!): AuthModel!
logoutUser: Boolean! logoutUser: Boolean!
makePayment(planId: String!): MakePaymentModel!
removeProfileAvatar: Boolean! removeProfileAvatar: Boolean!
removeSession(id: String!): Boolean! removeSession(id: String!): Boolean!
removeSocialLink(id: String!): Boolean! removeSocialLink(id: String!): Boolean!
removeSponsorshipPlan(planId: String!): PlanModel!
removeStreamThumbnail: Boolean! removeStreamThumbnail: Boolean!
reorderSocialLink(list: [SocialLinkOrderInput!]!): Boolean! reorderSocialLink(list: [SocialLinkOrderInput!]!): Boolean!
resetPassword(data: ResetPasswordInput!): Boolean! resetPassword(data: ResetPasswordInput!): Boolean!
sendChatMessage(data: SendMessageInput!): ChatMessageModel!
setNewPassword(data: NewPasswordInput!): Boolean! setNewPassword(data: NewPasswordInput!): Boolean!
unfollowChannel(channelId: String!): FollowModel!
updateSocialLink(data: SocialLinkInput!, id: String!): SocialLinkModel! updateSocialLink(data: SocialLinkInput!, id: String!): SocialLinkModel!
verifyAccount(data: VerificationInput!): AuthModel! verifyAccount(data: VerificationInput!): AuthModel!
} }
@ -181,70 +117,13 @@ input NewPasswordInput {
token: String! token: String!
} }
type NotificationModel {
createdAt: DateTime!
id: String!
isRead: Boolean!
text: String!
type: NotificationType!
updatedAt: DateTime!
user: UserModel!
userId: String!
}
type NotificationSettingsModel {
createdAt: DateTime!
id: String!
siteNotifications: Boolean!
telegramNotifications: Boolean!
updatedAt: DateTime!
user: UserModel!
userId: String!
}
enum NotificationType {
ENABLE_TWO_FACTOR
NEW_FOLLOWER
NEW_SPONSORSHIP
STREAM_START
VERIFIED_CHANNEL
}
type PlanModel {
channel: UserModel!
channelId: ID!
createdAt: DateTime!
description: String
id: ID!
price: Float!
stripePlanId: ID!
stripeProductId: ID!
title: String!
updatedAt: DateTime!
}
type Query { type Query {
findAllCategories: [CategoryModel!]!
findAllStreams(filters: FilterInput!): [StreamModel!]! findAllStreams(filters: FilterInput!): [StreamModel!]!
findCategoryBySlug(slug: String!): CategoryModel!
findChannelByUsername(name: String!): UserModel!
findChannelFollowersCount(channelId: String!): Float!
findCurrentSession: SessionModel! findCurrentSession: SessionModel!
findMessagesByStream(streamId: String!): [ChatMessageModel!]!
findMyFollowers: [FollowModel!]!
findMyFollowings: [FollowModel!]!
findMySponsors: [SubscriptionModel!]!
findMySponsorshipPlans: [PlanModel!]!
findMyTransactions: [TransactionModel!]!
findNotificationByUser: [NotificationModel!]!
findProfile: UserModel! findProfile: UserModel!
findRandomCategories: [CategoryModel!]!
findRandomStreams: [StreamModel!]! findRandomStreams: [StreamModel!]!
findRecommendedChannels: [UserModel!]!
findSessionsByUser: [SessionModel!]! findSessionsByUser: [SessionModel!]!
findSocialLinks: [SocialLinkModel!]! findSocialLinks: [SocialLinkModel!]!
findSponsorsByChannel(channelId: String!): [SubscriptionModel!]!
findUnreadNotificationsCount: Float!
generateTotpSecret: TotpModel! generateTotpSecret: TotpModel!
} }
@ -252,11 +131,6 @@ input ResetPasswordInput {
email: String! email: String!
} }
input SendMessageInput {
streamId: String!
text: String!
}
type SessionMetadataModel { type SessionMetadataModel {
device: DeviceModel! device: DeviceModel!
ip: String! ip: String!
@ -291,15 +165,9 @@ input SocialLinkOrderInput {
} }
type StreamModel { type StreamModel {
category: CategoryModel!
categoryId: ID!
chatMessages: [ChatMessageModel!]!
createdAt: DateTime! createdAt: DateTime!
id: ID! id: ID!
ingressId: String ingressId: String
isChatEnable: Boolean!
isChatFollowersOnly: Boolean!
isChatPremiumFollowersOnly: Boolean!
isLive: Boolean! isLive: Boolean!
key: String key: String
serverUrl: String serverUrl: String
@ -310,47 +178,11 @@ type StreamModel {
userId: ID! userId: ID!
} }
type Subscription {
chatMessageAdded(streamId: String!): ChatMessageModel!
}
type SubscriptionModel {
channel: UserModel!
channelId: String!
createdAt: DateTime!
expiresAt: DateTime!
id: ID!
plan: PlanModel!
planId: String!
updatedAt: DateTime!
user: UserModel!
userId: String!
}
type TotpModel { type TotpModel {
qrcodeUrl: String! qrcodeUrl: String!
secret: String! secret: String!
} }
type TransactionModel {
amount: Float!
createdAt: DateTime!
currency: String!
id: ID!
status: TransactionStatus!
stripeSubscriptionId: ID!
updatedAt: DateTime!
user: UserModel!
userId: ID!
}
enum TransactionStatus {
EXPIRED
FAILED
PENDING
SUCCESS
}
"""The `Upload` scalar type represents a file upload.""" """The `Upload` scalar type represents a file upload."""
scalar Upload scalar Upload
@ -361,20 +193,15 @@ type UserModel {
deactivatedAt: DateTime deactivatedAt: DateTime
displayName: String! displayName: String!
email: String! email: String!
followers: [FollowModel!]!
followings: [FollowModel!]!
id: ID! id: ID!
isDeactivated: Boolean! isDeactivated: Boolean!
isEmailVerified: Boolean! isEmailVerified: Boolean!
isTotpEnabled: Boolean! isTotpEnabled: Boolean!
isVerified: Boolean! isVerified: Boolean!
name: String! name: String!
notification: [NotificationModel!]!
notificationSettings: NotificationSettingsModel!
password: String! password: String!
socialLink: [SocialLinkModel!]! socialLink: [SocialLinkModel!]!
stream: StreamModel! stream: StreamModel!
telegramId: String
totpSecret: String totpSecret: String
updatedAt: DateTime! updatedAt: DateTime!
} }

View File

@ -1,128 +0,0 @@
export const CATEGORIES = [
{
title: 'Minecraft',
slug: 'minecraft',
description:
'Погрузитесь в бескрайний мир творчества и приключений в Minecraft! Эта категория посвящена самой популярной песочнице, где вы можете строить, исследовать и выживать в уникальных мирах. Следите за стримами, вдохновляйтесь креативными постройками, участвуйте в совместных проектах и делитесь своими достижениями. Присоединяйтесь к сообществу, где фантазия не знает границ, и откройте для себя безумные возможности, которые предлагает Minecraft!',
thumbnailUrl: '/categories/minecraft.webp',
},
{
title: 'Grand Theft Auto V',
slug: 'grand-theft-auto-v',
description:
'Добро пожаловать в криминальный мир Лос-Сантоса! Эта категория посвящена одной из самых популярных игр в открытом мире, где вы можете свободно исследовать, выполнять миссии и наслаждаться безумными приключениями. Следите за стримами, участвуйте в захватывающих гонках и ограблениях, обсуждайте стратегии и делитесь своими уникальными моментами из игры. Присоединяйтесь к сообществу, где каждый найдет себе занятие — от уличных гонок до создания собственных историй в рамках GTA V!',
thumbnailUrl: '/categories/grand-theft-auto-v.webp',
},
{
title: 'Rust',
slug: 'rust',
description:
'Добро пожаловать в суровый и захватывающий мир Rust! Эта категория посвящена одной из самых популярных игр на выживание, где стратегии и навыки играют ключевую роль. Следите за стримами, наблюдайте за построением баз, захватывающими боями и захватами ресурсов. Делитесь своими тактиками, обсуждайте обновления и находите единомышленников, готовых бросить вызов этому жестокому миру. Присоединяйтесь к нам, чтобы научиться выживать и процветать в условиях жестокой конкуренции!',
thumbnailUrl: '/categories/rust.webp',
},
{
title: 'Cyberpunk 2077',
slug: 'cyberpunk-2077',
description:
'Добро пожаловать в мрачный и захватывающий мир Night City! Эта категория посвящена Cyberpunk 2077, где вы сможете погрузиться в атмосферу будущего, полного технологий, приключений и сложных выборов. Следите за стримами, обсуждайте квесты и сюжетные линии, а также делитесь своими тактиками и опытом в этой уникальной RPG. Присоединяйтесь к сообществу, где каждый может исследовать мир киберпанка и раскрывать его тайны, становясь частью этой невероятной истории!',
thumbnailUrl: '/categories/cyberpunk-2077.webp',
},
{
title: 'Общение',
slug: 'just-chatting',
description:
'Погружайтесь в мир живых дискуссий и общения! Эта категория предлагает вам уникальную возможность обмениваться мнениями, участвовать в увлекательных беседах и находить единомышленников. Делитесь своими мыслями, задавайте вопросы и получайте ответы в реальном времени. Присоединяйтесь к стримам, где каждый может высказать свое мнение и стать частью дружелюбного сообщества!',
thumbnailUrl: '/categories/just-chatting.webp',
},
{
title: 'Red Dead Redemption 2',
slug: 'red-dead-redemption-2',
description:
'Погрузитесь в атмосферу Дикого Запада с одним из самых захватывающих приключений в истории игр! Эта категория посвящена Red Dead Redemption 2, где вы сможете следить за эпическими историями, исследовать живописные ландшафты и участвовать в увлекательных стримах. Обсуждайте стратегии, делитесь моментами из игры и находите единомышленников, разделяющих вашу страсть к этому культовому произведению. Присоединяйтесь к нам и откройте для себя мир, полный приключений, свободы и духа ковбойской жизни!',
thumbnailUrl: '/categories/red-dead-redemption-2.webp',
},
{
title: 'Обучение',
slug: 'learning',
description:
'Расширьте свои знания и навыки в этой категории, посвященной обучению! Здесь вы найдете стримы и курсы по разнообразным темам — от искусства и музыки до языков и личной продуктивности. Участвуйте в интерактивных уроках, задавайте вопросы и получайте советы от опытных наставников. Обучение стало еще более доступным и увлекательным! Присоединяйтесь к сообществу стремящихся к самосовершенствованию и откройте новые горизонты для своего развития!',
thumbnailUrl: '/categories/learning.webp',
},
{
title: 'Fortnite',
slug: 'fortnite',
description:
'Погрузитесь в яркий и динамичный мир Fortnite! Эта категория посвящена культовой игре, где строительство, стратегия и сражения с противниками сочетаются в одном захватывающем процессе. Следите за стримами, наблюдайте за матчами с участием лучших игроков и учитесь у них уникальным тактикам. Делитесь своими достижениями, обсуждайте новые обновления и находите единомышленников для совместной игры. Присоединяйтесь к нашему сообществу и станьте частью эпических сражений на острове Fortnite!',
thumbnailUrl: '/categories/fortnite.webp',
},
{
title: 'Counter-Strike',
slug: 'counter-strike',
description:
'Добро пожаловать в мир напряженных сражений и стратегического геймплея! Эта категория посвящена популярной игре Counter-Strike, где вы сможете наблюдать захватывающие матчи, учиться у лучших игроков и делиться своими тактиками. Присоединяйтесь к стримам, обсуждайте последние обновления и соревнуйтесь с другими игроками. Будьте в курсе самых ярких моментов и станьте частью увлекательной киберспортивной культуры!',
thumbnailUrl: '/categories/counter-strike.webp',
},
{
title: 'Программирование',
slug: 'programming',
description:
'Откройте для себя увлекательный мир кода и технологий! Эта категория посвящена программированию, где вы можете учиться, делиться опытом и вдохновляться новыми идеями. Следите за стримами, посвященными различным языкам программирования, инструментам и разработке проектов. Участвуйте в обсуждениях, задавайте вопросы и получайте советы от опытных разработчиков. Присоединяйтесь к сообществу, где код становится искусством, и каждая строчка — шаг к вашему следующему достижению!',
thumbnailUrl: '/categories/programming.webp',
},
{
title: 'Dota 2',
slug: 'dota-2',
description:
'Погрузитесь в мир стратегических сражений и командной игры с Dota 2! Эта категория предназначена для всех поклонников культовой MOBA, где вы сможете следить за увлекательными матчами, изучать стратегии и делиться опытом с другими игроками. Присоединяйтесь к стримам, обсуждайте последние обновления и тактики, а также находите единомышленников для совместной игры. Откройте для себя захватывающий мир героев, навыков и командных сражений, где каждое решение может изменить ход битвы!',
thumbnailUrl: '/categories/dota-2.webp',
},
{
title: 'Brawl Stars',
slug: 'brawl-stars',
description:
'Погрузитесь в динамичные сражения и быструю тактику в мире Brawl Stars! Эта категория создана для всех фанатов увлекательных битв 3 на 3 и эпических боев в режиме "Королевская битва". Здесь вы сможете смотреть лучшие стримы, обсуждать стратегии и разрабатывать новые тактики для своих бойцов. Присоединяйтесь к сообществу игроков, следите за последними обновлениями, открывайте новых персонажей и прокачивайте их способности. Каждый бой — это новый вызов, где командная работа и мастерство определяют победителя!',
thumbnailUrl: '/categories/brawl-stars.webp',
},
{
title: 'Clash Royale',
slug: 'clash-royale',
description:
'Погрузитесь в захватывающий мир Clash Royale, где стратегия и мгновенные решения определяют исход сражений! Эта категория предназначена для всех поклонников карточных баталий, где вы сможете наблюдать за увлекательными турнирами, изучать успешные колоды и делиться опытом с другими игроками. Присоединяйтесь к стримам, обсуждайте последние обновления и тактики, открывайте новые карты и улучшайте свои любимые карты до максимума. Исследуйте бесконечные возможности для создания уникальных стратегий и сражайтесь с противниками, чтобы стать мастером арены!',
thumbnailUrl: '/categories/clash-royale.webp',
},
{
title: 'Музыка',
slug: 'music',
description:
'Погрузитесь в мир музыки, где каждый аккорд и ритм наполняют атмосферу вдохновением и эмоциями! Эта категория предназначена для всех меломанов и музыкантов, где вы сможете наслаждаться живыми выступлениями, открывать новые жанры и делиться своим опытом с единомышленниками. Присоединяйтесь к стримам, обсуждайте последние релизы и события музыкальной индустрии, а также делитесь своими творениями и играми. Откройте для себя удивительный мир звуков, где каждое выступление — это уникальная история, которая ждет своего слушателя!',
thumbnailUrl: '/categories/music.webp',
},
{
title: 'Call of Duty',
slug: 'call-of-duty',
description:
'Погрузитесь в мир захватывающих военных операций и командных сражений с Call of Duty! Эта категория создана для всех фанатов легендарной франшизы, где вы сможете наблюдать за напряжёнными матчами, изучать тактики и делиться своим опытом с другими игроками. Присоединяйтесь к стримам, обсуждайте последние обновления и новшества, а также находите союзников для совместной игры в различных режимах. Исследуйте динамичные карты, осваивайте уникальное оружие и тактики, где каждое ваше решение может стать ключом к победе в этом эпическом боевом мире!',
thumbnailUrl: '/categories/call-of-duty.webp',
},
{
title: 'World of Tanks',
slug: 'world-of-tanks',
description:
'Погрузитесь в мир бронированных сражений и стратегической тактики с World of Tanks! Эта категория предназначена для всех поклонников легендарной танковой битвы, где вы сможете наблюдать за захватывающими матчами, изучать особенности различных танков и делиться опытом с единомышленниками. Присоединяйтесь к стримам, обсуждайте последние обновления и балансировку, а также находите товарищей по команде для совместной игры в различных режимах. Откройте для себя увлекательные сражения на разнообразных картах, прокачивайте свою армию и развивайте стратегические навыки, где каждое ваше решение может изменить ход боя!',
thumbnailUrl: '/categories/world-of-tanks.webp',
},
{
title: 'League of Legends',
slug: 'league-of-legends',
description:
'Погрузитесь в мир эпических сражений и стратегического геймплея с League of Legends! Эта категория создана для всех поклонников культовой MOBA, где вы сможете следить за захватывающими матчами, изучать стратегии и делиться опытом с другими игроками. Присоединяйтесь к стримам, обсуждайте последние обновления и патчи, а также находите единомышленников для совместной игры. Откройте для себя множество уникальных чемпионов, исследуйте их способности и развивайте свои навыки, чтобы стать мастером поля боя. Каждая игра — это новый вызов, где командная работа и умение адаптироваться к ситуации определяют победу!',
thumbnailUrl: '/categories/league-of-legends.webp',
},
{
title: 'Спорт',
slug: 'sport',
description:
'Погрузитесь в захватывающий мир спорта, где страсть, соревнование и дух командной игры сливаются воедино! Эта категория предназначена для всех поклонников активного образа жизни и спортивных событий, где вы сможете следить за увлекательными матчами, обсуждать стратегии команд и делиться своим опытом с другими фанатами. Присоединяйтесь к стримам, следите за последними новостями и результатами, а также находите единомышленников для совместных тренировок и обсуждений. Откройте для себя удивительный мир спорта, где каждое соревнование — это возможность проявить свои силы и стремление к победе!',
thumbnailUrl: '/categories/sport.webp',
},
];

View File

@ -1,218 +0,0 @@
export const STREAMS = {
minecraft: [
'Проходим Minecraft: выживание с нуля!',
'Строим эпические постройки в Minecraft',
'Тайны Майнкрафт: приключения начинаются!',
'Создаем ферму в Minecraft',
'Убиваем Дракона Края!',
'Исследуем подземелья Minecraft',
'Магические зелья и чары в Minecraft',
'Автоматизируем добычу ресурсов в Minecraft',
'Рыбалка и охота в Minecraft: собираем лут',
'Крафтим уникальные предметы в Minecraft',
],
'grand-theft-auto-v': [
'Проходим GTA 5 на 100%',
'Гонки и трюки в GTA Online',
'Сюжетка GTA 5: снова в деле!',
'Ограбления в GTA V: делаем дело',
'Веселимся в Лос-Сантосе',
'Открываем секретные локации в GTA 5',
'Играем в миссии "Контракт" в GTA Online',
'Миссии против полиции в GTA 5',
'Обзор новых машин и тюнинга в GTA Online',
'Создаем свой бизнес в Лос-Сантосе',
],
rust: [
'Рейдим базы в Rust',
'Выживание в пустоши: Rust',
'Новые механики Rust: тестируем!',
'От новичка до про в Rust',
'ПвП битвы в мире Rust',
'Готовим рейды в Rust: тактики и стратегии',
'Ловушки и оборонительные сооружения в Rust',
'Ищем редкие ресурсы в Rust',
'Исследуем огромные карты Rust',
'Выживаем в жестоких условиях Rust',
],
'cyberpunk-2077': [
'Исследуем Найт-Сити в Cyberpunk 2077',
'Проходим сюжетные квесты Cyberpunk',
'Моддинг и кастомизация в Cyberpunk',
'Оружие и хаки: боевые тактики Cyberpunk',
'Агентские миссии в мире будущего',
'Киберимпланты: улучшаем персонажа',
'Исследуем мир корпоративных заговоров',
'Миссии с быстрыми сетевыми атаками',
'Делаем побочные квесты Cyberpunk 2077',
'Обзор обновлений и дополнений к игре',
],
'just-chatting': [
'Общаемся с подписчиками!',
'Ответы на вопросы: задавайте любые!',
'Чилл-стрим: болтаем обо всем',
'Ваши истории, наши обсуждения',
'Уютные разговоры и новости',
'Обсуждаем последние фильмы и сериалы',
'Музыкальные рекомендации от зрителей',
'Ваши вопросы, наши ответы!',
'Рассуждаем о будущем технологий',
'Тренды в игровой индустрии: обсуждаем',
],
'red-dead-redemption-2': [
'Приключения в мире Red Dead Redemption 2',
'Проходим сюжет RDR2: Дикий Запад зовет',
'Охота и выживание в RDR2',
'Исследуем дикие земли RDR2',
'Лучшие миссии в Red Dead Redemption 2',
'Рейдим бандитские укрытия в RDR2',
'Путешествуем по миру Дикого Запада',
'Торговля и добыча ресурсов в RDR2',
'Становимся охотниками за головами в RDR2',
'Обзор легендарных зверей и трофеев',
],
learning: [
'Изучаем основы фотографии',
'Как стать мастером ораторского искусства',
'Погружаемся в искусство рисования',
'Как правильно учить иностранные языки',
'Тайм-менеджмент для продуктивной жизни',
'Разбираемся в психологии общения',
'Основы игры на гитаре для начинающих',
'Техники быстрого чтения и запоминания',
'Изучаем основы кулинарии: готовим вместе',
'Учим основы финансовой грамотности',
],
fortnite: [
'Стрим по Fortnite: королевская битва!',
'Секреты строительства в Fortnite',
'Лучшие тактики для победы в Fortnite',
'Сезонные события в Fortnite: участвуем!',
'Играем в дуо и сквады в Fortnite',
'Открываем новые скины в Fortnite',
'Как получить победу в Fortnite',
'Челленджи Fortnite: выполняем!',
'Обзор нового боевого пропуска Fortnite',
'Тренируемся в строительстве и стрельбе',
],
'counter-strike': [
'Герои Counter-Strike: стратегия и тактика',
'Лучшие моменты в CS:GO',
'Участвуем в турнирах по CS:GO',
'Обзор карт в Counter-Strike',
'ПвП бои в Counter-Strike',
'Оружие и амуниция в CS: выбираем лучшее',
'Тактики для победы в матчах CS:GO',
'Соревновательные игры: учимся побеждать',
'Киберспортивные команды: следим за матчами',
'Участвуем в тренировочных матчах CS:GO',
],
programming: [
'Программируем на JavaScript: от простого к сложному',
'Разработка игр на Python: шаг за шагом',
'Создание веб-приложений с React: практическое руководство',
'Учимся разрабатывать мобильные приложения',
'Разбираем алгоритмы и структуры данных',
'Работа с API в современных приложениях',
'Тестирование кода: что нужно знать',
'Автоматизация процессов разработки',
'Секреты эффективного дебага',
'Обзор современных фреймворков для веба',
],
'dota-2': [
'Проходим Dota 2: секреты победы',
'Обзор героев Dota 2: выбираем свою стратегию',
'Готовимся к турниру по Dota 2',
'Анализ лучших матчей Dota 2',
'Советы по улучшению игры в Dota 2',
'Герои поддержки: как правильно играть?',
'Секреты макроигры в Dota 2',
'Контроль карты и роуминг в Dota 2',
'Играем за керри: выигрываем матчи',
'Секреты победы в командных боях',
],
'brawl-stars': [
'Стримим Brawl Stars: лучшие бравлеры!',
'Советы по игре в Brawl Stars',
'Участвуем в событиях Brawl Stars',
'Новые режимы в Brawl Stars: тестируем!',
'Обсуждаем стратегии для Brawl Stars',
'Как выбрать лучшего бравлера для каждого режима',
'Играем дуо и командами в Brawl Stars',
'Проходим сезонные события в Brawl Stars',
'Открываем новые скины и награды',
'Секреты прокачки бравлеров',
],
'clash-royale': [
'Битвы в Clash Royale: стратегии и тактики',
'Проходим турниры Clash Royale',
'Обзор карт в Clash Royale',
'Достигаем новых высот в Clash Royale',
'Секреты успешной игры в Clash Royale',
'Создаем колоды для побед в Clash Royale',
'Обзор новых карт и механик Clash Royale',
'Лучшие моменты турниров по Clash Royale',
'Стримим клановые битвы в Clash Royale',
'Побеждаем в дуэлях и соревнованиях',
],
music: [
'Музыкальные новинки: обсуждаем хиты!',
'Слушаем и обсуждаем любимые альбомы',
'Обсуждаем музыкальные жанры: что слушать?',
'Создаем музыку вместе!',
'Музыкальные челленджи: участвуйте!',
'Обзор музыкальных инструментов: выбираем свой',
'Изучаем музыкальные тренды 2024 года',
'Музыкальные реакции: слушаем вместе',
'Чилл-аут стрим: слушаем расслабляющую музыку',
'Интерактивный плейлист: выбирайте треки',
],
'call-of-duty': [
'Готовимся к битве в Call of Duty',
'Сюжетные миссии Call of Duty: проходим вместе',
'Лучшие моменты из Call of Duty',
'Обзор новых карт в Call of Duty',
'Мультиплеер в Call of Duty: советы и тактики',
'Создаем кланы и участвуем в битвах в Call of Duty',
'Настраиваем оружие для максимальной эффективности',
'Секреты игры в Call of Duty: как быть лидером',
'Проходим режим зомби в Call of Duty',
'Обзор нового боевого пропуска Call of Duty',
],
'world-of-tanks': [
'Битвы в World of Tanks: тактики и стратегии',
'Лучшие танки в World of Tanks',
'Проходим квесты в World of Tanks',
'Обзор обновлений в World of Tanks',
'Стримим бои в World of Tanks',
'ПвП сражения: учимся побеждать',
'Создаем свою команду для турниров WoT',
'Настройки танков для лучшей игры',
'Открываем редкие танки и достижения',
'Секреты захвата ключевых точек в боях',
],
'league-of-legends': [
'Герои League of Legends: изучаем стратегии',
'Обзор новых обновлений в LoL',
'Соревнуемся в League of Legends',
'Советы по игре в League of Legends',
'Турниры и чемпионаты League of Legends',
'Играем за поддержку: как быть лучшим?',
'Секреты макроигры в League of Legends',
'Лучшая тактика для командной игры в LoL',
'Проходим ранговые игры в League of Legends',
'Обзор новых скинов и событий в League of Legends',
],
sport: [
'Обсуждаем спортивные события: последние новости!',
'Спортивные тренировки: секреты успеха',
'Лучшие моменты в мире спорта',
'Спортивные челленджи: участвуйте!',
'Обзор спортивных игр и событий',
'Поддерживаем себя в форме: советы и лайфхаки',
'Секреты успешных спортсменов: что важно знать?',
'Обсуждаем тактики и стратегии для командных видов спорта',
'Реакции на последние спортивные трансляции',
'Участвуем в спортивных турнирах и соревнованиях',
],
};

View File

@ -1,104 +0,0 @@
export const USERNAMES = [
'teacoder',
'stintik',
'alex',
'bella',
'carter',
'dylan',
'ethan',
'fiona',
'grace',
'henry',
'isabella',
'jackson',
'kate',
'liam',
'mia',
'noah',
'oliver',
'paige',
'quinn',
'ryan',
'sophia',
'thomas',
'ursula',
'victor',
'willow',
'xander',
'yara',
'zoe',
'adrian',
'bella',
'caroline',
'daniel',
'elena',
'felix',
'gabriel',
'hannah',
'ian',
'julia',
'kevin',
'lily',
'michael',
'nina',
'oscar',
'peter',
'quincy',
'rachel',
'samuel',
'taylor',
'ulysses',
'vanessa',
'wyatt',
'xenia',
'yuri',
'zoey',
'amelia',
'benjamin',
'charlotte',
'david',
'emma',
'frederick',
'georgia',
'harper',
'isaac',
'joseph',
'katie',
'lucas',
'madison',
'nathan',
'olivia',
'patrick',
'quincy',
'rebecca',
'sebastian',
'tiffany',
'ulyana',
'victoria',
'wesley',
'xavier',
'yasmin',
'zoella',
'aaron',
'brianna',
'claire',
'diego',
'ella',
'frank',
'george',
'holly',
'ivan',
'jessica',
'kyle',
'logan',
'mason',
'nicolas',
'olga',
'paul',
'quinn',
'ryder',
'scarlett',
'tristan',
'ulysses',
'violet',
];

View File

@ -1,6 +1,5 @@
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,110 +0,0 @@
import { BadRequestException, Logger } from '@nestjs/common';
import { hash } from 'argon2';
import { Prisma, PrismaClient } from '@/prisma/generated';
import { CATEGORIES } from './data/categories.data';
import { STREAMS } from './data/streams.data';
import { USERNAMES } from './data/users.data';
const prisma = new PrismaClient({
transactionOptions: {
maxWait: 5000,
timeout: 10000,
isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
},
});
async function main() {
try {
Logger.log('Начало заполнения базы данных');
await prisma.$transaction([
prisma.user.deleteMany({}),
prisma.socialLink.deleteMany({}),
prisma.stream.deleteMany({}),
prisma.category.deleteMany({}),
]);
await prisma.category.createMany({ data: CATEGORIES });
Logger.log('Категории успешно созданы');
const categories = await prisma.category.findMany();
const categoriesBySlug = Object.fromEntries(
categories.map((category) => [category.slug, category]),
);
await prisma.$transaction(async (tx) => {
for (const name of USERNAMES) {
const randomCategory = categoriesBySlug[
Object.keys(categoriesBySlug)[
Math.floor(Math.random() * Object.keys(categoriesBySlug).length)
]
];
const userExists = await tx.user.findUnique({ where: { name } });
if (!userExists) {
const createdUser = await tx.user.create({
data: {
email: `${name}@teastream.ru`,
password: await hash('12345678'),
name,
displayName: name,
avatar: `/channels/${name}.webp`,
isEmailVerified: true,
socialLink: {
createMany: {
data: [
{
title: 'Telegram',
url: `https://t.me/${name}`,
position: 1,
},
{
title: 'YouTube',
url: `https://youtube.com/@${name}`,
position: 2,
},
],
},
},
// notificationSettings: {
// create: {},
// },
},
});
const randomTitles = STREAMS[randomCategory.slug] as string;
const randomTitle = randomTitles[Math.floor(Math.random() * randomTitles.length)];
await tx.stream.create({
data: {
title: randomTitle,
thumbnailUrl: `/streams/${createdUser.name}.webp`,
user: {
connect: {
id: createdUser.id,
},
},
category: {
connect: {
id: randomCategory.id,
},
},
},
});
Logger.log(`Пользователь "${createdUser.name}" и его стрим успешно созданы`);
}
}
});
} catch (e) {
Logger.error(e);
throw new BadRequestException('Ошибка при заполнении базы данных');
} finally {
Logger.log('Закрытие соединения с базой данных');
await prisma.$disconnect();
Logger.log('Соединение с базой данныз успешно закрыто');
}
}
void main();

View File

@ -1,14 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { PrismaClient } from '@/prisma/generated'
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,6 +1,5 @@
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,14 +1,12 @@
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';
@Injectable() @Injectable()
export class RedisService extends Redis { export class RedisService extends Redis {
constructor( constructor(
private readonly configService: ConfigService<ProcessEnv>, private readonly configService: ConfigService,
) { ) {
super(configService.getOrThrow('REDIS_URI')); super(configService.getOrThrow('REDIS_URI'))
} }
} }

View File

@ -1,37 +1,36 @@
import { ValidationPipe } from '@nestjs/common'; import { RedisService } from '@/src/core/redis/redis.service'
import { ConfigService } from '@nestjs/config'; import { ms, StringValue } from '@/src/shared/util/ms.util'
import { NestFactory } from '@nestjs/core'; import { parseBoolean } from '@/src/shared/util/parse-boolean.util'
import RedisStore from 'connect-redis'; import { ValidationPipe } from '@nestjs/common'
import * as cookieParser from 'cookie-parser'; import { ConfigService } from '@nestjs/config'
import * as session from 'express-session'; import { NestFactory } from '@nestjs/core'
import * as graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js'; import RedisStore from 'connect-redis'
import * as cookieParser from 'cookie-parser'
import { CoreModule } from '@/src/core/core.module'; import { CoreModule } from '@/src/core/core.module'
import { RedisService } from '@/src/core/redis/redis.service'; import * as session from 'express-session'
import { ms } from '@/src/shared/util/ms.util'; import * as graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.js'
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)
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<string>('COOKIE_SECRET')))
app.use(config.getOrThrow('GRAPHQL_PREFIX'), graphqlUploadExpress()); app.use(config.getOrThrow<string>('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<string>('SESSION_SECRET'),
name: config.getOrThrow('SESSION_NAME'), name: config.getOrThrow<string>('SESSION_NAME'),
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
domain: config.getOrThrow('SESSION_DOMAIN'), domain: config.getOrThrow<string>('SESSION_DOMAIN'),
maxAge: ms(config.getOrThrow('SESSION_MAX_AGE')), maxAge: ms(config.getOrThrow<StringValue>('SESSION_MAX_AGE')),
httpOnly: parseBoolean(config.getOrThrow('SESSION_HTTP_ONLY')), httpOnly: parseBoolean(config.getOrThrow('SESSION_HTTP_ONLY')),
secure: parseBoolean(config.getOrThrow('SESSION_SECURE')), secure: parseBoolean(config.getOrThrow('SESSION_SECURE')),
sameSite: 'lax', sameSite: 'lax',
@ -40,14 +39,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<string>('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,9 +1,7 @@
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 { AccountResolver } from './account.resolver'; import { AccountService } from './account.service'
import { AccountService } from './account.service'; import { AccountResolver } from './account.resolver'
@Module({ @Module({
providers: [AccountResolver, AccountService, VerificationService], providers: [AccountResolver, AccountService, VerificationService],

View File

@ -1,16 +1,12 @@
import { import { ChangeEmailInput } from '@/src/module/auth/account/inputs/change-email.input'
Args, Mutation, Query, Resolver, import { ChangePasswordInput } from '@/src/module/auth/account/inputs/change-password.input'
} from '@nestjs/graphql'; import { Authorization } from '@/src/shared/decorators/auth.decorator'
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
import { ChangeEmailInput } from '@/src/module/auth/account/inputs/change-email.input'; import { User } from '@prisma/generated'
import { ChangePasswordInput } from '@/src/module/auth/account/inputs/change-password.input'; import { CreateUserInput } from './inputs/create-user.input'
import { Authorization } from '@/src/shared/decorators/auth.decorator'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'
import { Authorized } from '@/src/shared/decorators/authorized.decorator'; import { AccountService } from './account.service'
import { User } from '@prisma/generated'; import { UserModel } from './models/user.model'
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 {
@ -18,13 +14,13 @@ export class AccountResolver {
@Query(() => UserModel, { name: 'findProfile' }) @Query(() => UserModel, { name: 'findProfile' })
@Authorization() @Authorization()
public async me(@Authorized('id') id: UserModel['id']) { public 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()
@ -33,7 +29,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()
@ -42,6 +38,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,12 +1,11 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; import { User } from '@/prisma/generated'
import { hash, verify } from 'argon2'; import { PrismaService } from '@/src/core/prisma/prisma.service'
import { ChangeEmailInput } from '@/src/module/auth/account/inputs/change-email.input'
import { User } from '@/prisma/generated'; import { ChangePasswordInput } from '@/src/module/auth/account/inputs/change-password.input'
import { PrismaService } from '@/src/core/prisma/prisma.service'; import { CreateUserInput } from '@/src/module/auth/account/inputs/create-user.input'
import { ChangeEmailInput } from '@/src/module/auth/account/inputs/change-email.input'; import { VerificationService } from '@/src/module/auth/verification/verification.service'
import { ChangePasswordInput } from '@/src/module/auth/account/inputs/change-password.input'; import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'
import { CreateUserInput } from '@/src/module/auth/account/inputs/create-user.input'; import { hash, verify } from 'argon2'
import { VerificationService } from '@/src/module/auth/verification/verification.service';
@Injectable() @Injectable()
export class AccountService { export class AccountService {
@ -21,29 +20,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({
@ -58,15 +57,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: {
@ -75,15 +74,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({
@ -93,6 +92,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,7 +1,5 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql'
import { import { IsEmail, IsNotEmpty, IsString, Matches, MinLength } from 'class-validator'
IsEmail, IsNotEmpty, IsString, Matches, MinLength,
} from 'class-validator';
@InputType() @InputType()
export class CreateUserInput { export class CreateUserInput {
@ -9,17 +7,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,12 +1,11 @@
import { Field, ObjectType } from '@nestjs/graphql'; import { UserModel } from '@/src/module/auth/account/models/user.model'
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,77 +1,58 @@
import { Field, ID, ObjectType } from '@nestjs/graphql'; import { SocialLinkModel } from '@/src/module/auth/profile/inputs/models/social-link.model'
import { StreamModel } from '@/src/module/stream/models/stream.model'
import { FollowModel } from '@/src/module/follow'; import { Field, ID, ObjectType } from '@nestjs/graphql'
import { NotificationModel, NotificationSettingsModel } from '@/src/module/notification'; import { User } from '@prisma/generated'
import { StreamModel } from '@/src/module/stream/models/stream.model';
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])
followers: FollowModel[];
@Field(() => [FollowModel])
followings: FollowModel[];
@Field(() => String, { nullable: true })
telegramId: string;
@Field(() => [NotificationModel])
notification: NotificationModel[];
@Field(() => NotificationSettingsModel)
notificationSettings: NotificationSettingsModel;
@Field(() => Date) @Field(() => Date)
createdAt: Date; createdAt: Date
@Field(() => Date) @Field(() => Date)
updatedAt: Date; updatedAt: Date
} }

View File

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

View File

@ -1,16 +1,12 @@
import { import { AuthModel } from '@/src/module/auth/account/models/auth.model'
Args, Context, Mutation, Resolver, import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input'
} from '@nestjs/graphql'; import { Authorization } from '@/src/shared/decorators/auth.decorator'
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
import { AuthModel } from '@/src/module/auth/account/models/auth.model'; import { UserAgent } from '@/src/shared/decorators/user-agent.decorator'
import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input'; import { GqlContext } from '@/src/shared/types/gql-context.types'
import { Authorization } from '@/src/shared/decorators/auth.decorator'; import { Args, Context, Mutation, Resolver } from '@nestjs/graphql'
import { Authorized } from '@/src/shared/decorators/authorized.decorator'; import { User } from '@prisma/generated'
import { UserAgent } from '@/src/shared/decorators/user-agent.decorator'; import { DeactivateService } from './deactivate.service'
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 {
@ -24,6 +20,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,52 +1,81 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '@/src/core/prisma/prisma.service'
import { ConfigService } from '@nestjs/config'; import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input'
import { verify } from 'argon2'; import { MailService } from '@/src/module/libs/mail/mail.service'
import { SessionInfo } from '@/src/shared/types/session-metadata.types'
import { PrismaService } from '@/src/core/prisma/prisma.service'; import { generateToken } from '@/src/shared/util/generate-token.util'
import { DeactivateAccountInput } from '@/src/module/auth/deactivate/inputs/deactivate-account.input'; import { getSessionMetadata } from '@/src/shared/util/session-metadata.util'
import { MailService } from '@/src/module/libs/mail/mail.service'; import { destroySession } from '@/src/shared/util/session.util'
import { TelegramService } from '@/src/module/libs/telegram/telegram.service'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import { generateToken } from '@/src/shared/util/generate-token.util'; import { ConfigService } from '@nestjs/config'
import { getSessionMetadata } from '@/src/shared/util/session-metadata.util'; import { TokenType, User } from '@prisma/generated'
import { destroySession } from '@/src/shared/util/session.util'; import { verify } from 'argon2'
import { TokenType, User } from '@prisma/generated'; import { Request } from 'express'
import { ProcessEnv } from '../../../shared/types/env';
import type { Request } from 'express';
@Injectable() @Injectable()
export class DeactivateService { export class DeactivateService {
constructor( constructor(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly configService: ConfigService<ProcessEnv>, private readonly configService: ConfigService,
private readonly mailService: MailService, private readonly mailService: MailService,
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) {
@ -55,48 +84,11 @@ 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) { return true
await this.telegramService.sendDeactivateToken(deactivateToken.user.telegramId, deactivateToken.token, metadata);
}
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,7 +1,5 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql'
import { import { IsEmail, IsNotEmpty, IsOptional, IsString, Length, MinLength } from 'class-validator'
IsEmail, IsNotEmpty, IsOptional, IsString, Length, MinLength,
} from 'class-validator';
@InputType() @InputType()
export class DeactivateAccountInput { export class DeactivateAccountInput {
@ -9,18 +7,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

@ -1,7 +0,0 @@
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,11 +1,8 @@
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 {
@ -13,17 +10,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,8 +3,9 @@ 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,7 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PasswordRecoveryResolver } from './password-recovery.resolver';
import { PasswordRecoveryService } from './password-recovery.service'; import { PasswordRecoveryService } from './password-recovery.service';
import { PasswordRecoveryResolver } from './password-recovery.resolver';
@Module({ @Module({
providers: [PasswordRecoveryResolver, PasswordRecoveryService], providers: [PasswordRecoveryResolver, PasswordRecoveryService],

View File

@ -1,13 +1,9 @@
import { import { NewPasswordInput } from '@/src/module/auth/password-recovery/inputs/new-password.input'
Args, Context, Mutation, Resolver, import { ResetPasswordInput } from '@/src/module/auth/password-recovery/inputs/reset-password.input'
} from '@nestjs/graphql'; import { UserAgent } from '@/src/shared/decorators/user-agent.decorator'
import { GqlContext } from '@/src/shared/types/gql-context.types'
import { NewPasswordInput } from '@/src/module/auth/password-recovery/inputs/new-password.input'; import { Args, Context, Mutation, Resolver } from '@nestjs/graphql'
import { ResetPasswordInput } from '@/src/module/auth/password-recovery/inputs/reset-password.input'; import { PasswordRecoveryService } from './password-recovery.service'
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 {
@ -19,11 +15,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,61 +1,52 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '@/src/core/prisma/prisma.service'
import { hash } from 'argon2'; 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 { PrismaService } from '@/src/core/prisma/prisma.service'; import { MailService } from '@/src/module/libs/mail/mail.service'
import { NewPasswordInput } from '@/src/module/auth/password-recovery/inputs/new-password.input'; import { generateToken } from '@/src/shared/util/generate-token.util'
import { ResetPasswordInput } from '@/src/module/auth/password-recovery/inputs/reset-password.input'; import { getSessionMetadata } from '@/src/shared/util/session-metadata.util'
import { MailService } from '@/src/module/libs/mail/mail.service'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import { TelegramService } from '@/src/module/libs/telegram/telegram.service'; import { TokenType } from '@prisma/generated'
import { generateToken } from '@/src/shared/util/generate-token.util'; import { hash } from 'argon2'
import { getSessionMetadata } from '@/src/shared/util/session-metadata.util'; import { Request } from 'express'
import { TokenType } from '@prisma/generated';
import type { 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,
) { ) {
} }
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 }, })
});
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) { return true
await this.telegramService.sendPasswordResetToken(resetToken.user.telegramId, resetToken.token, metadata);
}
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({
@ -65,15 +56,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,7 +1,5 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql'
import { import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator'
IsNotEmpty, IsString, Matches, MaxLength,
} from 'class-validator';
@InputType() @InputType()
export class ChangeProfileInfoInput { export class ChangeProfileInfoInput {
@ -9,16 +7,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

@ -0,0 +1,27 @@
import { UserModel } from '@/src/module/auth/account/models/user.model'
import { Field, ID, ObjectType } from '@nestjs/graphql'
import { SocialLink } from '@prisma/generated'
@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,20 +1,18 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql'
import { import { IsNotEmpty, IsNumber, IsString, IsUrl } from 'class-validator'
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()
@ -22,10 +20,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

@ -1,29 +0,0 @@
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,7 +1,6 @@
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,22 +1,18 @@
import { import { UserModel } from '@/src/module/auth/account/models/user.model'
Args, Mutation, Query, Resolver, import { ChangeProfileInfoInput } from '@/src/module/auth/profile/inputs/change-profile-info.input'
} from '@nestjs/graphql'; import { SocialLinkModel } from '@/src/module/auth/profile/inputs/models/social-link.model'
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 { SocialLinkModel } from './models/social-link.model'; import { ProfileService } from './profile.service'
import { ProfileService } from './profile.service'; import { User } from '@/prisma/generated'
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 {
@ -28,13 +24,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()
@ -43,7 +39,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()
@ -52,7 +48,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()
@ -60,7 +56,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()
@ -69,7 +65,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()
@ -77,12 +73,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,16 +1,14 @@
import { ConflictException, Injectable } from '@nestjs/common'; import { PrismaService } from '@/src/core/prisma/prisma.service'
import * as Upload from 'graphql-upload/Upload.js'; import { ChangeProfileInfoInput } from '@/src/module/auth/profile/inputs/change-profile-info.input'
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 { StorageService } from '../../libs/storage/storage.service'; import sharp from 'sharp'
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 {
@ -22,71 +20,70 @@ 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: {
@ -99,44 +96,45 @@ 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(async (socialLink) => this.prismaService.socialLink.update({ const updatePromises = list.map((socialLink) => {
where: { id: socialLink.id }, return this.prismaService.socialLink.update({
data: { position: Number(socialLink.position) }, where: { id: socialLink.id },
})); 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,25 +1,23 @@
import { Field, InputType } from '@nestjs/graphql'; import { Field, InputType } from '@nestjs/graphql'
import { import { IsNotEmpty, IsOptional, IsString, Length, MinLength } from 'class-validator'
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,57 +1,56 @@
import { Field, ID, ObjectType } from '@nestjs/graphql'; import { DeviceInfo, LocationInfo, SessionInfo } from '@/src/shared/types/session-metadata.types'
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,9 +1,7 @@
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 { SessionResolver } from './session.resolver'; import { SessionService } from './session.service'
import { SessionService } from './session.service'; import { SessionResolver } from './session.resolver'
@Module({ @Module({
providers: [SessionResolver, SessionService, VerificationService], providers: [SessionResolver, SessionService, VerificationService],

View File

@ -1,15 +1,12 @@
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 { LoginInput } from '@/src/module/auth/session/inputs/login.input'; import { UserModel } from '@/src/module/auth/account/models/user.model'
import { Authorization } from '@/src/shared/decorators/auth.decorator'; import { LoginInput } from '@/src/module/auth/session/inputs/login.input'
import { UserAgent } from '@/src/shared/decorators/user-agent.decorator'; import { Authorization } from '@/src/shared/decorators/auth.decorator'
import { GqlContext } from '@/src/shared/types/gql-context.types'; import { UserAgent } from '@/src/shared/decorators/user-agent.decorator'
import { GqlContext } from '@/src/shared/types/gql-context.types'
import { SessionModel } from './models/session.model'; import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql'
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 {
@ -20,7 +17,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()
@ -28,7 +25,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' })
@ -37,26 +34,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 async remove( public 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,114 +1,108 @@
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 { SessionData } from 'express-session'; import { Request } from 'express'
import { TOTP } from 'otpauth'; import { SessionData } from 'express-session'
import { TOTP } from 'otpauth'
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 {
constructor( constructor(
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly redisService: RedisService, private readonly redisService: RedisService,
private readonly configService: ConfigService<ProcessEnv>, private readonly configService: ConfigService,
private readonly verificationService: VerificationService, private readonly verificationService: VerificationService,
) {} ) {}
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({ const user = await this.prismaService.user.findFirst({ where: {
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({
@ -117,36 +111,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,7 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TotpResolver } from './totp.resolver';
import { TotpService } from './totp.service'; import { TotpService } from './totp.service';
import { TotpResolver } from './totp.resolver';
@Module({ @Module({
providers: [TotpResolver, TotpService], providers: [TotpResolver, TotpService],

View File

@ -1,14 +1,10 @@
import { import { EnableTotpInput } from '@/src/module/auth/totp/inputs/enable-totp.input'
Args, Mutation, Query, Resolver, import { TotpModel } from '@/src/module/auth/totp/models/totp.model'
} from '@nestjs/graphql'; import { Authorization } from '@/src/shared/decorators/auth.decorator'
import { Authorized } from '@/src/shared/decorators/authorized.decorator'
import { EnableTotpInput } from '@/src/module/auth/totp/inputs/enable-totp.input'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'
import { TotpModel } from '@/src/module/auth/totp/models/totp.model'; import { User } from '@prisma/generated'
import { Authorization } from '@/src/shared/decorators/auth.decorator'; import { TotpService } from './totp.service'
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 {
@ -17,7 +13,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()
@ -26,12 +22,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,13 +1,11 @@
import { randomBytes } from 'node:crypto'; import { PrismaService } from '@/src/core/prisma/prisma.service'
import { EnableTotpInput } from '@/src/module/auth/totp/inputs/enable-totp.input'
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common'
import { encode } from 'hi-base32'; import { User } from '@prisma/generated'
import { TOTP } from 'otpauth'; import { encode } from 'hi-base32'
import * as QRCode from 'qrcode'; import { randomBytes } from 'node:crypto'
import { TOTP } from 'otpauth'
import { PrismaService } from '@/src/core/prisma/prisma.service'; import * as QRCode from 'qrcode'
import { EnableTotpInput } from '@/src/module/auth/totp/inputs/enable-totp.input';
import { User } from '@prisma/generated';
@Injectable() @Injectable()
export class TotpService { export class TotpService {
@ -19,7 +17,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',
@ -27,18 +25,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',
@ -46,12 +44,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({
@ -60,9 +58,9 @@ export class TotpService {
isTotpEnabled: true, isTotpEnabled: true,
totpSecret: secret, totpSecret: secret,
}, },
}); })
return true; return true
} }
async disable(user: User) { async disable(user: User) {
@ -72,8 +70,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,7 +1,6 @@
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,13 +1,9 @@
import { import { AuthModel } from '@/src/module/auth/account/models/auth.model'
Args, Context, Mutation, Resolver, import { VerificationInput } from '@/src/module/auth/verification/inputs/verification.input'
} from '@nestjs/graphql'; import { UserAgent } from '@/src/shared/decorators/user-agent.decorator'
import { GqlContext } from '@/src/shared/types/gql-context.types'
import { AuthModel } from '@/src/module/auth/account/models/auth.model'; import { Args, Context, Mutation, Resolver } from '@nestjs/graphql'
import { VerificationInput } from '@/src/module/auth/verification/inputs/verification.input'; import { VerificationService } from './verification.service'
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 {
@ -19,6 +15,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,13 +1,12 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '@/src/core/prisma/prisma.service'
import { Request } from 'express'; import { VerificationInput } from '@/src/module/auth/verification/inputs/verification.input'
import { MailService } from '@/src/module/libs/mail/mail.service'
import { PrismaService } from '@/src/core/prisma/prisma.service'; import { generateToken } from '@/src/shared/util/generate-token.util'
import { VerificationInput } from '@/src/module/auth/verification/inputs/verification.input'; import { getSessionMetadata } from '@/src/shared/util/session-metadata.util'
import { MailService } from '@/src/module/libs/mail/mail.service'; import { saveSession } from '@/src/shared/util/session.util'
import { generateToken } from '@/src/shared/util/generate-token.util'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
import { getSessionMetadata } from '@/src/shared/util/session-metadata.util'; import { TokenType, User } from '@prisma/generated'
import { saveSession } from '@/src/shared/util/session.util'; import { Request } from 'express'
import { TokenType, User } from '@prisma/generated';
@Injectable() @Injectable()
export class VerificationService { export class VerificationService {
@ -18,19 +17,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({
@ -40,16 +39,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) {
@ -57,10 +56,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,9 +0,0 @@
import { Module } from '@nestjs/common';
import { CategoryResolver } from './category.resolver';
import { CategoryService } from './category.service';
@Module({
providers: [CategoryResolver, CategoryService],
})
export class CategoryModule {}

View File

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

View File

@ -1,72 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '@/src/core/prisma/prisma.service';
@Injectable()
export class CategoryService {
constructor(
private readonly prismaService: PrismaService,
) {
}
public async findAll() {
return this.prismaService.category.findMany({
orderBy: { createdAt: 'desc' },
include: {
streams: {
include: {
user: true,
category: true,
},
},
},
});
}
public async findRandom() {
const total = await this.prismaService.category.count({});
const randomIndexes = new Set<number>();
while (randomIndexes.size < 7) {
const randomIndex = Math.floor(Math.random() * total);
randomIndexes.add(randomIndex);
}
const categories = await this.prismaService.category.findMany({
take: total,
skip: 0,
include: {
streams: {
include: {
user: true,
category: true,
},
},
},
});
return Array.from(randomIndexes).map((index) => categories[index]);
}
public async findBySlug(slug: string) {
const category = await this.prismaService.category.findUnique({
where: {
slug,
},
include: {
streams: {
include: {
user: true,
category: true,
},
},
},
});
if (!category) {
throw new NotFoundException('Категория не найдена');
}
return category;
}
}

View File

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

View File

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

View File

@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { ChannelResolver } from './channel.resolver';
import { ChannelService } from './channel.service';
@Module({
providers: [ChannelResolver, ChannelService],
})
export class ChannelModule {}

View File

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

View File

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

View File

@ -1,9 +0,0 @@
import { Module } from '@nestjs/common';
import { ChatResolver } from './chat.resolver';
import { ChatService } from './chat.service';
@Module({
providers: [ChatResolver, ChatService],
})
export class ChatModule {}

View File

@ -1,54 +0,0 @@
import {
Args, Mutation, Query, Resolver, Subscription,
} from '@nestjs/graphql';
import { PubSub } from 'graphql-subscriptions';
import { ChangeChatSettingsInput } from '@/src/module/chat/input/change-chat-settings.input';
import { SendMessageInput } from '@/src/module/chat/input/send-message.input';
import { StreamModel } from '@/src/module/stream/models/stream.model';
import { Authorization } from '@/src/shared/decorators/auth.decorator';
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')
export class ChatResolver {
public pubSub: PubSub;
constructor(private readonly chatService: ChatService) {
this.pubSub = new PubSub();
}
@Query(() => [ChatMessageModel], { name: 'findMessagesByStream' })
public async findMessagesByStream(@Args('streamId') streamId: string) {
return this.chatService.findMessagesByStream(streamId);
}
@Authorization()
@Mutation(() => ChatMessageModel, { name: 'sendChatMessage' })
public async sendMessage(
@Authorized('id') userId: User['id'],
@Args('data') input: SendMessageInput,
) {
const message = this.chatService.sendMessage(userId, input);
void this.pubSub.publish('CHAT_MESSAGE_ADDED', { message });
return message;
}
@Subscription(() => ChatMessageModel, { name: 'chatMessageAdded', filter: (payload: { message: ChatMessage }, variables: ChatMessageModel) => payload.message.streamId === variables.streamId })
public async chatMessageAdded(@Args('streamId') streamId: string) {
return this.pubSub.asyncIterableIterator('CHAT_MESSAGE_ADDED');
}
@Authorization()
@Mutation(() => StreamModel, { name: 'changeChatSettings' })
public async changeSettings(
@Args('data') input: ChangeChatSettingsInput,
@Authorized() user: User,
) {
return this.chatService.changeSettings(user, input);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,9 @@
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()],
providers: [CronService, NotificationService], providers: [CronService],
}) })
export class CronModule {} export class CronModule {}

View File

@ -1,11 +1,8 @@
import { Injectable } from '@nestjs/common'; import { PrismaService } from '@/src/core/prisma/prisma.service'
import { Cron, CronExpression } from '@nestjs/schedule'; import { MailService } from '@/src/module/libs/mail/mail.service'
import { StorageService } from '@/src/module/libs/storage/storage.service'
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 { NotificationService } from '@/src/module/notification/notification.service';
@Injectable() @Injectable()
export class CronService { export class CronService {
@ -13,15 +10,13 @@ export class CronService {
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly mailService: MailService, private readonly mailService: MailService,
private readonly storageService: StorageService, private readonly storageService: StorageService,
private readonly telegramService: TelegramService,
private readonly notificationService: NotificationService,
) { ) {
} }
@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: {
@ -30,19 +25,13 @@ export class CronService {
lte: sevenDayAgo, lte: sevenDayAgo,
}, },
}, },
include: { })
notificationSettings: true,
},
});
for (const user of deactivatedAccounts) { for (const user of deactivatedAccounts) {
await this.mailService.sendAccountDeletion(user.email); console.log('Deactivate user', user.name, user.email)
if (user?.telegramId) { await this.mailService.sendAccountDeletion(user.email)
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,74 +42,6 @@ export class CronService {
lte: sevenDayAgo, lte: sevenDayAgo,
}, },
}, },
}); })
}
@Cron('0 0 */4 * *')
public async notifyUsersEnableTwoFactor() {
const users = await this.prismaService.user.findMany({
where: { isTotpEnabled: false },
include: { notificationSettings: true },
});
for (const user of users) {
if (!user) {
continue;
}
await this.mailService.sendEnableTwoFactor(user.email);
if (user.notificationSettings?.siteNotifications) {
await this.notificationService.createEnableTwoFactor(user.id);
}
if (user.notificationSettings?.telegramNotifications && user.telegramId) {
await this.telegramService.sendEnableTwoFactor(user.telegramId);
}
}
}
@Cron(CronExpression.EVERY_DAY_AT_1AM)
public async verifyChannels() {
const users = await this.prismaService.user.findMany({
include: { notificationSettings: true },
});
for (const user of users) {
const followersCount = await this.prismaService.follow.count({
where: { followingId: user.id },
});
if (followersCount > 10 && !user.isVerified) {
await this.prismaService.user.update({
where: { id: user.id },
data: { isVerified: true },
});
await this.mailService.sendVerifyChannel(user.email);
if (user.notificationSettings?.siteNotifications) {
await this.notificationService.createVerifyChannel(user.id);
}
if (user.notificationSettings?.telegramNotifications && user.telegramId) {
await this.telegramService.sendVerifyChannel(user.telegramId);
}
}
}
}
@Cron(CronExpression.EVERY_DAY_AT_1AM)
public async deleteOldNotifications() {
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
await this.prismaService.notification.deleteMany({
where: {
createdAt: {
lte: sevenDaysAgo,
},
},
});
} }
} }

View File

@ -1,11 +0,0 @@
import { Module } from '@nestjs/common';
import { NotificationService } from '@/src/module/notification/notification.service';
import { FollowResolver } from './follow.resolver';
import { FollowService } from './follow.service';
@Module({
providers: [FollowResolver, FollowService, NotificationService],
})
export class FollowModule {}

View File

@ -1,39 +0,0 @@
import {
Args, Mutation, Query, Resolver,
} from '@nestjs/graphql';
import { FollowModel } from '@/src/module/follow/model/follow.model';
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')
export class FollowResolver {
constructor(private readonly followService: FollowService) {}
@Authorization()
@Query(() => [FollowModel], { name: 'findMyFollowers' })
public async findMyFollowers(@Authorized() user: User) {
return this.followService.findMyFollowers(user);
}
@Authorization()
@Query(() => [FollowModel], { name: 'findMyFollowings' })
public async findMyFollowings(@Authorized() user: User) {
return this.followService.findMyFollowings(user);
}
@Authorization()
@Mutation(() => FollowModel, { name: 'followChannel' })
public async follow(@Authorized() user: User, @Args('channelId') channelId: string) {
return this.followService.follow(user, channelId);
}
@Authorization()
@Mutation(() => FollowModel, { name: 'unfollowChannel' })
public async unfollow(@Authorized() user: User, @Args('channelId') channelId: string) {
return this.followService.unfollow(user, channelId);
}
}

View File

@ -1,112 +0,0 @@
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 { NotificationService } from '@/src/module/notification/notification.service';
import { User } from '@prisma/generated';
@Injectable()
export class FollowService {
constructor(
private readonly prismaService: PrismaService,
private readonly notificationService: NotificationService,
private readonly telegramService: TelegramService,
) {
}
public async findMyFollowers(user: User) {
return this.prismaService.follow.findMany({
where: { followingId: user.id },
orderBy: { createdAt: 'desc' },
include: { follower: true, following: true },
});
}
public async findMyFollowings(user: User) {
return this.prismaService.follow.findMany({
where: { followerId: user.id },
orderBy: { createdAt: 'desc' },
include: { follower: true, following: true },
});
}
public async follow(user: User, channelId: string) {
const channel = await this.prismaService.user.findUnique({
where: { id: channelId },
});
if (!channel) {
throw new NotFoundException('Пользователь не найден');
}
if (channel.id === user.id) {
throw new ConflictException('Нельзя подписаться на себя');
}
const existingFollow = await this.prismaService.follow.findFirst({
where: {
followerId: user.id,
followingId: channel.id,
},
});
if (existingFollow) {
throw new ConflictException('Подписка уже существует');
}
const follow = await this.prismaService.follow.create({
data: {
followerId: user.id,
followingId: channel.id,
},
include: {
following: {
include: {
notificationSettings: true,
},
},
follower: true,
},
});
if (follow.following.notificationSettings?.siteNotifications) {
await this.notificationService.createNewFollowing(follow.following.id, follow.follower);
}
if (follow.following.notificationSettings?.telegramNotifications && follow.following.telegramId) {
await this.telegramService.sendNewFollowing(follow.following.telegramId, follow.follower);
}
return follow;
}
public async unfollow(user: User, channelId: string) {
const channel = await this.prismaService.user.findUnique({
where: { id: channelId },
});
if (!channel) {
throw new NotFoundException('Пользователь не найден');
}
if (channel.id === user.id) {
throw new ConflictException('Нельзя отписаться от себя');
}
const existingFollow = await this.prismaService.follow.findFirst({
where: {
followerId: user.id,
followingId: channel.id,
},
});
if (!existingFollow) {
throw new ConflictException('Подписки не существует');
}
return this.prismaService.follow.delete({
where: { id: existingFollow.id },
include: {
following: true,
follower: true,
},
});
}
}

View File

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

View File

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

View File

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

View File

@ -1,47 +1,44 @@
import { Inject, Injectable } from '@nestjs/common'; import { LiveKitOptionSymbol, TypeLiveKitOptions } from '@/src/module/libs/livekit/type/livekit.type'
import { IngressClient, RoomServiceClient, WebhookReceiver } from 'livekit-server-sdk'; import { Inject, Injectable } from '@nestjs/common'
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 readonly roomService: RoomServiceClient; private roomService: RoomServiceClient
private ingressClient: IngressClient
private readonly ingressClient: IngressClient; private webhookReceiver: WebhookReceiver
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,11 +1,12 @@
import type { FactoryProvider, ModuleMetadata } from '@nestjs/common'; import { 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<FactoryProvider<TypeLiveKitOptions>, 'inject' | 'useFactory'> & Pick<ModuleMetadata, 'imports'>; export type TypeLiveKitAsyncOptions = Pick<ModuleMetadata, 'imports'>
& Pick<FactoryProvider<TypeLiveKitOptions>, 'useFactory' | 'inject'>

View File

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

View File

@ -1,68 +1,53 @@
import { Injectable } from '@nestjs/common'; import { AccountDeletionTemplate } from '@/src/module/libs/mail/templates/account-deletion.template'
import { ConfigService } from '@nestjs/config'; import { DeactivateTemplate } from '@/src/module/libs/mail/templates/deactivate.template'
import { MailerService } from '@nestjs-modules/mailer'; import PasswordRecoveryTemplate from '@/src/module/libs/mail/templates/password-recovery.template'
import { render } from '@react-email/components'; import { SessionInfo } from '@/src/shared/types/session-metadata.types'
import { Token } from '@prisma/generated'
import { ProcessEnv } from '@/src/shared/types/env'; import VerificationTemplate from './templates/verification.template'
import { SessionInfo } from '@/src/shared/types/session-metadata.types'; import { MailerService } from '@nestjs-modules/mailer'
import { Token } from '@prisma/generated'; import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { import { render } from '@react-email/components'
EnableTwoFactorTemplate, VerifyChannelTemplate, VerificationTemplate, PasswordRecoveryTemplate, DeactivateTemplate, AccountDeletionTemplate,
} from './templates';
@Injectable() @Injectable()
export class MailService { export class MailService {
constructor( constructor(
private readonly configService: ConfigService<ProcessEnv>, private readonly configService: ConfigService,
private readonly mailerService: MailerService, private readonly mailerService: MailerService,
) {} ) {}
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) { private sendMail(email: string, subject: string, html: string) {
const domain = this.configService.getOrThrow<string>('ALLOWED_ORIGIN');
const html = await render(EnableTwoFactorTemplate({ domain }));
void this.sendMail(email, 'Обеспечьте свою безопасность', html);
}
public async sendVerifyChannel(email: string) {
const html = await render(VerifyChannelTemplate());
void this.sendMail(email, 'Ваш канал верифицирован', html);
}
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,29 +8,26 @@ 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'
type AccountDeletionTemplateProps = { interface AccountDeletionTemplateProps {
domain: string; domain: string
}; }
export const AccountDeletionTemplate = ({ domain }: AccountDeletionTemplateProps) => { export function 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>
@ -40,14 +37,12 @@ export const AccountDeletionTemplate = ({ domain }: AccountDeletionTemplateProps
<Text> <Text>
Вы больше не будете получать уведомления в Telegram и на почту. Вы больше не будете получать уведомления в Telegram и на почту.
</Text> </Text>
<Text> <Text>
Если вы захотите вернуться на платформу, вы можете зарегистрироваться по следующей ссылке: Если вы захотите вернуться на платформу, вы можете зарегистрироваться по следующей ссылке:
</Text> </Text>
<Link <Link
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} 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"
> >
Зарегистрироваться на Teastream Зарегистрироваться на Teastream
</Link> </Link>
@ -61,5 +56,5 @@ export const AccountDeletionTemplate = ({ domain }: AccountDeletionTemplateProps
</Body> </Body>
</Tailwind> </Tailwind>
</Html> </Html>
); )
}; }

View File

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

View File

@ -1,74 +0,0 @@
import {
Body,
Head,
Heading,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from '@react-email/components';
import * as React from 'react';
type EnableTwoFactorTemplateProps = {
domain: string;
};
export const EnableTwoFactorTemplate = ({ domain }: EnableTwoFactorTemplateProps) => {
const settingsLink = `${domain}/dashboard/settings`;
return (
<Html>
<Head />
<Preview>Обеспечьте свою безопасность</Preview>
<Tailwind>
<Body className="max-w-2xl mx-auto p-6 bg-slate-50">
<Section className="text-center mb-8">
<Heading className="text-3xl text-black font-bold">
Защитите свой аккаунт с двухфакторной аутентификацией
</Heading>
<Text className="text-black text-base mt-2">
Включите двухфакторную аутентификацию, чтобы повысить безопасность вашего аккаунта.
</Text>
</Section>
<Section className="bg-white rounded-lg shadow-md p-6 text-center mb-6">
<Heading className="text-2xl text-black font-semibold">
Почему это важно?
</Heading>
<Text className="text-base text-black mt-2">
Двухфакторная аутентификация добавляет дополнительный уровень защиты, требуя код, который известен только вам.
</Text>
<Link
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>
</Section>
<Section className="text-center mt-8">
<Text className="text-gray-600">
Если у вас возникли вопросы, обращайтесь в службу поддержки по адресу
{' '}
<Link
className="text-[#18b9ae] underline"
href="mailto:help@teastream.ru"
>
help@teastream.ru
</Link>
.
</Text>
</Section>
</Body>
</Tailwind>
</Html>
);
};

View File

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

View File

@ -1,43 +1,35 @@
import { import * as React from 'react'
Body, Head, Heading, Preview, Section, Tailwind, Text, Link, import { Body, Head, Heading, Preview, Section, Tailwind, Text, Link } from '@react-email/components'
} from '@react-email/components'; import { SessionInfo } from '@/src/shared/types/session-metadata.types'
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>
@ -48,32 +40,26 @@ 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>
@ -83,10 +69,9 @@ const PasswordRecoveryTemplate = (props: PasswordRecoveryTemplateProps) => {
<Text className="text-gray-600"> <Text className="text-gray-600">
Если у вас есть вопросы или вы столкнулись с трудностями, не стесняйтесь обращаться в нашу службу поддержки по адресу Если у вас есть вопросы или вы столкнулись с трудностями, не стесняйтесь обращаться в нашу службу поддержки по адресу
{' '} {' '}
<Link <Link
className="text-[#18b9ae] underline"
href="mailto:help@teastream.ru" href="mailto:help@teastream.ru"
className="text-[#18b9ae] underline"
> >
help@teastream.ru help@teastream.ru
</Link> </Link>
@ -96,7 +81,7 @@ const PasswordRecoveryTemplate = (props: PasswordRecoveryTemplateProps) => {
</Body> </Body>
</Tailwind> </Tailwind>
</Html> </Html>
); )
}; }
export default PasswordRecoveryTemplate; export default PasswordRecoveryTemplate

View File

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

View File

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

View File

@ -1,6 +1,5 @@
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,20 +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';
@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,
) { ) {
this.client = new S3Client({ this.client = new S3Client({
endpoint: this.configService.getOrThrow('S3_ENDPOINT'), endpoint: this.configService.getOrThrow('S3_ENDPOINT'),
@ -26,9 +23,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) {
@ -37,25 +34,28 @@ 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 { }
throw new BadRequestException('Ошибка при загрузке файла'); catch (e) {
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: key, Key: String(key),
}; }
try { try {
await this.client.send(new DeleteObjectCommand(command)); await this.client.send(new DeleteObjectCommand(command))
} catch { }
throw new BadRequestException('Ошибка при удалении файла'); catch (e) {
console.log('e', e);
throw new BadRequestException('Ошибка при удалении файла')
} }
} }
} }

View File

@ -1,39 +0,0 @@
import { DynamicModule, Module } from '@nestjs/common';
import { StripeService } from './stripe.service';
import { StripeOptionSymbol, TypeStripeAsyncOptions, TypeStripeOptions } from './types/stripe.type';
@Module({})
export class StripeModule {
public static register(options?: TypeStripeOptions): DynamicModule {
return {
module: StripeModule,
providers: [
{
provide: StripeOptionSymbol,
useValue: options,
},
StripeService,
],
exports: [StripeService],
global: true,
};
}
public static registerAsync(options: TypeStripeAsyncOptions): DynamicModule {
return {
module: StripeModule,
imports: options.imports || [],
providers: [
{
provide: StripeOptionSymbol,
useFactory: options.useFactory,
inject: options.inject || [],
},
StripeService,
],
exports: [StripeService],
global: true,
};
}
}

View File

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

View File

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

View File

@ -1,17 +0,0 @@
import { Markup } from 'telegraf';
export const BUTTONS = {
authSuccess: Markup.inlineKeyboard([
[
Markup.button.callback('📜 Мои подписки', 'follows'),
Markup.button.callback('👤 Просмотреть профиль', 'me'),
],
[Markup.button.url('🌐 На сайт', 'https://teastream.ru')],
]),
profile: Markup.inlineKeyboard([
Markup.button.url(
'⚙️ Настройки аккаунта',
'https://teastream.ru/dashboard/settings',
),
]),
};

View File

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

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