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
+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;
}