feat: add movie and category services

This commit is contained in:
Sergey Krylov 2026-04-12 09:31:52 +03:00
commit 827c1a431b
35 changed files with 7385 additions and 0 deletions

56
.gitignore vendored Normal file
View File

@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

98
README.md Normal file
View File

@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ yarn install
```
## Compile and run the project
```bash
# development
$ yarn run start
# watch mode
$ yarn run start:dev
# production mode
$ yarn run start:prod
```
## Run tests
```bash
# unit tests
$ yarn run test
# e2e tests
$ yarn run test:e2e
# test coverage
$ yarn run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ yarn install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

23
drizzle.config.ts Normal file
View File

@ -0,0 +1,23 @@
import * as dotenv from 'dotenv'
import { defineConfig } from 'drizzle-kit'
dotenv.config()
export default defineConfig({
out: process.env.DRIZZLE_OUT,
schema: './src/infra/database/drizzle/schema',
dialect: 'postgresql',
dbCredentials: {
host: process.env.DATABASE_HOST,
port: Number(process.env.DATABASE_PORT),
user: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
ssl:
process.env.DATABASE_SSL === 'true'
? { rejectUnauthorized: false }
: false
},
verbose: true,
strict: true
})

View File

@ -0,0 +1,27 @@
CREATE TABLE "categories" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"title" varchar(255) NOT NULL,
"slug" varchar(255),
CONSTRAINT "categories_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "movies" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"title" varchar(255) NOT NULL,
"slug" varchar(255),
"description" text NOT NULL,
"poster" text,
"duration" integer DEFAULT 0 NOT NULL,
"release_year" integer,
"release_date" timestamp,
"rating_age" integer DEFAULT 0,
"country" varchar(255),
"category_id" uuid,
CONSTRAINT "movies_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
ALTER TABLE "movies" ADD CONSTRAINT "movies_category_id_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."categories"("id") ON DELETE cascade ON UPDATE no action;

View File

@ -0,0 +1,2 @@
ALTER TABLE "movies" ALTER COLUMN "poster" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "movies" ADD COLUMN "banner" text;

View File

