feat: add hall and seats services

This commit is contained in:
Sergey Krylov 2026-04-19 08:54:35 +03:00
parent 81003652d7
commit 00e68dbbf6
24 changed files with 475 additions and 21 deletions

View File

@ -19,8 +19,45 @@ model Theater {
name String name String
address String address String
halls Hall[]
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")
@@map("theaters") @@map("theaters")
} }
model Hall {
id String @id @default(nanoid())
name String
seats Seat[]
theater Theater @relation(fields: [theaterId], references: [id], onDelete: Cascade)
theaterId String @map("theater_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("halls")
}
model Seat {
id String @id @default(nanoid())
row Int
number Int
x Int
y Int
type String
price Int
hall Hall @relation(fields: [hallId], references: [id], onDelete: Cascade)
hallId String @map("hall_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([hallId, row, number])
@@map("seats")
}

View File

@ -2,6 +2,8 @@ import { Module } from '@nestjs/common'
import { ConfigModule } from '@nestjs/config' import { ConfigModule } from '@nestjs/config'
import { PrismaModule } from '@/infra/prisma/prisma.module' import { PrismaModule } from '@/infra/prisma/prisma.module'
import { HallModule } from '@/modules/hall/infra/hall.module'
import { SeatModule } from '@/modules/seat/infra/seat.module'
import { TheaterModule } from './modules/theater/infra/theater.module' import { TheaterModule } from './modules/theater/infra/theater.module'
@ -11,7 +13,9 @@ import { TheaterModule } from './modules/theater/infra/theater.module'
isGlobal: true isGlobal: true
}), }),
PrismaModule, PrismaModule,
TheaterModule TheaterModule,
HallModule,
SeatModule
] ]
}) })
export class AppModule {} export class AppModule {}

View File

