add redis, graphql

This commit is contained in:
Sergey Krylov 2025-06-23 06:38:51 +03:00
parent c5936cf05e
commit b1a7097d02
22 changed files with 1317 additions and 147 deletions

View File

@ -1,2 +0,0 @@
node_modules
dist

View File

@ -1,2 +0,0 @@
node_modules
dist

View File

@ -1,19 +0,0 @@
{
"trailingComma": "all",
"tabWidth": 2,
"useTabs": true,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": false,
"arrowParens": "avoid",
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrderCaseInsensitive": true,
"importOrderParserPlugin": [
"classProperties",
"decorators-legacy",
"typescript"
],
"importOrder": ["<THIRD_PARTY_MODULES>", "^@/(.*)$", "^../(.*)", "^./(.*)"],
"plugins": ["@trivago/prettier-plugin-sort-imports"]
}

View File

@ -13,9 +13,22 @@ services:
networks: networks:
- teastream-backend - teastream-backend
redis:
container_name: teastream-redis
image: redis:5.0
restart: always
ports:
- 6379:6379
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
networks:
- teastream-backend
volumes: volumes:
postgres_data: postgres_data:
redis_data:
networks: networks:
teastream-backend: teastream-backend:

View File

@ -1,16 +1,19 @@
// @ts-check // @ts-check
import eslint from '@eslint/js'; import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals'; import globals from 'globals';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import stylistic from '@stylistic/eslint-plugin'
export default tseslint.config( export default tseslint.config(
{ {
ignores: ['eslint.config.mjs'], ignores: [
'node_modules',
'dist'
],
}, },
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked, ...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended, stylistic.configs.recommended,
{ {
languageOptions: { languageOptions: {
globals: { globals: {
@ -23,6 +26,9 @@ export default tseslint.config(
tsconfigRootDir: import.meta.dirname, tsconfigRootDir: import.meta.dirname,
}, },
}, },
plugins: {
'@stylistic': stylistic,
}
}, },
{ {
rules: { rules: {

View File

@ -7,7 +7,6 @@
"license": "UNLICENSED", "license": "UNLICENSED",
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start", "start": "nest start",
"start:dev": "nest start --watch", "start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch", "start:debug": "nest start --debug --watch",
@ -23,11 +22,22 @@
"db:generate": "prisma generate" "db:generate": "prisma generate"
}, },
"dependencies": { "dependencies": {
"@apollo/server": "^4.12.2",
"@nestjs/apollo": "^13.1.0",
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/graphql": "^13.1.0",
"@nestjs/mapped-types": "*", "@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@prisma/client": "^6.10.1", "@prisma/client": "^6.10.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"connect-redis": "^9.0.0",
"cookie-parser": "^1.4.7",
"express-session": "^1.18.1",
"graphql": "^16.11.0",
"ioredis": "^5.6.1",
"prisma": "^6.10.1", "prisma": "^6.10.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
@ -38,19 +48,18 @@
"@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",
"@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/cookie-parser": "^1.4.9",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/express-session": "^1.18.2",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",

View File

@ -0,0 +1,20 @@
import { isDev } from '@/src/shared/util/is-dev.util'
import { ApolloDriverConfig } from '@nestjs/apollo'
import { ConfigService } from '@nestjs/config'
import { Request, Response } from 'express'
import * as path from 'node:path'
type ContextType = {
req: Request
res: Response
}
export function getGraphQLConfig(configService: ConfigService): ApolloDriverConfig {
return {
playground: isDev(configService),
path: configService.getOrThrow('GRAPHQL_PREFIX'),
autoSchemaFile: path.join(process.cwd(), 'src', 'core', 'graphql', 'schema.gql'),
sortSchema: true,
context: ({ req, res }: ContextType) => ({ req, res }),
}
}

View File

@ -1,7 +1,28 @@
import { Module } from '@nestjs/common'; import { getGraphQLConfig } from '@/src/core/config/graphql.config'
import { PrismaModule } from './prisma/prisma.module'; import { AccountModule } from '@/src/module/auth/account/account.module'
import { IS_DEV } from '@/src/shared/util/is-dev.util'
import { ApolloDriver } from '@nestjs/apollo'
import { Module } from '@nestjs/common'
import { ConfigModule, ConfigService } from '@nestjs/config'
import { GraphQLModule } from '@nestjs/graphql'
import { PrismaModule } from './prisma/prisma.module'
import { RedisModule } from './redis/redis.module'
@Module({ @Module({
imports: [PrismaModule], imports: [
ConfigModule.forRoot({
ignoreEnvFile: !IS_DEV,
isGlobal: true,
}),
GraphQLModule.forRootAsync({
driver: ApolloDriver,
imports: [ConfigModule],
useFactory: getGraphQLConfig,
inject: [ConfigService],
}),
PrismaModule,
RedisModule,
AccountModule,
],
}) })
export class CoreModule {} export class CoreModule {}

View File

@ -0,0 +1,24 @@
# ------------------------------------------------------
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
# ------------------------------------------------------
"""
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
"""
scalar DateTime
type Query {
findAllUsers: [UserModel!]!
}
type UserModel {
avatar: String
bio: String
createdAt: DateTime!
displayName: String!
email: String!
id: ID!
name: String!
password: String!
updatedAt: DateTime!
}

View File

@ -1,9 +1,9 @@
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({
providers: [PrismaService], providers: [PrismaService],
exports: [PrismaService] exports: [PrismaService],
}) })
export class PrismaModule {} export class PrismaModule {}

View File

@ -1,13 +1,13 @@
import { PrismaClient } from '@/prisma/generated'; import { PrismaClient } from '@/prisma/generated'
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'
@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

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common'
import { RedisService } from './redis.service'
@Global()
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

View File

@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import Redis from 'ioredis'
@Injectable()
export class RedisService extends Redis {
constructor(
private readonly configService: ConfigService,
) {
super(configService.getOrThrow('REDIS_URI'))
}
}

View File

@ -1,8 +1,50 @@
import { CoreModule } from '@/src/core/core.module'; import { RedisService } from '@/src/core/redis/redis.service'
import { NestFactory } from '@nestjs/core'; import { ms, StringValue } from '@/src/shared/util/ms.util'
import { parseBoolean } from '@/src/shared/util/parse-boolean.util'
import { ValidationPipe } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { NestFactory } from '@nestjs/core'
import { RedisStore } from 'connect-redis'
import * as cookieParser from 'cookie-parser'
import { CoreModule } from '@/src/core/core.module'
import * as session from 'express-session'
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(CoreModule); const app = await NestFactory.create(CoreModule)
await app.listen(process.env.PORT ?? 3000);
const config = app.get(ConfigService)
const redis = app.get(RedisService)
app.use(cookieParser(config.getOrThrow<string>('COOKIE_SECRET')))
app.useGlobalPipes(new ValidationPipe({
transform: true,
}))
app.use(session({
secret: config.getOrThrow<string>('SESSION_SECRET'),
name: config.getOrThrow<string>('SESSION_NAME'),
resave: false,
saveUninitialized: false,
cookie: {
domain: config.getOrThrow<string>('SESSION_DOMAIN'),
maxAge: ms(config.getOrThrow<StringValue>('SESSION_MAX_AGE')),
httpOnly: parseBoolean(config.getOrThrow('SESSION_HTTP_ONLY')),
secure: parseBoolean(config.getOrThrow('SESSION_SECURE')),
sameSite: 'lax',
},
store: new RedisStore({
client: redis,
prefix: config.getOrThrow('SESSION_FOLDER'),
}),
}))
app.enableCors({
origin: config.getOrThrow<string>('ALLOWED_ORIGIN'),
credential: true,
exposedHeaders: ['set-cookie'],
})
await app.listen(config.getOrThrow('APPLICATION_PORT'))
} }
bootstrap(); void bootstrap()

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common'
import { AccountService } from './account.service'
import { AccountResolver } from './account.resolver'
@Module({
providers: [AccountResolver, AccountService],
})
export class AccountModule {}

View File

@ -0,0 +1,13 @@
import { Query, Resolver } from '@nestjs/graphql'
import { AccountService } from './account.service'
import { UserModel } from './models/user.model'
@Resolver('Account')
export class AccountResolver {
constructor(private readonly accountService: AccountService) {}
@Query(() => [UserModel], { name: 'findAllUsers' })
public async findAll() {
return await this.accountService.findAll()
}
}

View File

@ -0,0 +1,15 @@
import { PrismaService } from '@/src/core/prisma/prisma.service'
import { Injectable } from '@nestjs/common'
@Injectable()
export class AccountService {
constructor(
private readonly prismaService: PrismaService,
) {
}
public async findAll() {
const users = await this.prismaService.user.findMany({})
return users
}
}

View File

@ -0,0 +1,31 @@
import { Field, ID, ObjectType } from '@nestjs/graphql'
@ObjectType()
export class UserModel {
@Field(() => ID)
id: string
@Field(() => String)
email: string
@Field(() => String)
password: string
@Field(() => String)
name: string
@Field(() => String)
displayName: string
@Field(() => String, { nullable: true })
avatar: string
@Field(() => String, { nullable: true })
bio: string
@Field(() => Date)
createdAt: Date
@Field(() => Date)
updatedAt: Date
}

View File

@ -0,0 +1,11 @@
import { ConfigService } from '@nestjs/config'
import * as dotenv from 'dotenv'
import * as process from 'node:process'
dotenv.config()
export function isDev(configService: ConfigService) {
return configService.getOrThrow('NODE_ENV') === 'development'
}
export const IS_DEV = process.env.NODE_ENV === 'development'

View File

@ -0,0 +1,112 @@
const s = 1000
const m = s * 60
const h = m * 60
const d = h * 24
const w = d * 7
const y = d * 365.25
type Unit
= | 'Years'
| 'Year'
| 'Yrs'
| 'Yr'
| 'Y'
| 'Weeks'
| 'Week'
| 'W'
| 'Days'
| 'Day'
| 'D'
| 'Hours'
| 'Hour'
| 'Hrs'
| 'Hr'
| 'H'
| 'Minutes'
| 'Minute'
| 'Mins'
| 'Min'
| 'M'
| 'Seconds'
| 'Second'
| 'Secs'
| 'Sec'
| 's'
| 'Milliseconds'
| 'Millisecond'
| 'Msecs'
| 'Msec'
| 'Ms'
type UnitAnyCase = Unit | Uppercase<Unit> | Lowercase<Unit>
export type StringValue
= | `${number}`
| `${number}${UnitAnyCase}`
| `${number} ${UnitAnyCase}`
export function ms(str: StringValue): number {
if (typeof str !== 'string' || str.length === 0 || str.length > 100) {
throw new Error(
'Value provided to ms() must be a string with length between 1 and 99.',
)
}
const match
= /^(?<value>-?(?:\d+)?\.?\d+) *(?<type>milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(
str,
)
const groups = match?.groups as { value: string, type?: string } | undefined
if (!groups) {
return NaN
}
const n = parseFloat(groups.value)
const type = (groups.type || 'ms').toLowerCase() as Lowercase<Unit>
switch (type) {
case 'years':
case 'year':
case 'yrs':
case 'yr':
case 'y':
return n * y
case 'weeks':
case 'week':
case 'w':
return n * w
case 'days':
case 'day':
case 'd':
return n * d
case 'hours':
case 'hour':
case 'hrs':
case 'hr':
case 'h':
return n * h
case 'minutes':
case 'minute':
case 'mins':
case 'min':
case 'm':
return n * m
case 'seconds':
case 'second':
case 'secs':
case 'sec':
case 's':
return n * s
case 'milliseconds':
case 'millisecond':
case 'msecs':
case 'msec':
case 'ms':
return n
default:
throw new Error(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`Ошибка: единица времени ${type} была распознана, но не существует соответствующего случая. Пожалуйста, проверьте введенные данные.`,
)
}
}

View File

@ -0,0 +1,19 @@
export function parseBoolean(value: string): boolean {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'string') {
const lowerValue = value.trim().toLowerCase()
if (lowerValue === 'true') {
return true
}
if (lowerValue === 'false') {
return false
}
}
throw new Error(
`Не удалось преобразовать значение "${value}" в логическое значение.`,
)
}

File diff suppressed because it is too large Load Diff