Initial commit: Harat's Booking monorepo with deploy setup
This commit is contained in:
@@ -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"
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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)}║
|
||||
╚══════════════════════════════════════════╝
|
||||
`);
|
||||
});
|
||||
@@ -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: 'Недействительный токен' });
|
||||
}
|
||||
}
|
||||
@@ -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 : 'Внутренняя ошибка сервера',
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user