@ -17,8 +17,8 @@ async function bootstrap() {
transport: Transport.GRPC, transport: Transport.GRPC,
options: { options: {
url, url,
package: ['theater.v1'], package: ['theater.v1', 'hall.v1', 'seat.v1'],
protoPath: [PROTO_PATHS.THEATER], protoPath: [PROTO_PATHS.THEATER, PROTO_PATHS.HALL, PROTO_PATHS.SEAT],
loader: { loader: {
keepCase: false, keepCase: false,
longs: String, longs: String,

View File

@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common'
import { PrismaService } from '@/infra/prisma/prisma.service'
import {
HallRepositoryPort,
RowLayout
} from '../../domain/ports/hall.repository.port'
@Injectable()
export class CreateHallUsecase {
constructor(
private readonly repository: HallRepositoryPort,
private readonly prismaService: PrismaService
) {}
public async execute(data: {
name: string
theaterId: string
layout: RowLayout[]
}) {
return this.prismaService.$transaction(async () => {
const hall = await this.repository.create(data)
await this.repository.createSeats({
hallId: hall.id,
layout: data.layout
})
return { hall }
})
}
}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common'
import { RpcException } from '@nestjs/microservices'
import { RpcStatus } from '@teacinema/common'
import { HallRepositoryPort } from '../../domain/ports/hall.repository.port'
@Injectable()
export class GetHallUsecase {
public constructor(private readonly repository: HallRepositoryPort) {}
public async execute(id: string) {
const hall = await this.repository.findById(id)
if (!hall) {
throw new RpcException({
code: RpcStatus.NOT_FOUND,
message: 'Hall not found'
})
}
return { hall }
}
}

View File

@ -0,0 +1,12 @@
import { Injectable } from '@nestjs/common'
import { HallRepositoryPort } from '@/modules/hall/domain/ports/hall.repository.port'
@Injectable()
export class ListHallsUseCase {
public constructor(private readonly repository: HallRepositoryPort) {}
public execute(theaterId: string) {
return this.repository.listByTheater(theaterId)
}
}

View File

@ -0,0 +1,9 @@
export class HallEntity {
public constructor(
public readonly id: string,
public readonly name: string,
public readonly theaterId: string,
public readonly createdAt: Date,
public readonly updatedAt: Date
) {}
}

View File

@ -0,0 +1,29 @@
import { HallEntity } from '../entities/hall.entity'
export interface RowLayout {
row: number
columns: number
type: string
price: number
}
export abstract class HallRepositoryPort {
public abstract create(data: {
name: string
theaterId: string
layout: RowLayout[]
}): Promise<HallEntity>
public abstract findById(id: string): Promise<HallEntity | null>
public abstract listByTheater(
theaterId: string
): Promise<{ halls: HallEntity[] }>
public abstract createSeats(data: {
hallId: string
layout: RowLayout[]
}): Promise<void>
public abstract findSeatsByHall(hallId: string)
}

View File

@ -0,0 +1,23 @@
import { Module } from '@nestjs/common'
import { HallRepositoryPort } from '@/modules/hall/domain/ports/hall.repository.port'
import { HallPrismaRepository } from '@/modules/hall/infra/prisma/hall.prisma.repository'
import { HallGrpcController } from '@/modules/hall/interfaces/grpc/hall.controller'
import { CreateHallUsecase } from '../application/commands/create-hall.usecase'
import { GetHallUsecase } from '../application/queries/get-hall.usecase'
import { ListHallsUseCase } from '../application/queries/list-halls.usecase'
@Module({
controllers: [HallGrpcController],
providers: [
{
provide: HallRepositoryPort,
useClass: HallPrismaRepository
},
CreateHallUsecase,
GetHallUsecase,
ListHallsUseCase
]
})
export class HallModule {}

View File

@ -0,0 +1,97 @@
import { Injectable } from '@nestjs/common'
import { Hall } from '@prisma/generated/client'
import { SeatCreateManyInput } from '@prisma/generated/models/Seat'
import { PrismaService } from '@/infra/prisma/prisma.service'
import { HallEntity } from '@/modules/hall/domain/entities/hall.entity'
import {
HallRepositoryPort,
RowLayout
} from '@/modules/hall/domain/ports/hall.repository.port'
@Injectable()
export class HallPrismaRepository implements HallRepositoryPort {
public constructor(private readonly prismaService: PrismaService) {}
public async create(data: {
name: string
theaterId: string
layout: RowLayout[]
}): Promise<HallEntity> {
const { name, theaterId } = data
const hall = await this.prismaService.hall.create({
data: {
name,
theater: {
connect: {
id: theaterId
}
}
}
})
return this.toEntity(hall)
}
public async findById(id: string): Promise<HallEntity | null> {
const hall = await this.prismaService.hall.findUnique({
where: { id }
})
return hall ? this.toEntity(hall) : null
}
public async listByTheater(
theaterId: string
): Promise<{ halls: HallEntity[] }> {
const halls = await this.prismaService.hall.findMany({
where: { theaterId },
orderBy: { name: 'asc' }
})
return {
halls: halls.map(hall => this.toEntity(hall))
}
}
public async createSeats(data: {
hallId: string
layout: RowLayout[]
}): Promise<void> {
const seats: SeatCreateManyInput[] = []
for (const rowConfig of data.layout) {
for (let num = 1; num <= rowConfig.columns; num++) {
seats.push({
row: rowConfig.row,
number: num,
hallId: data.hallId,
type: rowConfig.type,
price: rowConfig.price,
x: num,
y: rowConfig.row
})
}
}
await this.prismaService.seat.createMany({
data: seats
})
}
public async findSeatsByHall(hallId: string) {
const hall = await this.prismaService.hall.findUnique({
where: { id: hallId },
include: {
seats: true
}
})
return hall?.seats
}
private toEntity(hall: Hall): HallEntity {
const { id, name, theaterId, createdAt, updatedAt } = hall
return new HallEntity(id, name, theaterId, createdAt, updatedAt)
}
}

View File

@ -0,0 +1,42 @@
import { Controller } from '@nestjs/common'
import { GrpcMethod } from '@nestjs/microservices'
import type {
CreateHallRequest,
CreateHallResponse,
GetHallRequest,
GetHallResponse,
ListHallsByTheaterRequest,
ListHallsByTheaterResponse
} from '@teacinema/contracts/gen/ts/hall'
import { CreateHallUsecase } from '../../application/commands/create-hall.usecase'
import { GetHallUsecase } from '../../application/queries/get-hall.usecase'
import { ListHallsUseCase } from '../../application/queries/list-halls.usecase'
@Controller()
export class HallGrpcController {
public constructor(
private readonly getHallUC: GetHallUsecase,
private readonly listHallsUC: ListHallsUseCase,
private readonly createHallsUC: CreateHallUsecase
) {}
@GrpcMethod('HallService', 'GetHall')
public async getHall(data: GetHallRequest): Promise<GetHallResponse> {
return this.getHallUC.execute(data.id)
}
@GrpcMethod('HallService', 'CreateHall')
public async createHall(
data: CreateHallRequest
): Promise<CreateHallResponse> {
return this.createHallsUC.execute(data)
}
@GrpcMethod('HallService', 'ListHallsByTheater')
public async listHalls(
data: ListHallsByTheaterRequest
): Promise<ListHallsByTheaterResponse> {
return this.listHallsUC.execute(data.theaterId)
}
}

View File

@ -0,0 +1,28 @@
import { Injectable } from '@nestjs/common'
import { RpcException } from '@nestjs/microservices'
import { RpcStatus } from '@teacinema/common'
import { SeatRepositoryPort } from '../../domain/ports/seat.repository.port'
@Injectable()
export class GetSeatUsecase {
public constructor(private readonly repository: SeatRepositoryPort) {}
public async execute(id: string) {
const seat = await this.repository.findById(id)
if (!seat) {
throw new RpcException({
code: RpcStatus.NOT_FOUND,
message: 'Seat not found'
})
}
return {
seat: {
...seat,
status: 'available'
}
}
}
}

View File

@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common'
import { SeatRepositoryPort } from '../../domain/ports/seat.repository.port'
@Injectable()
export class ListSeatsUseCase {
public constructor(private readonly repository: SeatRepositoryPort) {}
public async execute(data: { hallId: string; screeningId: string }) {
const seats = await this.repository.findByHall(data.hallId)
return {
seats: seats.map(s => ({
...s,
status: 'available'
}))
}
}
}

View File

@ -0,0 +1,10 @@
export class SeatEntity {
public constructor(
public readonly id: string,
public readonly row: number,
public readonly number: number,
public readonly price: number,
public readonly type: string,
public readonly hallId: string,
) {}
}

View File

@ -0,0 +1,6 @@
import { SeatEntity } from '../entities/seat.entity'
export abstract class SeatRepositoryPort {
public abstract findById(id: string): Promise<SeatEntity | null>
public abstract findByHall(hallId: string): Promise<SeatEntity[]>
}

View File

@ -0,0 +1,34 @@
import { Injectable } from '@nestjs/common'
import { Seat } from '@prisma/generated/client'
import { PrismaService } from '@/infra/prisma/prisma.service'
import { SeatEntity } from '../../domain/entities/seat.entity'
import { SeatRepositoryPort } from '../../domain/ports/seat.repository.port'
@Injectable()
export class SeatPrismaRepository implements SeatRepositoryPort {
public constructor(private readonly prismaService: PrismaService) {}
public async findById(id: string): Promise<SeatEntity | null> {
const seat = await this.prismaService.seat.findUnique({
where: { id }
})
return seat ? this.toEntity(seat) : null
}
public async findByHall(hallId: string) {
const seats = await this.prismaService.seat.findMany({
where: { hallId },
orderBy: [{ row: 'asc' }, { number: 'asc' }]
})
return seats.map(s => this.toEntity(s))
}
private toEntity(seat: Seat): SeatEntity {
const { id, number, price, row, type, hallId } = seat
return new SeatEntity(id, row, number, price, type, hallId)
}
}

View File

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common'
import { GetSeatUsecase } from '../application/queries/get-seat.usecase'
import { ListSeatsUseCase } from '../application/queries/list-seats.usecase'
import { SeatRepositoryPort } from '../domain/ports/seat.repository.port'
import { SeatGrpcController } from '../interfaces/grpc/seat.controller'
import { SeatPrismaRepository } from './prisma/seat.prisma.repository'
@Module({
controllers: [SeatGrpcController],
providers: [
{
provide: SeatRepositoryPort,
useClass: SeatPrismaRepository
},
GetSeatUsecase,
ListSeatsUseCase
]
})
export class SeatModule {}

View File

@ -0,0 +1,31 @@
import { Controller } from '@nestjs/common'
import { GrpcMethod } from '@nestjs/microservices'
import type {
GetSeatRequest,
GetSeatResponse,
ListSeatsByHallRequest,
ListSeatsByHallResponse
} from '@teacinema/contracts/gen/ts/seat'
import { GetSeatUsecase } from '../../application/queries/get-seat.usecase'
import { ListSeatsUseCase } from '../../application/queries/list-seats.usecase'
@Controller()
export class SeatGrpcController {
public constructor(
private readonly getSeatUC: GetSeatUsecase,
private readonly listSeatsUC: ListSeatsUseCase
) {}
@GrpcMethod('SeatService', 'GetSeat')
public async getSeat(data: GetSeatRequest): Promise<GetSeatResponse> {
return this.getSeatUC.execute(data.id)
}
@GrpcMethod('SeatService', 'ListSeatsByHall')
public async listSeatsByHall(
data: ListSeatsByHallRequest
): Promise<ListSeatsByHallResponse> {
return this.listSeatsUC.execute(data)
}
}

View File

@ -1,10 +1,8 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { PrismaModule } from '@/infra/prisma/prisma.module' import { CreateTheaterUsecase } from '../application/commands/create-theater.usecase'
import { GetTheaterUsecase } from '../application/queries/get-theater.usecase'
import { CreateTheaterUsecase } from '../applications/commands/create-theater.usecase' import { ListTheaterUsecase } from '../application/queries/list-theater.usecase'
import { GetTheaterUsecase } from '../applications/queries/get-theater.usecase'
import { ListTheaterUsecase } from '../applications/queries/list-theater.usecase'
import { TheaterRepositoryPort } from '../domain/ports/theater.repository.port' import { TheaterRepositoryPort } from '../domain/ports/theater.repository.port'
import { TheaterGrpcController } from '../interfaces/grpc/theater.grpc.controller' import { TheaterGrpcController } from '../interfaces/grpc/theater.grpc.controller'

View File

@ -8,10 +8,9 @@ import type {
ListTheaterResponse ListTheaterResponse
} from '@teacinema/contracts/gen/ts/theater' } from '@teacinema/contracts/gen/ts/theater'
import { CreateTheaterUsecase } from '@/modules/theater/applications/commands/create-theater.usecase' import { CreateTheaterUsecase } from '../../application/commands/create-theater.usecase'
import { GetTheaterUsecase } from '@/modules/theater/applications/queries/get-theater.usecase' import { GetTheaterUsecase } from '../../application/queries/get-theater.usecase'
import { ListTheaterUsecase } from '../../application/queries/list-theater.usecase'
import { ListTheaterUsecase } from '../../applications/queries/list-theater.usecase'
@Controller() @Controller()
export class TheaterGrpcController { export class TheaterGrpcController {

View File

@ -1345,9 +1345,9 @@
integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==
"@teacinema/common@^1.4.0": "@teacinema/common@^1.4.0":
version "1.4.0" version "1.5.0"
resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcommon/-/1.4.0/common-1.4.0.tgz#9523c8a5a54c54b4756f2bc4db5d2cabb60504f2" resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcommon/-/1.5.0/common-1.5.0.tgz#236c1aba248b31773297bf17aad9053116483e93"
integrity sha512-M2c95xPg/lSG5Vz9tkqheuKzrd5m0HDtZdBEpPVehkTjk81GmSywyZr/HMBEl4GU/cQG93Xza//l3yCP7JnaZg== integrity sha512-GqGx8jFaMP6ADcZP18ariJtAHbJqLhT9Up80SLwU1fInKUthQb5MzLrTc4ce8jAxbczLVMGmikphIQNYNpvCGg==
dependencies: dependencies:
"@nestjs/common" "^11.1.16" "@nestjs/common" "^11.1.16"
"@nestjs/config" "^4.0.3" "@nestjs/config" "^4.0.3"
@ -1355,9 +1355,9 @@
"@teacinema/contracts" "^1.1.3" "@teacinema/contracts" "^1.1.3"
"@teacinema/contracts@^1.1.3": "@teacinema/contracts@^1.1.3":
version "1.3.1" version "1.4.0"
resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.3.1/contracts-1.3.1.tgz#f911a6d2a82a373ccf0f9a6223192e8bf3d7f933" resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.4.0/contracts-1.4.0.tgz#96f3abb5fa2ddf8c134e040bb8f155cd0829fcaa"
integrity sha512-iDvXw1zwvORps1d9qbvABVMRAx/WuftOciDAB1KG9TWD76nJT6CfCB9aJfovg3dfeeJo2WYc7mRbp8Ns6YeA/w== integrity sha512-1bQdII9naDgEiOXA/F2mxw9d62yF7NFv6f0atfUGvWrSTtM+uARX+wwcLHTuPDu3I1y8XrAYc8y4stFZxt2RrQ==
dependencies: dependencies:
"@nestjs/microservices" "^11.1.12" "@nestjs/microservices" "^11.1.12"
protoc "33.4.0" protoc "33.4.0"
@ -1365,9 +1365,9 @@
ts-proto "2.11.0" ts-proto "2.11.0"
"@teacinema/contracts@^1.3.1": "@teacinema/contracts@^1.3.1":
version "1.3.2" version "1.4.1"
resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.3.2/contracts-1.3.2.tgz#4abd896c2af5030ec049b34f978aa52554e6b03d" resolved "https://git.ksv741.keenetic.pro/api/packages/teacinema/npm/%40teacinema%2Fcontracts/-/1.4.1/contracts-1.4.1.tgz#20feccf1784a41ad6b4b0ebfcc035f991a7473c9"
integrity sha512-myXVPXPDOzSxixIAh8LuIMg84ftBHSKht9sySLnztrQfssutg4Hz5590aQIMlgUooHBiC/Gu/6sCUEkfeFOofQ== integrity sha512-CcYU/cUQfBw+KJp8d14RONIZahkbUAYxiLqudHhx/kpEPuoQDO8VMJy6SbUqBM1HKZ1VTtyQWfxm/79PBPMe0g==
dependencies: dependencies:
"@nestjs/microservices" "^11.1.12" "@nestjs/microservices" "^11.1.12"
protoc "33.4.0" protoc "33.4.0"