Initial commit: Harat's Booking monorepo with deploy setup

This commit is contained in:
Eugenius-Anonymous
2026-06-08 14:36:51 +07:00
commit c97f7fd1a8
89 changed files with 10536 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@harats/server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"db:migrate": "prisma migrate dev",
"db:seed": "tsx prisma/seed.ts",
"db:reset": "prisma migrate reset --force",
"db:studio": "prisma studio"
},
"dependencies": {
"@harats/shared": "*",
"@prisma/client": "^6.4.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.16",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/nodemailer": "^6.4.17",
"prisma": "^6.4.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
@@ -0,0 +1,193 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
-- CreateEnum
CREATE TYPE "ReservationStatus" AS ENUM ('PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED');
-- CreateEnum
CREATE TYPE "DishAvailability" AS ENUM ('AVAILABLE', 'STOPPED');
-- CreateTable
CREATE TABLE "User" (
"id" SERIAL NOT NULL,
"phone" VARCHAR(20) NOT NULL,
"password" VARCHAR(255) NOT NULL,
"name" VARCHAR(100) NOT NULL,
"surname" VARCHAR(100),
"email" VARCHAR(255),
"role" "Role" NOT NULL DEFAULT 'USER',
"cardToken" VARCHAR(255),
"cardLastFour" VARCHAR(4),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Pub" (
"id" SERIAL NOT NULL,
"address" VARCHAR(255) NOT NULL,
"phone" VARCHAR(20) NOT NULL,
"name" VARCHAR(100) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Pub_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Table" (
"id" SERIAL NOT NULL,
"pubId" INTEGER NOT NULL,
"number" INTEGER NOT NULL,
"capacity" INTEGER NOT NULL,
"posX" DOUBLE PRECISION NOT NULL,
"posY" DOUBLE PRECISION NOT NULL,
"width" DOUBLE PRECISION NOT NULL DEFAULT 60,
"height" DOUBLE PRECISION NOT NULL DEFAULT 60,
"shape" VARCHAR(20) NOT NULL DEFAULT 'rect',
"zone" VARCHAR(50),
CONSTRAINT "Table_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Category" (
"id" SERIAL NOT NULL,
"title" VARCHAR(100) NOT NULL,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Dish" (
"id" SERIAL NOT NULL,
"title" VARCHAR(100) NOT NULL,
"description" TEXT,
"categoryId" INTEGER NOT NULL,
"grams" INTEGER,
"price" DECIMAL(10,2) NOT NULL,
"photo" VARCHAR(255),
"availability" "DishAvailability" NOT NULL DEFAULT 'AVAILABLE',
CONSTRAINT "Dish_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Menu" (
"id" SERIAL NOT NULL,
"pubId" INTEGER NOT NULL,
"dishId" INTEGER NOT NULL,
CONSTRAINT "Menu_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LunchMenu" (
"id" SERIAL NOT NULL,
"pubId" INTEGER NOT NULL,
"dishId" INTEGER NOT NULL,
"weekStart" DATE NOT NULL,
"weekEnd" DATE NOT NULL,
CONSTRAINT "LunchMenu_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Billboard" (
"id" SERIAL NOT NULL,
"title" VARCHAR(200) NOT NULL,
"artist" VARCHAR(100) NOT NULL,
"pubId" INTEGER NOT NULL,
"datetime" TIMESTAMP(3) NOT NULL,
"poster" VARCHAR(255),
CONSTRAINT "Billboard_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Reservation" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"pubId" INTEGER NOT NULL,
"tableId" INTEGER NOT NULL,
"date" DATE NOT NULL,
"time" TIME NOT NULL,
"numPeople" INTEGER NOT NULL,
"status" "ReservationStatus" NOT NULL DEFAULT 'PENDING',
"totalCost" DECIMAL(10,2),
"prepaid" DECIMAL(10,2),
"paymentId" VARCHAR(255),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Reservation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ReservationDish" (
"id" SERIAL NOT NULL,
"reservationId" INTEGER NOT NULL,
"dishId" INTEGER NOT NULL,
"quantity" INTEGER NOT NULL DEFAULT 1,
"priceAtOrder" DECIMAL(10,2) NOT NULL,
CONSTRAINT "ReservationDish_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "Pub_address_key" ON "Pub"("address");
-- CreateIndex
CREATE UNIQUE INDEX "Table_pubId_number_key" ON "Table"("pubId", "number");
-- CreateIndex
CREATE UNIQUE INDEX "Category_title_key" ON "Category"("title");
-- CreateIndex
CREATE UNIQUE INDEX "Menu_pubId_dishId_key" ON "Menu"("pubId", "dishId");
-- CreateIndex
CREATE UNIQUE INDEX "LunchMenu_pubId_dishId_weekStart_key" ON "LunchMenu"("pubId", "dishId", "weekStart");
-- CreateIndex
CREATE UNIQUE INDEX "ReservationDish_reservationId_dishId_key" ON "ReservationDish"("reservationId", "dishId");
-- AddForeignKey
ALTER TABLE "Table" ADD CONSTRAINT "Table_pubId_fkey" FOREIGN KEY ("pubId") REFERENCES "Pub"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Dish" ADD CONSTRAINT "Dish_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Menu" ADD CONSTRAINT "Menu_pubId_fkey" FOREIGN KEY ("pubId") REFERENCES "Pub"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Menu" ADD CONSTRAINT "Menu_dishId_fkey" FOREIGN KEY ("dishId") REFERENCES "Dish"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LunchMenu" ADD CONSTRAINT "LunchMenu_pubId_fkey" FOREIGN KEY ("pubId") REFERENCES "Pub"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LunchMenu" ADD CONSTRAINT "LunchMenu_dishId_fkey" FOREIGN KEY ("dishId") REFERENCES "Dish"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Billboard" ADD CONSTRAINT "Billboard_pubId_fkey" FOREIGN KEY ("pubId") REFERENCES "Pub"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reservation" ADD CONSTRAINT "Reservation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reservation" ADD CONSTRAINT "Reservation_pubId_fkey" FOREIGN KEY ("pubId") REFERENCES "Pub"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reservation" ADD CONSTRAINT "Reservation_tableId_fkey" FOREIGN KEY ("tableId") REFERENCES "Table"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationDish" ADD CONSTRAINT "ReservationDish_reservationId_fkey" FOREIGN KEY ("reservationId") REFERENCES "Reservation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ReservationDish" ADD CONSTRAINT "ReservationDish_dishId_fkey" FOREIGN KEY ("dishId") REFERENCES "Dish"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+155
View File
@@ -0,0 +1,155 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Role {
USER
ADMIN
}
enum ReservationStatus {
PENDING
CONFIRMED
CANCELLED
COMPLETED
}
enum DishAvailability {
AVAILABLE
STOPPED
}
model User {
id Int @id @default(autoincrement())
phone String @unique @db.VarChar(20)
password String @db.VarChar(255)
name String @db.VarChar(100)
surname String? @db.VarChar(100)
email String? @db.VarChar(255)
role Role @default(USER)
cardToken String? @db.VarChar(255)
cardLastFour String? @db.VarChar(4)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reservations Reservation[]
}
model Pub {
id Int @id @default(autoincrement())
address String @unique @db.VarChar(255)
phone String @db.VarChar(20)
name String @db.VarChar(100)
createdAt DateTime @default(now())
tables Table[]
menus Menu[]
lunchMenus LunchMenu[]
billboards Billboard[]
reservations Reservation[]
}
model Table {
id Int @id @default(autoincrement())
pubId Int
number Int
capacity Int
posX Float
posY Float
width Float @default(60)
height Float @default(60)
shape String @default("rect") @db.VarChar(20)
zone String? @db.VarChar(50)
pub Pub @relation(fields: [pubId], references: [id])
reservations Reservation[]
@@unique([pubId, number])
}
model Category {
id Int @id @default(autoincrement())
title String @unique @db.VarChar(100)
dishes Dish[]
}
model Dish {
id Int @id @default(autoincrement())
title String @db.VarChar(100)
description String? @db.Text
categoryId Int
grams Int?
price Decimal @db.Decimal(10, 2)
photo String? @db.VarChar(255)
availability DishAvailability @default(AVAILABLE)
category Category @relation(fields: [categoryId], references: [id])
menus Menu[]
lunchMenus LunchMenu[]
orderItems ReservationDish[]
}
model Menu {
id Int @id @default(autoincrement())
pubId Int
dishId Int
pub Pub @relation(fields: [pubId], references: [id])
dish Dish @relation(fields: [dishId], references: [id])
@@unique([pubId, dishId])
}
model LunchMenu {
id Int @id @default(autoincrement())
pubId Int
dishId Int
weekStart DateTime @db.Date
weekEnd DateTime @db.Date
pub Pub @relation(fields: [pubId], references: [id])
dish Dish @relation(fields: [dishId], references: [id])
@@unique([pubId, dishId, weekStart])
}
model Billboard {
id Int @id @default(autoincrement())
title String @db.VarChar(200)
artist String @db.VarChar(100)
pubId Int
datetime DateTime
poster String? @db.VarChar(255)
pub Pub @relation(fields: [pubId], references: [id])
}
model Reservation {
id Int @id @default(autoincrement())
userId Int
pubId Int
tableId Int
date DateTime @db.Date
time DateTime @db.Time()
numPeople Int
status ReservationStatus @default(PENDING)
totalCost Decimal? @db.Decimal(10, 2)
prepaid Decimal? @db.Decimal(10, 2)
paymentId String? @db.VarChar(255)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id])
pub Pub @relation(fields: [pubId], references: [id])
table Table @relation(fields: [tableId], references: [id])
dishes ReservationDish[]
}
model ReservationDish {
id Int @id @default(autoincrement())
reservationId Int
dishId Int
quantity Int @default(1)
priceAtOrder Decimal @db.Decimal(10, 2)
reservation Reservation @relation(fields: [reservationId], references: [id], onDelete: Cascade)
dish Dish @relation(fields: [dishId], references: [id])
@@unique([reservationId, dishId])
}
+282
View File
@@ -0,0 +1,282 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Seeding database...');
// ─── Categories ──────────────────────────────────────
const categories = await Promise.all([
prisma.category.upsert({ where: { title: 'Закуски' }, update: {}, create: { title: 'Закуски' } }),
prisma.category.upsert({ where: { title: 'Салаты' }, update: {}, create: { title: 'Салаты' } }),
prisma.category.upsert({ where: { title: 'Супы' }, update: {}, create: { title: 'Супы' } }),
prisma.category.upsert({ where: { title: 'Горячее' }, update: {}, create: { title: 'Горячее' } }),
prisma.category.upsert({ where: { title: 'Бургеры' }, update: {}, create: { title: 'Бургеры' } }),
prisma.category.upsert({ where: { title: 'Пицца' }, update: {}, create: { title: 'Пицца' } }),
prisma.category.upsert({ where: { title: 'Десерты' }, update: {}, create: { title: 'Десерты' } }),
prisma.category.upsert({ where: { title: 'Пиво' }, update: {}, create: { title: 'Пиво' } }),
prisma.category.upsert({ where: { title: 'Коктейли' }, update: {}, create: { title: 'Коктейли' } }),
prisma.category.upsert({ where: { title: 'Безалкогольные' }, update: {}, create: { title: 'Безалкогольные' } }),
prisma.category.upsert({ where: { title: 'Бизнес-ланч' }, update: {}, create: { title: 'Бизнес-ланч' } }),
]);
const [snacks, salads, soups, main, burgers, pizza, desserts, beer, cocktails, soft, lunch] = categories;
// ─── Pubs ────────────────────────────────────────────
const pub1 = await prisma.pub.upsert({
where: { address: 'ул. Карла Маркса, 26, Иркутск' },
update: {},
create: {
name: "Harat's Irish Pub Иркутск",
address: 'ул. Карла Маркса, 26, Иркутск',
phone: '+7 (3952) 48-68-76',
},
});
const pub2 = await prisma.pub.upsert({
where: { address: 'ул. Ленина, 15, Новосибирск' },
update: {},
create: {
name: "Harat's Irish Pub Новосибирск",
address: 'ул. Ленина, 15, Новосибирск',
phone: '+7 (383) 230-01-20',
},
});
const pub3 = await prisma.pub.upsert({
where: { address: 'Невский проспект, 88, Санкт-Петербург' },
update: {},
create: {
name: "Harat's Irish Pub Санкт-Петербург",
address: 'Невский проспект, 88, Санкт-Петербург',
phone: '+7 (812) 640-16-16',
},
});
const pub4 = await prisma.pub.upsert({
where: { address: 'пр. Мира, 81, Красноярск' },
update: {},
create: {
name: "Harat's Irish Pub Красноярск",
address: 'пр. Мира, 81, Красноярск',
phone: '+7 (391) 223-22-22',
},
});
// ─── Tables for Pub 1 (main pub with full layout) ───
const tablesData = [
// Main hall
{ pubId: pub1.id, number: 1, capacity: 2, posX: 80, posY: 100, width: 55, height: 55, shape: 'rect', zone: 'main' },
{ pubId: pub1.id, number: 2, capacity: 2, posX: 180, posY: 100, width: 55, height: 55, shape: 'rect', zone: 'main' },
{ pubId: pub1.id, number: 3, capacity: 4, posX: 280, posY: 100, width: 70, height: 55, shape: 'rect', zone: 'main' },
{ pubId: pub1.id, number: 4, capacity: 4, posX: 400, posY: 100, width: 70, height: 55, shape: 'rect', zone: 'main' },
{ pubId: pub1.id, number: 5, capacity: 6, posX: 80, posY: 220, width: 90, height: 60, shape: 'rect', zone: 'main' },
{ pubId: pub1.id, number: 6, capacity: 6, posX: 230, posY: 220, width: 90, height: 60, shape: 'rect', zone: 'main' },
{ pubId: pub1.id, number: 7, capacity: 4, posX: 380, posY: 220, width: 70, height: 55, shape: 'rect', zone: 'main' },
// Bar zone
{ pubId: pub1.id, number: 8, capacity: 2, posX: 560, posY: 80, width: 50, height: 50, shape: 'circle', zone: 'bar' },
{ pubId: pub1.id, number: 9, capacity: 2, posX: 560, posY: 160, width: 50, height: 50, shape: 'circle', zone: 'bar' },
{ pubId: pub1.id, number: 10, capacity: 2, posX: 560, posY: 240, width: 50, height: 50, shape: 'circle', zone: 'bar' },
// VIP
{ pubId: pub1.id, number: 11, capacity: 8, posX: 80, posY: 360, width: 120, height: 70, shape: 'rect', zone: 'vip' },
{ pubId: pub1.id, number: 12, capacity: 8, posX: 260, posY: 360, width: 120, height: 70, shape: 'rect', zone: 'vip' },
// Terrace
{ pubId: pub1.id, number: 13, capacity: 4, posX: 460, posY: 360, width: 65, height: 55, shape: 'rect', zone: 'terrace' },
{ pubId: pub1.id, number: 14, capacity: 4, posX: 560, posY: 360, width: 65, height: 55, shape: 'rect', zone: 'terrace' },
];
for (const t of tablesData) {
await prisma.table.upsert({
where: { pubId_number: { pubId: t.pubId, number: t.number } },
update: {},
create: t,
});
}
// Simple tables for pub2, pub3, and pub4
for (const pub of [pub2, pub3, pub4]) {
for (let i = 1; i <= 8; i++) {
await prisma.table.upsert({
where: { pubId_number: { pubId: pub.id, number: i } },
update: {},
create: {
pubId: pub.id,
number: i,
capacity: i <= 4 ? 2 : 4,
posX: 80 + ((i - 1) % 4) * 140,
posY: 100 + Math.floor((i - 1) / 4) * 140,
width: i <= 4 ? 55 : 70,
height: 55,
shape: i <= 4 ? 'circle' : 'rect',
zone: 'main',
},
});
}
}
// ─── Dishes ──────────────────────────────────────────
const dishesData = [
// Snacks
{ title: 'Ирландские колбаски', categoryId: snacks.id, grams: 250, price: 490, description: 'Ассорти из трёх видов домашних колбасок с горчичным соусом' },
{ title: 'Начос с соусами', categoryId: snacks.id, grams: 300, price: 420, description: 'Кукурузные чипсы с гуакамоле, сальсой и сырным соусом' },
{ title: 'Луковые кольца', categoryId: snacks.id, grams: 200, price: 350, description: 'Хрустящие луковые кольца в пивном кляре' },
{ title: 'Куриные крылья BBQ', categoryId: snacks.id, grams: 350, price: 520, description: 'Крылышки в фирменном соусе BBQ с сельдереем' },
{ title: 'Картофель фри', categoryId: snacks.id, grams: 250, price: 280, description: 'Золотистый картофель фри с паприкой' },
{ title: 'Сырные палочки', categoryId: snacks.id, grams: 200, price: 390, description: 'Моцарелла в панировке с томатным соусом' },
// Salads
{ title: 'Цезарь с курицей', categoryId: salads.id, grams: 280, price: 450, description: 'Классический салат с куриной грудкой, пармезаном и гренками' },
{ title: 'Греческий', categoryId: salads.id, grams: 260, price: 380, description: 'Свежие овощи с фетой и оливковым маслом' },
{ title: 'Коул-слоу', categoryId: salads.id, grams: 200, price: 250, description: 'Капустный салат с морковью в сливочной заправке' },
// Soups
{ title: 'Ирландское рагу', categoryId: soups.id, grams: 350, price: 420, description: 'Традиционное рагу из баранины с картофелем и овощами' },
{ title: 'Крем-суп из шампиньонов', categoryId: soups.id, grams: 300, price: 350, description: 'Нежный крем-суп с гренками и трюфельным маслом' },
{ title: 'Томатный суп', categoryId: soups.id, grams: 300, price: 320, description: 'Густой томатный суп с базиликом' },
// Main
{ title: 'Стейк Рибай', categoryId: main.id, grams: 300, price: 1490, description: 'Стейк рибай medium rare с овощами гриль' },
{ title: 'Fish & Chips', categoryId: main.id, grams: 350, price: 590, description: 'Треска в пивном кляре с картофелем фри и соусом тартар' },
{ title: 'Shepherd\'s Pie', categoryId: main.id, grams: 400, price: 520, description: 'Пастуший пирог с бараниной и картофельным пюре' },
{ title: 'Свиная рулька', categoryId: main.id, grams: 500, price: 890, description: 'Томлёная свиная рулька с квашеной капустой' },
// Burgers
{ title: 'Classic Burger', categoryId: burgers.id, grams: 350, price: 550, description: 'Говяжья котлета, чеддер, томат, салат, фирменный соус' },
{ title: 'Irish Burger', categoryId: burgers.id, grams: 400, price: 650, description: 'Двойная котлета, бекон, яйцо, жареный лук, BBQ соус' },
{ title: 'Chicken Burger', categoryId: burgers.id, grams: 330, price: 490, description: 'Куриная котлета, авокадо, руккола, медово-горчичный соус' },
// Pizza
{ title: 'Маргарита', categoryId: pizza.id, grams: 450, price: 490, description: 'Томатный соус, моцарелла, базилик' },
{ title: 'Пепперони', categoryId: pizza.id, grams: 480, price: 590, description: 'Томатный соус, моцарелла, пепперони' },
{ title: 'Четыре сыра', categoryId: pizza.id, grams: 460, price: 620, description: 'Моцарелла, горгонзола, пармезан, дор блю' },
// Desserts
{ title: 'Чизкейк', categoryId: desserts.id, grams: 180, price: 350, description: 'Классический нью-йоркский чизкейк' },
{ title: 'Брауни', categoryId: desserts.id, grams: 150, price: 320, description: 'Шоколадный брауни с ванильным мороженым' },
{ title: 'Apple Crumble', categoryId: desserts.id, grams: 200, price: 380, description: 'Тёплый яблочный крамбл с корицей и мороженым' },
// Beer
{ title: 'Guinness (0.5)', categoryId: beer.id, grams: 500, price: 450, description: 'Ирландский тёмный стаут' },
{ title: 'Kilkenny (0.5)', categoryId: beer.id, grams: 500, price: 420, description: 'Ирландский красный эль' },
{ title: 'Harp Lager (0.5)', categoryId: beer.id, grams: 500, price: 380, description: 'Ирландский лагер' },
{ title: 'Крафт IPA (0.4)', categoryId: beer.id, grams: 400, price: 390, description: 'Крафтовый индийский пэйл эль' },
// Cocktails
{ title: 'Irish Coffee', categoryId: cocktails.id, grams: 250, price: 450, description: 'Кофе, ирландский виски, сливки' },
{ title: 'Whiskey Sour', categoryId: cocktails.id, grams: 200, price: 520, description: 'Виски, лимонный сок, сахарный сироп, яичный белок' },
{ title: 'Mojito', categoryId: cocktails.id, grams: 300, price: 480, description: 'Ром, лайм, мята, содовая, сахар' },
{ title: 'Long Island', categoryId: cocktails.id, grams: 350, price: 590, description: 'Водка, ром, текила, джин, трипл сек, кола' },
// Soft drinks
{ title: 'Лимонад домашний', categoryId: soft.id, grams: 400, price: 250, description: 'Свежевыжатый лимонад с мятой' },
{ title: 'Морс клюквенный', categoryId: soft.id, grams: 400, price: 220, description: 'Домашний морс из свежей клюквы' },
{ title: 'Сок апельсиновый', categoryId: soft.id, grams: 300, price: 280, description: 'Свежевыжатый апельсиновый сок' },
// Lunch
{ title: 'Ланч: Борщ + хлеб', categoryId: lunch.id, grams: 350, price: 220, description: 'Домашний борщ с пампушкой' },
{ title: 'Ланч: Котлета с пюре', categoryId: lunch.id, grams: 300, price: 280, description: 'Куриная котлета с картофельным пюре и подливой' },
{ title: 'Ланч: Паста болоньезе', categoryId: lunch.id, grams: 300, price: 260, description: 'Спагетти с мясным соусом болоньезе' },
{ title: 'Ланч: Комплекс (суп + горячее + напиток)', categoryId: lunch.id, grams: null, price: 390, description: 'Суп дня, горячее дня, компот' },
];
const createdDishes: any[] = [];
for (const d of dishesData) {
const dish = await prisma.dish.upsert({
where: { id: createdDishes.length + 1 },
update: {},
create: d,
});
createdDishes.push(dish);
}
// ─── Menu (link dishes to pubs, except lunch category) ──
for (const pub of [pub1, pub2, pub3, pub4]) {
for (const dish of createdDishes) {
if (dish.categoryId === lunch.id) continue; // lunch is separate
await prisma.menu.upsert({
where: { pubId_dishId: { pubId: pub.id, dishId: dish.id } },
update: {},
create: { pubId: pub.id, dishId: dish.id },
});
}
}
// ─── Lunch Menu (current week) ─────────────────────
const now = new Date();
const monday = new Date(now);
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
const lunchDishes = createdDishes.filter(d => d.categoryId === lunch.id);
for (const pub of [pub1, pub2, pub3, pub4]) {
for (const dish of lunchDishes) {
await prisma.lunchMenu.upsert({
where: { pubId_dishId_weekStart: { pubId: pub.id, dishId: dish.id, weekStart: monday } },
update: {},
create: {
pubId: pub.id,
dishId: dish.id,
weekStart: monday,
weekEnd: sunday,
},
});
}
}
// ─── Billboard ─────────────────────────────────────
const billboards = [
{ title: 'Live Rock Night', artist: 'The Wild Rovers', pubId: pub1.id, datetime: new Date('2026-04-12T20:00:00'), poster: null },
{ title: 'Jazz Friday', artist: 'Smooth Quartet', pubId: pub1.id, datetime: new Date('2026-04-11T19:30:00'), poster: null },
{ title: 'Irish Folk Evening', artist: 'Celtic Storm', pubId: pub1.id, datetime: new Date('2026-04-13T18:00:00'), poster: null },
{ title: 'Stand-Up Comedy', artist: 'Андрей Бебуришвили', pubId: pub2.id, datetime: new Date('2026-04-12T19:00:00'), poster: null },
{ title: 'Karaoke Night', artist: 'DJ Shamrock', pubId: pub2.id, datetime: new Date('2026-04-10T21:00:00'), poster: null },
{ title: 'Acoustic Session', artist: 'Indie Folk Band', pubId: pub3.id, datetime: new Date('2026-04-11T20:00:00'), poster: null },
];
for (const b of billboards) {
await prisma.billboard.create({ data: b });
}
// ─── Admin User ────────────────────────────────────
const hashedPassword = await bcrypt.hash('admin123', 12);
await prisma.user.upsert({
where: { phone: '+79001234567' },
update: {},
create: {
phone: '+79001234567',
password: hashedPassword,
name: 'Администратор',
role: 'ADMIN',
email: 'admin@harats.com',
},
});
// Demo user
const userPassword = await bcrypt.hash('user123', 12);
await prisma.user.upsert({
where: { phone: '+79009876543' },
update: {},
create: {
phone: '+79009876543',
password: userPassword,
name: 'Иван',
surname: 'Петров',
role: 'USER',
email: 'ivan@example.com',
},
});
console.log('✅ Seed complete!');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+44
View File
@@ -0,0 +1,44 @@
import express from 'express';
import cors from 'cors';
import { errorHandler } from './middleware/errorHandler.js';
// Routes
import authRoutes from './modules/auth/auth.routes.js';
import userRoutes from './modules/user/user.routes.js';
import pubRoutes from './modules/pub/pub.routes.js';
import menuRoutes from './modules/menu/menu.routes.js';
import dishRoutes from './modules/dish/dish.routes.js';
import tableRoutes from './modules/table/table.routes.js';
import reservationRoutes from './modules/reservation/reservation.routes.js';
import billboardRoutes from './modules/billboard/billboard.routes.js';
import paymentRoutes from './modules/payment/payment.routes.js';
const app = express();
// Middleware
app.use(cors({
origin: ['http://localhost:5173', 'http://localhost:3000'],
credentials: true,
}));
app.use(express.json());
// ─── API Routes ──────────────────────────────────────
app.use('/api/auth', authRoutes);
app.use('/api/users', userRoutes);
app.use('/api/pubs', pubRoutes);
app.use('/api/pubs', menuRoutes); // /api/pubs/:id/menu, /api/pubs/:id/lunch-menu
app.use('/api/pubs', tableRoutes); // /api/pubs/:id/tables
app.use('/api/pubs', billboardRoutes); // /api/pubs/:id/billboard
app.use('/api/dishes', dishRoutes);
app.use('/api/reservations', reservationRoutes);
app.use('/api/payments', paymentRoutes);
// Health check
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Error handler
app.use(errorHandler);
export default app;
+20
View File
@@ -0,0 +1,20 @@
import dotenv from 'dotenv';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
export const env = {
PORT: parseInt(process.env.PORT || '3001', 10),
NODE_ENV: process.env.NODE_ENV || 'development',
DATABASE_URL: process.env.DATABASE_URL!,
JWT_SECRET: process.env.JWT_SECRET!,
JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET!,
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '15m',
JWT_REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: parseInt(process.env.SMTP_PORT || '587', 10),
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
};
+12
View File
@@ -0,0 +1,12 @@
import { env } from './config/env.js';
import app from './app.js';
app.listen(env.PORT, () => {
console.log(`
╔══════════════════════════════════════════╗
║ 🍀 Harat's Booking API ║
║ http://localhost:${env.PORT}
║ Mode: ${env.NODE_ENV.padEnd(28)}
╚══════════════════════════════════════════╝
`);
});
+25
View File
@@ -0,0 +1,25 @@
import { Request, Response, NextFunction } from 'express';
import { verifyAccessToken } from '../utils/jwt.js';
export interface AuthRequest extends Request {
userId?: number;
userRole?: string;
}
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
res.status(401).json({ success: false, error: 'Требуется авторизация' });
return;
}
try {
const token = header.split(' ')[1];
const payload = verifyAccessToken(token);
req.userId = payload.userId;
req.userRole = payload.role;
next();
} catch {
res.status(401).json({ success: false, error: 'Недействительный токен' });
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Request, Response, NextFunction } from 'express';
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
console.error('❌ Error:', err.message);
if (process.env.NODE_ENV === 'development') {
console.error(err.stack);
}
res.status(500).json({
success: false,
error: process.env.NODE_ENV === 'development' ? err.message : 'Внутренняя ошибка сервера',
});
}
+12
View File
@@ -0,0 +1,12 @@
import { Response, NextFunction } from 'express';
import { AuthRequest } from './auth.js';
export function roleMiddleware(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction): void => {
if (!req.userRole || !roles.includes(req.userRole)) {
res.status(403).json({ success: false, error: 'Недостаточно прав' });
return;
}
next();
};
}
+21
View File
@@ -0,0 +1,21 @@
import { Request, Response, NextFunction } from 'express';
import { ZodSchema, ZodError } from 'zod';
export function validate(schema: ZodSchema) {
return (req: Request, res: Response, next: NextFunction): void => {
try {
schema.parse(req.body);
next();
} catch (err) {
if (err instanceof ZodError) {
const errors = err.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
}));
res.status(400).json({ success: false, error: 'Ошибка валидации', details: errors });
return;
}
next(err);
}
};
}
@@ -0,0 +1,38 @@
import { Request, Response } from 'express';
import { AuthService } from './auth.service.js';
const authService = new AuthService();
export class AuthController {
async register(req: Request, res: Response) {
try {
const result = await authService.register(req.body);
res.status(201).json({ success: true, data: result });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
async login(req: Request, res: Response) {
try {
const result = await authService.login(req.body);
res.json({ success: true, data: result });
} catch (err: any) {
res.status(401).json({ success: false, error: err.message });
}
}
async refresh(req: Request, res: Response) {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
res.status(400).json({ success: false, error: 'Refresh token обязателен' });
return;
}
const result = await authService.refresh(refreshToken);
res.json({ success: true, data: result });
} catch (err: any) {
res.status(401).json({ success: false, error: 'Невалидный refresh token' });
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Router } from 'express';
import { AuthController } from './auth.controller.js';
import { validate } from '../../middleware/validate.js';
import { registerSchema, loginSchema } from './auth.schema.js';
const router = Router();
const controller = new AuthController();
router.post('/register', validate(registerSchema), (req, res) => controller.register(req, res));
router.post('/login', validate(loginSchema), (req, res) => controller.login(req, res));
router.post('/refresh', (req, res) => controller.refresh(req, res));
export default router;
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
export const registerSchema = z.object({
phone: z.string().min(10).max(20),
password: z.string().min(6).max(100),
name: z.string().min(1).max(100),
surname: z.string().max(100).optional(),
email: z.string().email().optional(),
});
export const loginSchema = z.object({
phone: z.string().min(10).max(20),
password: z.string().min(1),
});
+111
View File
@@ -0,0 +1,111 @@
import { PrismaClient } from '@prisma/client';
import { hashPassword, comparePassword } from '../../utils/hash.js';
import { generateAccessToken, generateRefreshToken, verifyRefreshToken } from '../../utils/jwt.js';
const prisma = new PrismaClient();
interface RegisterInput {
phone: string;
password: string;
name: string;
surname?: string;
email?: string;
}
interface LoginInput {
phone: string;
password: string;
}
export class AuthService {
private extractCardExpiry(cardToken?: string | null): string | null {
if (!cardToken) return null;
try {
const parsed = JSON.parse(cardToken);
return parsed.cardExpiry || null;
} catch {
return null;
}
}
async register(input: RegisterInput) {
const existing = await prisma.user.findUnique({ where: { phone: input.phone } });
if (existing) {
throw new Error('Пользователь с таким номером уже существует');
}
const hashed = await hashPassword(input.password);
const user = await prisma.user.create({
data: {
phone: input.phone,
password: hashed,
name: input.name,
surname: input.surname,
email: input.email,
},
});
const accessToken = generateAccessToken({ userId: user.id, role: user.role });
const refreshToken = generateRefreshToken({ userId: user.id, role: user.role });
return {
user: {
id: user.id,
phone: user.phone,
name: user.name,
surname: user.surname,
email: user.email,
role: user.role,
cardLastFour: user.cardLastFour,
cardExpiry: this.extractCardExpiry(user.cardToken),
createdAt: user.createdAt.toISOString(),
},
accessToken,
refreshToken,
};
}
async login(input: LoginInput) {
const user = await prisma.user.findUnique({ where: { phone: input.phone } });
if (!user) {
throw new Error('Неверный номер телефона или пароль');
}
const valid = await comparePassword(input.password, user.password);
if (!valid) {
throw new Error('Неверный номер телефона или пароль');
}
const accessToken = generateAccessToken({ userId: user.id, role: user.role });
const refreshToken = generateRefreshToken({ userId: user.id, role: user.role });
return {
user: {
id: user.id,
phone: user.phone,
name: user.name,
surname: user.surname,
email: user.email,
role: user.role,
cardLastFour: user.cardLastFour,
cardExpiry: this.extractCardExpiry(user.cardToken),
createdAt: user.createdAt.toISOString(),
},
accessToken,
refreshToken,
};
}
async refresh(token: string) {
const payload = verifyRefreshToken(token);
const user = await prisma.user.findUnique({ where: { id: payload.userId } });
if (!user) {
throw new Error('Пользователь не найден');
}
const accessToken = generateAccessToken({ userId: user.id, role: user.role });
const refreshToken = generateRefreshToken({ userId: user.id, role: user.role });
return { accessToken, refreshToken };
}
}
@@ -0,0 +1,12 @@
import { Request, Response } from 'express';
import { BillboardService } from './billboard.service.js';
const billboardService = new BillboardService();
export class BillboardController {
async getByPub(req: Request, res: Response) {
const pubId = Number(req.params.id);
const events = await billboardService.getByPub(pubId);
res.json({ success: true, data: events });
}
}
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { BillboardController } from './billboard.controller.js';
const router = Router();
const controller = new BillboardController();
// Nested under /api/pubs
router.get('/:id/billboard', (req, res) => controller.getByPub(req, res));
export default router;
@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class BillboardService {
async getByPub(pubId: number) {
return prisma.billboard.findMany({
where: {
pubId,
datetime: { gte: new Date() },
},
orderBy: { datetime: 'asc' },
});
}
}
@@ -0,0 +1,25 @@
import { Request, Response } from 'express';
import { DishService } from './dish.service.js';
const dishService = new DishService();
export class DishController {
async toggleAvailability(req: Request, res: Response) {
try {
const { availability } = req.body;
if (!['AVAILABLE', 'STOPPED'].includes(availability)) {
res.status(400).json({ success: false, error: 'Статус должен быть AVAILABLE или STOPPED' });
return;
}
const dish = await dishService.toggleAvailability(Number(req.params.id), availability);
res.json({ success: true, data: dish });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
async getAll(_req: Request, res: Response) {
const dishes = await dishService.getAll();
res.json({ success: true, data: dishes });
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Router } from 'express';
import { DishController } from './dish.controller.js';
import { authMiddleware } from '../../middleware/auth.js';
import { roleMiddleware } from '../../middleware/role.js';
const router = Router();
const controller = new DishController();
router.get('/', (req, res) => controller.getAll(req, res));
router.put('/:id/availability', authMiddleware, roleMiddleware('ADMIN'), (req, res) => controller.toggleAvailability(req, res));
export default router;
+22
View File
@@ -0,0 +1,22 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class DishService {
async toggleAvailability(dishId: number, availability: 'AVAILABLE' | 'STOPPED') {
const dish = await prisma.dish.update({
where: { id: dishId },
data: { availability },
include: { category: true },
});
return { ...dish, price: Number(dish.price) };
}
async getAll() {
const dishes = await prisma.dish.findMany({
include: { category: true },
orderBy: { title: 'asc' },
});
return dishes.map((d: any) => ({ ...d, price: Number(d.price) }));
}
}
@@ -0,0 +1,18 @@
import { Request, Response } from 'express';
import { MenuService } from './menu.service.js';
const menuService = new MenuService();
export class MenuController {
async getMenu(req: Request, res: Response) {
const pubId = Number(req.params.id);
const menu = await menuService.getMenuByPub(pubId);
res.json({ success: true, data: menu });
}
async getLunchMenu(req: Request, res: Response) {
const pubId = Number(req.params.id);
const menu = await menuService.getLunchMenu(pubId);
res.json({ success: true, data: menu });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Router } from 'express';
import { MenuController } from './menu.controller.js';
const router = Router();
const controller = new MenuController();
// These are nested under /api/pubs/:id
router.get('/:id/menu', (req, res) => controller.getMenu(req, res));
router.get('/:id/lunch-menu', (req, res) => controller.getLunchMenu(req, res));
export default router;
+62
View File
@@ -0,0 +1,62 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class MenuService {
async getMenuByPub(pubId: number) {
const items = await prisma.menu.findMany({
where: { pubId },
include: {
dish: {
include: { category: true },
},
},
});
// Group by category
const grouped: Record<string, any[]> = {};
for (const item of items) {
const cat = item.dish.category.title;
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push({
id: item.id,
pubId: item.pubId,
dishId: item.dishId,
dish: {
...item.dish,
price: Number(item.dish.price),
category: item.dish.category,
},
});
}
return grouped;
}
async getLunchMenu(pubId: number) {
const now = new Date();
const monday = new Date(now);
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7));
monday.setHours(0, 0, 0, 0);
const items = await prisma.lunchMenu.findMany({
where: {
pubId,
weekStart: { lte: now },
weekEnd: { gte: now },
},
include: {
dish: {
include: { category: true },
},
},
});
return items.map(item => ({
...item,
dish: {
...item.dish,
price: Number(item.dish.price),
},
}));
}
}
@@ -0,0 +1,28 @@
import { Request, Response } from 'express';
export class PaymentController {
async createPayment(req: Request, res: Response) {
// Mock payment — in production, integrate with YooKassa/Stripe
const { amount, reservationId } = req.body;
const mockPaymentId = `PAY-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
console.log(`💳 [MOCK PAYMENT] ${amount}₽ for reservation #${reservationId}`);
res.json({
success: true,
data: {
paymentId: mockPaymentId,
status: 'succeeded',
amount,
reservationId,
},
});
}
async webhook(req: Request, res: Response) {
// Mock webhook handler
console.log('📨 [MOCK WEBHOOK]', req.body);
res.json({ success: true });
}
}
@@ -0,0 +1,11 @@
import { Router } from 'express';
import { PaymentController } from './payment.controller.js';
import { authMiddleware } from '../../middleware/auth.js';
const router = Router();
const controller = new PaymentController();
router.post('/create', authMiddleware, (req, res) => controller.createPayment(req, res));
router.post('/webhook', (req, res) => controller.webhook(req, res));
export default router;
+20
View File
@@ -0,0 +1,20 @@
import { Request, Response } from 'express';
import { PubService } from './pub.service.js';
const pubService = new PubService();
export class PubController {
async getAll(_req: Request, res: Response) {
const pubs = await pubService.getAll();
res.json({ success: true, data: pubs });
}
async getById(req: Request, res: Response) {
try {
const pub = await pubService.getById(Number(req.params.id));
res.json({ success: true, data: pub });
} catch (err: any) {
res.status(404).json({ success: false, error: err.message });
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express';
import { PubController } from './pub.controller.js';
const router = Router();
const controller = new PubController();
router.get('/', (req, res) => controller.getAll(req, res));
router.get('/:id', (req, res) => controller.getById(req, res));
export default router;
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class PubService {
async getAll() {
return prisma.pub.findMany({ orderBy: { name: 'asc' } });
}
async getById(id: number) {
const pub = await prisma.pub.findUnique({ where: { id } });
if (!pub) throw new Error('Паб не найден');
return pub;
}
}
@@ -0,0 +1,50 @@
import { Response } from 'express';
import { AuthRequest } from '../../middleware/auth.js';
import { ReservationService } from './reservation.service.js';
const reservationService = new ReservationService();
export class ReservationController {
async create(req: AuthRequest, res: Response) {
try {
const reservation = await reservationService.create({
userId: req.userId!,
...req.body,
});
res.status(201).json({ success: true, data: reservation });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
async getMy(req: AuthRequest, res: Response) {
const reservations = await reservationService.getMyReservations(req.userId!);
res.json({ success: true, data: reservations });
}
async getAll(req: AuthRequest, res: Response) {
const pubId = req.query.pubId ? Number(req.query.pubId) : undefined;
const date = req.query.date as string | undefined;
const reservations = await reservationService.getAllReservations(pubId, date);
res.json({ success: true, data: reservations });
}
async cancel(req: AuthRequest, res: Response) {
try {
const reservation = await reservationService.cancel(Number(req.params.id), req.userId!);
res.json({ success: true, data: reservation });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
async updateStatus(req: AuthRequest, res: Response) {
try {
const { status } = req.body;
const reservation = await reservationService.updateStatus(Number(req.params.id), status);
res.json({ success: true, data: reservation });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
}
@@ -0,0 +1,17 @@
import { Router } from 'express';
import { ReservationController } from './reservation.controller.js';
import { authMiddleware } from '../../middleware/auth.js';
import { roleMiddleware } from '../../middleware/role.js';
import { validate } from '../../middleware/validate.js';
import { createReservationSchema } from './reservation.schema.js';
const router = Router();
const controller = new ReservationController();
router.post('/', authMiddleware, validate(createReservationSchema), (req, res) => controller.create(req, res));
router.get('/my', authMiddleware, (req, res) => controller.getMy(req, res));
router.get('/', authMiddleware, roleMiddleware('ADMIN'), (req, res) => controller.getAll(req, res));
router.put('/:id/cancel', authMiddleware, (req, res) => controller.cancel(req, res));
router.put('/:id/status', authMiddleware, roleMiddleware('ADMIN'), (req, res) => controller.updateStatus(req, res));
export default router;
@@ -0,0 +1,13 @@
import { z } from 'zod';
export const createReservationSchema = z.object({
pubId: z.number().int().positive(),
tableId: z.number().int().positive(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
time: z.string().regex(/^\d{2}:\d{2}$/),
numPeople: z.number().int().min(1).max(20),
dishes: z.array(z.object({
dishId: z.number().int().positive(),
quantity: z.number().int().min(1).max(50),
})).optional(),
});
@@ -0,0 +1,209 @@
import { PrismaClient, Prisma } from '@prisma/client';
import { sendReceiptEmail } from '../../utils/email.js';
const prisma = new PrismaClient();
interface CreateReservationInput {
userId: number;
pubId: number;
tableId: number;
date: string;
time: string;
numPeople: number;
dishes?: { dishId: number; quantity: number }[];
}
export class ReservationService {
async create(input: CreateReservationInput) {
// Check table availability
const existing = await prisma.reservation.findFirst({
where: {
tableId: input.tableId,
date: new Date(input.date),
time: new Date(`1970-01-01T${input.time}:00.000Z`),
status: { in: ['PENDING', 'CONFIRMED'] },
},
});
if (existing) {
throw new Error('Этот стол уже забронирован на указанное время');
}
// Calculate cost if dishes are present
let totalCost = new Prisma.Decimal(0);
let dishData: { dishId: number; quantity: number; priceAtOrder: Prisma.Decimal }[] = [];
if (input.dishes && input.dishes.length > 0) {
const dishIds = input.dishes.map(d => d.dishId);
const dishes = await prisma.dish.findMany({
where: { id: { in: dishIds }, availability: 'AVAILABLE' },
});
// Validate all dishes exist and are available
for (const orderDish of input.dishes) {
const dish = dishes.find(d => d.id === orderDish.dishId);
if (!dish) {
throw new Error(`Блюдо #${orderDish.dishId} недоступно или не найдено`);
}
// Check it's not a lunch dish
const isLunch = await prisma.lunchMenu.findFirst({
where: { dishId: orderDish.dishId },
});
if (isLunch) {
throw new Error(`Блюдо "${dish.title}" из ланч-меню не может быть в предзаказе`);
}
const lineTotal = dish.price.mul(orderDish.quantity);
totalCost = totalCost.add(lineTotal);
dishData.push({
dishId: orderDish.dishId,
quantity: orderDish.quantity,
priceAtOrder: dish.price,
});
}
}
const hasDishes = dishData.length > 0;
const prepaid = hasDishes ? totalCost.mul(new Prisma.Decimal(0.5)) : new Prisma.Decimal(0);
const reservation = await prisma.reservation.create({
data: {
userId: input.userId,
pubId: input.pubId,
tableId: input.tableId,
date: new Date(input.date),
time: new Date(`1970-01-01T${input.time}:00.000Z`),
numPeople: input.numPeople,
status: hasDishes ? 'PENDING' : 'CONFIRMED',
totalCost: hasDishes ? totalCost : null,
prepaid: hasDishes ? prepaid : null,
dishes: {
create: dishData,
},
},
include: {
pub: true,
table: true,
dishes: {
include: { dish: true },
},
user: true,
},
});
// Send receipt email if user has email
if (reservation.user.email) {
try {
await sendReceiptEmail({
to: reservation.user.email,
reservationId: reservation.id,
pubName: reservation.pub.name,
date: input.date,
time: input.time,
table: reservation.table.number,
dishes: reservation.dishes.map(d => ({
title: d.dish.title,
quantity: d.quantity,
price: Number(d.priceAtOrder),
})),
totalCost: Number(totalCost),
prepaid: Number(prepaid),
});
} catch (e) {
console.error('Failed to send email:', e);
}
}
return this.formatReservation(reservation);
}
async getMyReservations(userId: number) {
const reservations = await prisma.reservation.findMany({
where: { userId },
include: {
pub: true,
table: true,
dishes: { include: { dish: true } },
},
orderBy: { createdAt: 'desc' },
});
return reservations.map(this.formatReservation);
}
async getAllReservations(pubId?: number, date?: string) {
const where: any = {};
if (pubId) where.pubId = pubId;
if (date) where.date = new Date(date);
const reservations = await prisma.reservation.findMany({
where,
include: {
pub: true,
table: true,
user: { select: { id: true, name: true, surname: true, phone: true } },
dishes: { include: { dish: true } },
},
orderBy: { createdAt: 'desc' },
});
return reservations.map(this.formatReservation);
}
async cancel(reservationId: number, userId: number) {
const reservation = await prisma.reservation.findUnique({ where: { id: reservationId } });
if (!reservation) throw new Error('Бронь не найдена');
if (reservation.userId !== userId) throw new Error('Нет доступа к этой брони');
if (!['PENDING', 'CONFIRMED'].includes(reservation.status)) {
throw new Error('Эту бронь нельзя отменить');
}
const updated = await prisma.reservation.update({
where: { id: reservationId },
data: { status: 'CANCELLED' },
include: {
pub: true,
table: true,
dishes: { include: { dish: true } },
},
});
return this.formatReservation(updated);
}
async updateStatus(reservationId: number, status: 'PENDING' | 'CONFIRMED' | 'CANCELLED' | 'COMPLETED') {
const updated = await prisma.reservation.update({
where: { id: reservationId },
data: { status },
include: {
pub: true,
table: true,
dishes: { include: { dish: true } },
},
});
return this.formatReservation(updated);
}
private formatReservation(r: any) {
return {
id: r.id,
userId: r.userId,
user: r.user ? { id: r.user.id, name: r.user.name, surname: r.user.surname, phone: r.user.phone } : undefined,
pubId: r.pubId,
pub: r.pub,
tableId: r.tableId,
table: r.table,
date: r.date instanceof Date ? r.date.toISOString().split('T')[0] : r.date,
time: r.time instanceof Date ? r.time.toISOString().split('T')[1].substring(0, 5) : r.time,
numPeople: r.numPeople,
status: r.status,
totalCost: r.totalCost ? Number(r.totalCost) : null,
prepaid: r.prepaid ? Number(r.prepaid) : null,
dishes: r.dishes?.map((d: any) => ({
id: d.id,
dishId: d.dishId,
dish: d.dish ? { ...d.dish, price: Number(d.dish.price) } : undefined,
quantity: d.quantity,
priceAtOrder: Number(d.priceAtOrder),
})) || [],
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : r.createdAt,
};
}
}
@@ -0,0 +1,23 @@
import { Request, Response } from 'express';
import { TableService } from './table.service.js';
const tableService = new TableService();
export class TableController {
async getByPub(req: Request, res: Response) {
const pubId = Number(req.params.id);
const tables = await tableService.getByPub(pubId);
res.json({ success: true, data: tables });
}
async getAvailability(req: Request, res: Response) {
const pubId = Number(req.params.id);
const { date, time } = req.query as { date: string; time: string };
if (!date || !time) {
res.status(400).json({ success: false, error: 'Параметры date и time обязательны' });
return;
}
const tables = await tableService.getAvailability(pubId, date, time);
res.json({ success: true, data: tables });
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Router } from 'express';
import { TableController } from './table.controller.js';
const router = Router();
const controller = new TableController();
// Mounted under /api/pubs
router.get('/:id/tables', (req, res) => controller.getByPub(req, res));
router.get('/:id/tables/availability', (req, res) => controller.getAvailability(req, res));
export default router;
+36
View File
@@ -0,0 +1,36 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class TableService {
async getByPub(pubId: number) {
return prisma.table.findMany({
where: { pubId },
orderBy: { number: 'asc' },
});
}
async getAvailability(pubId: number, date: string, time: string) {
const tables = await prisma.table.findMany({
where: { pubId },
orderBy: { number: 'asc' },
});
const reservedTables = await prisma.reservation.findMany({
where: {
pubId,
date: new Date(date),
time: new Date(`1970-01-01T${time}:00.000Z`),
status: { in: ['PENDING', 'CONFIRMED'] },
},
select: { tableId: true },
});
const reservedIds = new Set(reservedTables.map(r => r.tableId));
return tables.map(table => ({
...table,
isAvailable: !reservedIds.has(table.id),
}));
}
}
@@ -0,0 +1,45 @@
import { Response } from 'express';
import { AuthRequest } from '../../middleware/auth.js';
import { UserService } from './user.service.js';
const userService = new UserService();
export class UserController {
async getProfile(req: AuthRequest, res: Response) {
try {
const profile = await userService.getProfile(req.userId!);
res.json({ success: true, data: profile });
} catch (err: any) {
res.status(404).json({ success: false, error: err.message });
}
}
async updateProfile(req: AuthRequest, res: Response) {
try {
const profile = await userService.updateProfile(req.userId!, req.body);
res.json({ success: true, data: profile });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
async linkCard(req: AuthRequest, res: Response) {
try {
const { cardNumber, cardExpiry, cardCvv, currentPassword } = req.body;
const profile = await userService.linkCard(req.userId!, { cardNumber, cardExpiry, cardCvv, currentPassword });
res.json({ success: true, data: profile });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
async unlinkCard(req: AuthRequest, res: Response) {
try {
const { currentPassword } = req.body;
const profile = await userService.unlinkCard(req.userId!, currentPassword);
res.json({ success: true, data: profile });
} catch (err: any) {
res.status(400).json({ success: false, error: err.message });
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Router } from 'express';
import { UserController } from './user.controller.js';
import { authMiddleware } from '../../middleware/auth.js';
const router = Router();
const controller = new UserController();
router.get('/me', authMiddleware, (req, res) => controller.getProfile(req, res));
router.put('/me', authMiddleware, (req, res) => controller.updateProfile(req, res));
router.put('/me/card', authMiddleware, (req, res) => controller.linkCard(req, res));
router.delete('/me/card', authMiddleware, (req, res) => controller.unlinkCard(req, res));
export default router;
+117
View File
@@ -0,0 +1,117 @@
import { PrismaClient } from '@prisma/client';
import { comparePassword } from '../../utils/hash.js';
const prisma = new PrismaClient();
export class UserService {
private parseCardToken(cardToken?: string | null): { cardExpiry?: string } {
if (!cardToken) return {};
try {
const parsed = JSON.parse(cardToken);
return { cardExpiry: parsed.cardExpiry };
} catch {
return {};
}
}
private sanitizeProfile(user: any) {
const { password, cardToken, ...profile } = user;
const cardMeta = this.parseCardToken(cardToken);
return {
...profile,
cardExpiry: cardMeta.cardExpiry || null,
};
}
async getProfile(userId: number) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new Error('Пользователь не найден');
return this.sanitizeProfile(user);
}
async updateProfile(
userId: number,
data: { name?: string; surname?: string; email?: string; phone?: string; password?: string }
) {
const updateData: any = {};
if (data.name) updateData.name = data.name;
if (data.surname !== undefined) updateData.surname = data.surname;
if (data.email !== undefined) updateData.email = data.email;
if (data.phone) updateData.phone = data.phone;
if (data.password) {
const { hashPassword } = await import('../../utils/hash.js');
updateData.password = await hashPassword(data.password);
}
try {
const user = await prisma.user.update({ where: { id: userId }, data: updateData });
return this.sanitizeProfile(user);
} catch (err: any) {
if (err?.code === 'P2002') {
throw new Error('Пользователь с таким телефоном уже существует');
}
throw err;
}
}
async linkCard(
userId: number,
payload: { cardNumber: string; cardExpiry: string; cardCvv: string; currentPassword?: string }
) {
const userBeforeUpdate = await prisma.user.findUnique({ where: { id: userId } });
if (!userBeforeUpdate) throw new Error('Пользователь не найден');
const cardDigits = payload.cardNumber.replace(/\D/g, '');
if (cardDigits.length !== 16) {
throw new Error('Номер карты должен содержать 16 цифр');
}
if (!/^\d{2}\/\d{2}$/.test(payload.cardExpiry)) {
throw new Error('Срок действия должен быть в формате ММ/ГГ');
}
if (!/^\d{3,4}$/.test(payload.cardCvv)) {
throw new Error('CVV/CVC должен содержать 3 или 4 цифры');
}
const hasLinkedCard = Boolean(userBeforeUpdate.cardLastFour);
if (hasLinkedCard) {
if (!payload.currentPassword) {
throw new Error('Для изменения карты требуется пароль от аккаунта');
}
const isValidPassword = await comparePassword(payload.currentPassword, userBeforeUpdate.password);
if (!isValidPassword) {
throw new Error('Неверный пароль');
}
}
const cardToken = JSON.stringify({
cardNumber: cardDigits,
cardExpiry: payload.cardExpiry,
cardCvv: payload.cardCvv,
updatedAt: new Date().toISOString(),
});
const user = await prisma.user.update({
where: { id: userId },
data: { cardLastFour: cardDigits.slice(-4), cardToken },
});
return this.sanitizeProfile(user);
}
async unlinkCard(userId: number, currentPassword: string) {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new Error('Пользователь не найден');
if (!user.cardLastFour) throw new Error('Карта не привязана');
if (!currentPassword) throw new Error('Для удаления карты требуется пароль от аккаунта');
const isValidPassword = await comparePassword(currentPassword, user.password);
if (!isValidPassword) {
throw new Error('Неверный пароль');
}
const updated = await prisma.user.update({
where: { id: userId },
data: { cardLastFour: null, cardToken: null },
});
return this.sanitizeProfile(updated);
}
}
+71
View File
@@ -0,0 +1,71 @@
import nodemailer from 'nodemailer';
import { env } from '../config/env.js';
const transporter = env.SMTP_USER
? nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: false,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASS,
},
})
: null;
interface EmailReceipt {
to: string;
reservationId: number;
pubName: string;
date: string;
time: string;
table: number;
dishes: { title: string; quantity: number; price: number }[];
totalCost: number;
prepaid: number;
}
export async function sendReceiptEmail(data: EmailReceipt): Promise<void> {
if (!transporter) {
console.log(`📧 [MOCK EMAIL] Receipt for reservation #${data.reservationId}${data.to}`);
console.log(` Pub: ${data.pubName}, Date: ${data.date}, Time: ${data.time}, Table: ${data.table}`);
if (data.dishes.length) {
console.log(` Dishes: ${data.dishes.map(d => `${d.title} x${d.quantity}`).join(', ')}`);
console.log(` Total: ${data.totalCost}₽, Prepaid: ${data.prepaid}`);
}
return;
}
const dishRows = data.dishes
.map(d => `<tr><td>${d.title}</td><td>${d.quantity}</td><td>${d.price}₽</td></tr>`)
.join('');
const html = `
<div style="font-family:Inter,sans-serif;max-width:600px;margin:0 auto;background:#1a1a1a;color:#f0efe8;padding:32px;border-radius:12px;">
<h1 style="color:#c8a951;text-align:center;">Harat's Irish Pub</h1>
<h2 style="color:#2eaa66;">Бронирование подтверждено</h2>
<p><strong>Номер брони:</strong> #${data.reservationId}</p>
<p><strong>Паб:</strong> ${data.pubName}</p>
<p><strong>Дата:</strong> ${data.date}</p>
<p><strong>Время:</strong> ${data.time}</p>
<p><strong>Стол:</strong> №${data.table}</p>
${data.dishes.length ? `
<h3 style="color:#c8a951;">Предзаказ блюд</h3>
<table style="width:100%;border-collapse:collapse;color:#f0efe8;">
<tr style="border-bottom:1px solid #333;"><th style="text-align:left;padding:8px;">Блюдо</th><th>Кол-во</th><th>Цена</th></tr>
${dishRows}
<tr style="border-top:2px solid #c8a951;"><td colspan="2" style="padding:8px;"><strong>Итого:</strong></td><td><strong>${data.totalCost}₽</strong></td></tr>
<tr><td colspan="2" style="padding:8px;"><strong>Предоплата:</strong></td><td><strong>${data.prepaid}₽</strong></td></tr>
</table>
` : ''}
<p style="color:#a09e94;margin-top:24px;font-size:12px;">Это автоматическое письмо. По вопросам: 8 (800) 775-03-39</p>
</div>
`;
await transporter.sendMail({
from: `"Harat's Booking" <${env.SMTP_USER}>`,
to: data.to,
subject: `Бронь #${data.reservationId} — Harat's Irish Pub`,
html,
});
}
+11
View File
@@ -0,0 +1,11 @@
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function comparePassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
+23
View File
@@ -0,0 +1,23 @@
import jwt from 'jsonwebtoken';
import { env } from '../config/env.js';
interface TokenPayload {
userId: number;
role: string;
}
export function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, env.JWT_SECRET, { expiresIn: env.JWT_EXPIRES_IN });
}
export function generateRefreshToken(payload: TokenPayload): string {
return jwt.sign(payload, env.JWT_REFRESH_SECRET, { expiresIn: env.JWT_REFRESH_EXPIRES_IN });
}
export function verifyAccessToken(token: string): TokenPayload {
return jwt.verify(token, env.JWT_SECRET) as TokenPayload;
}
export function verifyRefreshToken(token: string): TokenPayload {
return jwt.verify(token, env.JWT_REFRESH_SECRET) as TokenPayload;
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src", "prisma/seed.ts"]
}