@ -0,0 +1,191 @@
{
"id": "18088c29-e51e-4bf6-96d6-72273adab2e4",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.categories": {
"name": "categories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"categories_slug_unique": {
"name": "categories_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.movies": {
"name": "movies",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"poster": {
"name": "poster",
"type": "text",
"primaryKey": false,
"notNull": false
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"release_year": {
"name": "release_year",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"release_date": {
"name": "release_date",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"rating_age": {
"name": "rating_age",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"country": {
"name": "country",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"category_id": {
"name": "category_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"movies_category_id_categories_id_fk": {
"name": "movies_category_id_categories_id_fk",
"tableFrom": "movies",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"movies_slug_unique": {
"name": "movies_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,197 @@
{
"id": "46b9aaed-8501-4aa0-837c-312bf24a66b1",
"prevId": "18088c29-e51e-4bf6-96d6-72273adab2e4",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.categories": {
"name": "categories",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"categories_slug_unique": {
"name": "categories_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.movies": {
"name": "movies",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"title": {
"name": "title",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"poster": {
"name": "poster",
"type": "text",
"primaryKey": false,
"notNull": true
},
"banner": {
"name": "banner",
"type": "text",
"primaryKey": false,
"notNull": false
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"release_year": {
"name": "release_year",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"release_date": {
"name": "release_date",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"rating_age": {
"name": "rating_age",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 0
},
"country": {
"name": "country",
"type": "varchar(255)",
"primaryKey": false,
"notNull": false
},
"category_id": {
"name": "category_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"movies_category_id_categories_id_fk": {
"name": "movies_category_id_categories_id_fk",
"tableFrom": "movies",
"tableTo": "categories",
"columnsFrom": [
"category_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"movies_slug_unique": {
"name": "movies_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1775616500784,
"tag": "0000_mixed_the_watchers",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1775620058610,
"tag": "0001_flippant_scrambler",
"breakpoints": true
}
]
}

35
eslint.config.mjs Normal file
View File

@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
nest-cli.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

89
package.json Normal file
View File

@ -0,0 +1,89 @@
{
"name": "movie-service",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"db:seed": "node -r tsconfig-paths/register -r ts-node/register ./src/infra/database/seed.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "ts-node ./src/infra/database/drizzle/migration.runner.ts",
"db:studio": "drizzle-kit studio",
"db:push": "drizzle-kit push",
"db:validate": "drizzle-kit check"
},
"dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/mapped-types": "*",
"@nestjs/microservices": "^11.1.18",
"@nestjs/platform-express": "^11.0.1",
"@teacinema/common": "^1.1.6",
"@teacinema/contracts": "^1.2.1",
"drizzle-orm": "^0.45.2",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@teacinema/core": "^1.0.9",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/pg": "^8.20.0",
"@types/supertest": "^6.0.2",
"drizzle-kit": "^0.31.10",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

7
prettier.config.mjs Normal file
View File

@ -0,0 +1,7 @@
import config from '@teacinema/core/prettier'
export default {
...config,
tabWidth: 2,
useTabs: false,
};

16
src/core/app.module.ts Normal file
View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config'
import { DatabaseModule } from 'src/infra/database/database.module'
import { CategoryModule } from '../modules/category/category.module'
import { MovieModule } from '../modules/movie/movie.module'
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
DatabaseModule,
MovieModule,
CategoryModule
]
})
export class AppModule {}

View File

@ -0,0 +1,26 @@
import { INestApplication } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { MicroserviceOptions, Transport } from '@nestjs/microservices'
import { PROTO_PATHS } from '@teacinema/contracts'
export function setupGrpc(app: INestApplication, config: ConfigService) {
const host = config.getOrThrow<string>('GRPC_HOST')
const port = config.getOrThrow<string>('GRPC_PORT')
const url = `${host}:${port}`
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.GRPC,
options: {
url,
package: ['movie.v1', 'category.v1'],
protoPath: [PROTO_PATHS.MOVIE, PROTO_PATHS.CATEGORY],
loader: {
keepCase: false,
longs: String,
enums: String,
defaults: true,
oneofs: true
}
}
})
}

View File

@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common'
import { connectionProvider } from './drizzle/connection.provider'
import { drizzleProvider } from './drizzle/drizzle.provider'
Global()
@Module({
providers: [connectionProvider, drizzleProvider],
exports: [drizzleProvider]
})
export class DatabaseModule {}

View File

@ -0,0 +1,26 @@
import { Provider } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import { Client } from 'pg'
export const PG_CONNECTION = Symbol('PG_CONNECTION')
export const connectionProvider: Provider = {
provide: PG_CONNECTION,
useFactory: async (configService: ConfigService) => {
const client = new Client({
host: configService.getOrThrow<string>('DATABASE_HOST'),
port: configService.getOrThrow<number>('DATABASE_PORT'),
user: configService.getOrThrow<string>('DATABASE_USERNAME'),
password: configService.getOrThrow<string>('DATABASE_PASSWORD'),
database: configService.getOrThrow<string>('DATABASE_NAME'),
ssl:
configService.getOrThrow<string>('DATABASE_SSL') === 'true'
? { rejectUnauthorized: false }
: false
})
await client.connect()
return client
},
inject: [ConfigService]
}

View File

@ -0,0 +1,14 @@
import { Provider } from '@nestjs/common'
import { drizzle } from 'drizzle-orm/node-postgres'
import { Client } from 'pg'
import { PG_CONNECTION } from './connection.provider'
export const DRIZZLE_DB = Symbol('DRIZZLE_DB')
export const drizzleProvider: Provider = {
provide: DRIZZLE_DB,
useFactory: (client: Client) => {
return drizzle(client)
},
inject: [PG_CONNECTION]
}

View File

@ -0,0 +1,30 @@
import * as dotenv from 'dotenv'
import { drizzle } from 'drizzle-orm/node-postgres'
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import { Pool } from 'pg'
dotenv.config()
const pool = new Pool({
host: process.env.DATABASE_HOST,
port: Number(process.env.DATABASE_PORT),
user: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME
})
const main = async () => {
const client = await pool.connect()
const db = drizzle(client)
console.log('Running drizzle migrations...')
await migrate(db, {
migrationsFolder: process.env.DRIZZLE_OUT ?? './drizzle'
})
console.log('Migrations completed')
client.release()
process.exit(0)
}
main()

View File

@ -0,0 +1,9 @@
import { pgTable, varchar } from 'drizzle-orm/pg-core'
import { baseColumns } from './common.schema'
export const categories = pgTable('categories', {
...baseColumns,
title: varchar('title', { length: 255 }).notNull(),
slug: varchar('slug', { length: 255 }).unique()
})

View File

@ -0,0 +1,7 @@
import { timestamp, uuid } from 'drizzle-orm/pg-core'
export const baseColumns = {
id: uuid('id').primaryKey().defaultRandom(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow()
}

View File

@ -0,0 +1,36 @@
import { relations } from 'drizzle-orm'
import {
integer,
pgTable,
text,
timestamp,
uuid,
varchar
} from 'drizzle-orm/pg-core'
import { categories } from './categories,schema'
import { baseColumns } from './common.schema'
export const movies = pgTable('movies', {
...baseColumns,
title: varchar('title', { length: 255 }).notNull(),
slug: varchar('slug', { length: 255 }).unique(),
description: text('description').notNull(),
poster: text('poster').notNull(),
banner: text('banner'),
duration: integer('duration').default(0).notNull(),
releaseYear: integer('release_year'),
releaseDate: timestamp('release_date'),
ratingAge: integer('rating_age').default(0),
country: varchar('country', { length: 255 }),
categoryId: uuid('category_id').references(() => categories.id, {
onDelete: 'cascade'
})
})
export const movieRelations = relations(movies, ({ one }) => ({
category: one(categories, {
fields: [movies.categoryId],
references: [categories.id]
})
}))

274
src/infra/database/seed.ts Normal file
View File

@ -0,0 +1,274 @@
import * as dotenv from 'dotenv'
import { drizzle } from 'drizzle-orm/node-postgres'
import { migrate } from 'drizzle-orm/node-postgres/migrator'
import { Pool } from 'pg'
import { categories } from './drizzle/schema/categories,schema'
import { movies } from './drizzle/schema/movies.schema'
dotenv.config()
const CATEGORIES = [
{
title: 'Классика',
slug: 'classic'
},
{
title: 'Гоночные фильмы',
slug: 'racing'
},
{
title: 'Кино Балабанова',
slug: 'balabanov'
}
]
const MOVIES = [
{
title: 'Крёстный отец',
slug: 'the-godfather',
description: `АмериканСкая мафия. Сила семьи. Драма б том, как сын дона Корлеоне втягивается в мир, где правят честь и кровь`,
poster: '/posters/the-godfather.webp',
banner: '/banners/the-godfather.webp',
duration: 175,
releaseDate: null,
releaseYear: 1972,
ratingAge: 16,
country: 'США',
category: 'classic'
},
{
title: 'Крестный отец 2',
slug: 'the-godfather-2',
description: `История семьи Корлеоне продолжается: параллельно - путь юного Вито и падение Майкла`,
poster: '/posters/the-godfather-2.webp',
banner: null,
duration: 202,
releaseDate: new Date('2025-12-31'),
releaseYear: 1974,
ratingAge: 16,
country: 'США',
category: 'classic'
},
{
title: 'Форрест Гамп',
slug: 'forrest-gump',
description:
'Простой парень с чистой душой проживает эпоху перемен и сам становится их частью. Невинность, любовь и великая американская история в одном сердце',
poster: '/posters/forrest-gump.webp',
banner: '/banners/forrest-gump.webp',
duration: 142,
releaseDate: null,
releaseYear: 1994,
ratingAge: 18,
country: 'США',
category: 'classic'
},
{
title: 'Однажды в Америке',
slug: 'once-upon-a-time-in-america',
description:
'Дружба, предательство и тень гангстерской эпохи. Долгий путь от детских улиц до мрачных разборок взрослых, где прошлое всегда догоняет',
poster: '/posters/once-upon-a-time-in-america.webp',
banner: '/banners/once-upon-a-time-in-america.webp',
duration: 229,
releaseDate: null,
releaseYear: 1984,
ratingAge: 18,
country: 'США',
category: 'classic'
},
{
title: 'Побег из Шоушенка',
slug: 'the-shawshank-redemption',
description:
'Банкир, несправедливо осуждённый, находит свободу - и надежду - в самых тёмных стенах. История силе духа и долгом пути к свету',
poster: '/posters/the-shawshank-redemption.webp',
banner: '/banners/the-shawshank-redemption.webp',
duration: 142,
releaseDate: null,
releaseYear: 1994,
ratingAge: 18,
country: 'США',
category: 'classic'
},
{
title: 'Зелёная миля',
slug: 'the-green-mile',
description:
'Тюрьма смертников, чудо посреди ужаса. История чудесах и боли, которую несут даже самые добрые сердца',
poster: '/posters/the-green-mile.webp',
banner: '/banners/the-green-mile.webp',
duration: 189,
releaseDate: null,
releaseYear: 1999,
ratingAge: 12,
country: 'США',
category: 'classic'
},
{
title: 'Апокалипсис сегодня',
slug: 'apocalypse-now',
description:
'Путешествие в сердце войны во Вьетнаме, где грань между разумом и безумием стирается',
poster: '/posters/apocalypse-now.webp',
banner: '/banners/apocalypse-now.webp',
duration: 147,
releaseDate: null,
releaseYear: 1979,
ratingAge: 18,
country: 'США',
category: 'classic'
},
{
title: 'Славные парни',
slug: 'goodfellas',
description:
'История о Генри Хилле - начинающем гангстере, занимающемся грабежами. История вместе с подельниками',
poster: '/posters/goodfellas.webp',
banner: '/banners/goodfellas.webp',
duration: 140,
releaseDate: null,
releaseYear: 1990,
ratingAge: 18,
country: 'США',
category: 'classic'
},
{
title: 'Формула-1',
slug: 'f1',
description:
'прошлом звезда «Формулы-1», Сонни Хейс возвращается в большой спорт спустя 30 лет, чтобы помочь команде-аутсайдеру и стать наставником молодого гонщика',
poster: '/posters/f1.webp',
banner: '/banners/f1. webp',
duration: 156,
releaseDate: new Date('2025-06-03'),
releaseYear: 2025,
ratingAge: 12,
country: 'США',
category: null
},
{
title: 'Оппенгеймер',
slug: 'oppenheimer',
description:
'История жизни американского физика-теоретика Роберта Оппенгеймера, который во времена Второй мировой войны руководил Манхэттенским проектом - секретными разработками ядерного оружия',
poster: '/posters/oppenheimer.webp',
banner: '/banners/oppenheimer.webp',
duration: 181,
releaseDate: new Date('2025-10-01'),
releaseYear: 2023,
ratingAge: 16,
country: 'США',
category: null
},
{
title: 'Груз 200',
slug: 'cargo-200',
description:
'Жесткий триллер о советской провинции середины 1980-х убийствах, насилии и распаде системы',
poster: '/posters/cargo-200. webp',
banner: '/banners/cargo-200.webp',
duration: 85,
releaseDate: new Date('2025-12-31'),
releaseYear: 2007,
ratingAge: 18,
country: 'Россия',
category: 'balabanov'
},
{
title: 'Солнцестояние',
slug: 'midsommar',
description:
'Шведский культ. Летний фестиваль. Драма о группе друзей, группе друзей, которые попадают в загадочный мир древних ритуалов',
poster: '/posters/midsommar.webp',
banner: '/banners/midsommar.webp',
duration: 147,
releaseDate: new Date('2025-10-31'),
releaseYear: 2019,
ratingAge: 18,
country: 'США',
category: null
},
{
title: 'Человек-паук: Через вселенные',
slug: 'spider-man-into-the-spider-verse',
description:
'Мы всё знаем Питере Паркере. Он спас город, влюбился, потом спасал город снова и снова... все это в нашем измерении. если в результате работы гигантского коллайдера откроется окно из одного измерения в другое? Найдется ли в нем свой Человек-паук? И как он будет выглядеть? Приготовьтесь к тому, что в разных вселенных могут быть разные Люди-пауки и однажды им придется собраться вместе для борьбы с почти непобедимым врагом',
poster: '/posters/spider-man-into-the-spider-verse.webp',
banner: '/banners/spider-man-into-the-spider-verse.webp',
duration: 117,
releaseDate: new Date('2025-09-25'),
releaseYear: 2018,
ratingAge: 6,
country: 'США',
category: null
},
{
title: 'Интерстеллар',
slug: 'interstellar',
description:
'Путешествие сквозь червоточины и расстояния, где каждая секунда ценнее жизни',
poster: '/posters/interstellar.webp',
banner: '/banners/interstellar.webp',
duration: 169,
releaseDate: new Date('2025-12-31'),
releaseYear: 2014,
ratingAge: 12,
country: 'США',
category: null
},
{
title: 'Форсаж: Токийский дрифт',
slug: 'fast-and-furious-tokyo-drift',
description: 'Мир подпольных гонок, дрифта и уличной культуры Токио. ',
poster: '/posters/fast-and-furious-tokyo-drift.webp',
banner: '/banners/fast-and-furious-tokyo-drift.webp',
duration: 104,
releaseDate: null,
releaseYear: 2006,
ratingAge: 12,
country: 'США, Япония',
category: 'racing'
}
]
const pool = new Pool({
host: process.env.DATABASE_HOST,
port: Number(process.env.DATABASE_PORT),
user: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME
})
const main = async () => {
const client = await pool.connect()
const db = drizzle(client)
console.log('Seeding categories...')
await db
.insert(categories)
.values([...CATEGORIES])
.onConflictDoNothing()
console.log('Categories completed')
console.log('Seeding movies...')
const dbCategories = await db.select().from(categories)
const categoriesMap = new Map(dbCategories.map(c => [c.slug, c.id]))
await db
.insert(movies)
.values(
MOVIES.map(movie => ({
...movie,
categoryId: movie.category
? (categoriesMap.get(movie.category) ?? null)
: null
}))
)
.onConflictDoNothing()
console.log('Movies completed')
client.release()
process.exit(0)
}
main()

15
src/main.ts Normal file
View File

@ -0,0 +1,15 @@
import { ConfigService } from '@nestjs/config'
import { NestFactory } from '@nestjs/core'
import { AppModule } from './core/app.module'
import { setupGrpc } from './core/bootstrap/grpc'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
const config = app.get(ConfigService)
setupGrpc(app, config)
app.startAllMicroservices()
app.init()
}
bootstrap()

View File

@ -0,0 +1,15 @@
import { Controller } from '@nestjs/common'
import { GrpcMethod } from '@nestjs/microservices'
import { GetAllCategoriesResponse } from '@teacinema/contracts/gen/ts/category'
import { CategoryService } from './category.service'
@Controller()
export class CategoryController {
constructor(private readonly categoryService: CategoryService) {}
@GrpcMethod('CategoryService', 'GetAllCategories')
public async getAll(): Promise<GetAllCategoriesResponse> {
return this.categoryService.getAll()
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common'
import { DatabaseModule } from '../../infra/database/database.module'
import { CategoryController } from './category.controller'
import { CategoryRepository } from './category.repository'
import { CategoryService } from './category.service'
@Module({
imports: [DatabaseModule],
controllers: [CategoryController],
providers: [CategoryService, CategoryRepository]
})
export class CategoryModule {}

View File

@ -0,0 +1,22 @@
import { Inject, Injectable } from '@nestjs/common'
import { desc } from 'drizzle-orm'
import { NodePgDatabase } from 'drizzle-orm/node-postgres'
import { DRIZZLE_DB } from '../../infra/database/drizzle/drizzle.provider'
import { categories } from '../../infra/database/drizzle/schema/categories,schema'
@Injectable()
export class CategoryRepository {
public constructor(@Inject(DRIZZLE_DB) private readonly db: NodePgDatabase) {}
public async findAll() {
return this.db
.select({
id: categories.id,
title: categories.title,
slug: categories.slug
})
.from(categories)
.orderBy(desc(categories.createdAt))
}
}

View File

@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common'
import { Category } from '@teacinema/contracts/gen/ts/category'
import { CategoryRepository } from './category.repository'
@Injectable()
export class CategoryService {
public constructor(private readonly repository: CategoryRepository) {}
public async getAll() {
const categories: Category[] = await this.repository.findAll()
return { categories }
}
}

View File

@ -0,0 +1,25 @@
import { Controller } from '@nestjs/common'
import { GrpcMethod } from '@nestjs/microservices'
import type {
GetMovieRequest,
GetMovieResponse,
ListMovieRequest,
ListMovieResponse
} from '@teacinema/contracts/gen/ts/movie'
import { MovieService } from './movie.service'
@Controller()
export class MovieController {
constructor(private readonly movieService: MovieService) {}
@GrpcMethod('MovieService', 'ListMovies')
public async list(data: ListMovieRequest): Promise<ListMovieResponse> {
return this.movieService.getAll(data)
}
@GrpcMethod('MovieService', 'GetMovie')
public async getOne(data: GetMovieRequest): Promise<GetMovieResponse> {
return this.movieService.getOne(data)
}
}

View File

@ -0,0 +1,19 @@
export class MovieMapper {
// todo улучшить типизацию
public static toMovie<T>(entity: any): T {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (!entity.releaseDate) return entity as T
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
const ms = entity.releaseDate.getTime()
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...entity,
releaseDate: {
seconds: Math.floor(ms / 1000),
nanos: (ms % 1000) * 1_000_000
}
}
}
}

View File

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common'
import { DatabaseModule } from '../../infra/database/database.module'
import { MovieController } from './movie.controller'
import { MovieRepository } from './movie.repository'
import { MovieService } from './movie.service'
@Module({
imports: [DatabaseModule],
controllers: [MovieController],
providers: [MovieService, MovieRepository]
})
export class MovieModule {}

View File

@ -0,0 +1,82 @@
import { Inject, Injectable } from '@nestjs/common'
import { ListMovieRequest } from '@teacinema/contracts/gen/ts/movie'
import { and, desc, eq, gt, isNull, lte, or, sql } from 'drizzle-orm'
import { NodePgDatabase } from 'drizzle-orm/node-postgres'
import { DRIZZLE_DB } from '../../infra/database/drizzle/drizzle.provider'
import { categories } from '../../infra/database/drizzle/schema/categories,schema'
import { movies } from '../../infra/database/drizzle/schema/movies.schema'
@Injectable()
export class MovieRepository {
public constructor(@Inject(DRIZZLE_DB) private readonly db: NodePgDatabase) {}
public async findAll(filter: ListMovieRequest) {
const where = this.buildWhere(filter)
const orderBy = this.buildOrder(filter)
const query = this.db
.select({
id: movies.id,
title: movies.title,
slug: movies.slug,
poster: movies.poster,
ratingAge: movies.ratingAge,
releaseDate: movies.releaseDate
})
.from(movies)
.leftJoin(categories, eq(movies.categoryId, categories.id))
.where(where)
.orderBy(orderBy)
if (filter.limit && filter.limit > 0) {
return query.limit(filter.limit)
}
return query
}
public async findBySlug(slug: string) {
return this.db
.select()
.from(movies)
.where(eq(movies.slug, slug))
.limit(1)
.then(result => result[0] ?? null)
}
public async findById(id: string) {
return this.db
.select()
.from(movies)
.where(eq(movies.id, id))
.limit(1)
.then(result => result[0] ?? null)
}
private buildWhere(filter: ListMovieRequest) {
const now = new Date()
const conditions = []
if (!filter.category || filter.category === 'all') {
} else if (filter.category === 'now') {
conditions.push(lte(movies.releaseDate, now))
} else if (filter.category === 'soon') {
conditions.push(
or(gt(movies.releaseDate, now), isNull(movies.releaseDate))
)
} else {
conditions.push(eq(categories.slug, filter.category))
}
return conditions.length ? and(...conditions) : undefined
}
private buildOrder(filter: ListMovieRequest) {
if (filter.random) {
return sql`RANDOM()`
}
return desc(movies.releaseDate)
}
}

View File

@ -0,0 +1,46 @@
import { Injectable } from '@nestjs/common'
import { RpcException } from '@nestjs/microservices'
import { RpcStatus } from '@teacinema/common'
import {
GetMovieRequest,
ListMovieRequest,
Movie,
MovieDetails
} from '@teacinema/contracts/gen/ts/movie'
import { MovieMapper } from './movie.mapper'
import { MovieRepository } from './movie.repository'
@Injectable()
export class MovieService {
public constructor(private readonly movieRepository: MovieRepository) {}
public async getAll(data: ListMovieRequest) {
const filter = {
category: data.category ?? undefined,
random: data.random === true,
limit: data.limit > 0 ? data.limit : undefined
}
const movies = await this.movieRepository.findAll(filter)
return {
movies: movies.map(movie => MovieMapper.toMovie<Movie>(movie))
}
}
public async getOne(data: GetMovieRequest) {
const movie = data.id
? await this.movieRepository.findById(data.id)
: await this.movieRepository.findBySlug(data.slug)
if (!movie) {
throw new RpcException({
code: RpcStatus.NOT_FOUND,
details: 'Movie not found'
})
}
return { movie: MovieMapper.toMovie<MovieDetails>(movie) }
}
}

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false,
"strict": false
}
}

5888
yarn.lock Normal file

File diff suppressed because it is too large Load Diff