Skip to content

Commit eb4fe4b

Browse files
authored
Merge pull request Discorverly#194 from blegodwin/rest-crud
add restaurant module with CRUD operations and validation
2 parents 88bffdd + 4bb7144 commit eb4fe4b

8 files changed

Lines changed: 264 additions & 0 deletions

File tree

apps/api/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { rateLimit } from 'express-rate-limit';
44
import helmet from 'helmet';
55
import morgan from 'morgan';
66
import { authRouter } from './modules/auth/routes/auth.routes.js';
7+
import { restaurantRouter } from './modules/restaurants/routes/restaurant.routes.js';
78
import { uploadsRouter } from './modules/uploads/uploads.routes.js';
89
import { errorHandler } from './shared/middleware/error-handler.js';
910
import { notFoundHandler } from './shared/middleware/not-found.js';
@@ -26,6 +27,7 @@ export function createApp(): Express {
2627
});
2728

2829
app.use('/api/auth', authRouter);
30+
app.use('/api/restaurants', restaurantRouter);
2931
app.use('/api/uploads', uploadsRouter);
3032

3133
app.use(notFoundHandler);
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { NextFunction, Request, Response } from 'express';
2+
import { AppError } from '../../../shared/errors/app-error.js';
3+
import type { AuthenticatedRequest } from '../../../shared/types/express.js';
4+
import { restaurantService } from '../services/restaurant.service.js';
5+
import { createRestaurantSchema, updateRestaurantSchema } from '../validators/restaurant.validators.js';
6+
7+
export const restaurantController = {
8+
async create(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
9+
try {
10+
if (!req.user) throw AppError.unauthorized();
11+
const data = createRestaurantSchema.parse(req.body);
12+
const restaurant = await restaurantService.create(req.user.id, data);
13+
res.status(201).json({ data: restaurant });
14+
} catch (error) {
15+
next(error);
16+
}
17+
},
18+
19+
async list(req: Request, res: Response, next: NextFunction): Promise<void> {
20+
try {
21+
const page = Math.max(1, Number(req.query['page']) || 1);
22+
const limit = Math.min(50, Math.max(1, Number(req.query['limit']) || 20));
23+
const { docs, total } = await restaurantService.list(page, limit);
24+
res.status(200).json({ data: docs, meta: { page, limit, total } });
25+
} catch (error) {
26+
next(error);
27+
}
28+
},
29+
30+
async getById(req: Request, res: Response, next: NextFunction): Promise<void> {
31+
try {
32+
const restaurant = await restaurantService.getById(req.params['id']!);
33+
res.status(200).json({ data: restaurant });
34+
} catch (error) {
35+
next(error);
36+
}
37+
},
38+
39+
async getOwn(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
40+
try {
41+
if (!req.user) throw AppError.unauthorized();
42+
const restaurant = await restaurantService.getByOwner(req.user.id);
43+
res.status(200).json({ data: restaurant });
44+
} catch (error) {
45+
next(error);
46+
}
47+
},
48+
49+
async update(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
50+
try {
51+
if (!req.user) throw AppError.unauthorized();
52+
const data = updateRestaurantSchema.parse(req.body);
53+
const restaurant = await restaurantService.update(req.params['id']!, req.user.id, data);
54+
res.status(200).json({ data: restaurant });
55+
} catch (error) {
56+
next(error);
57+
}
58+
},
59+
60+
async delete(req: AuthenticatedRequest, res: Response, next: NextFunction): Promise<void> {
61+
try {
62+
if (!req.user) throw AppError.unauthorized();
63+
await restaurantService.delete(req.params['id']!, req.user.id);
64+
res.status(204).send();
65+
} catch (error) {
66+
next(error);
67+
}
68+
},
69+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Schema, model, type HydratedDocument, type InferSchemaType } from 'mongoose';
2+
3+
const coordinatesSchema = new Schema(
4+
{
5+
lat: { type: Number, required: true },
6+
lng: { type: Number, required: true },
7+
},
8+
{ _id: false },
9+
);
10+
11+
const addressSchema = new Schema(
12+
{
13+
street: { type: String, trim: true },
14+
city: { type: String, required: true, trim: true },
15+
country: { type: String, required: true, trim: true },
16+
coordinates: { type: coordinatesSchema },
17+
},
18+
{ _id: false },
19+
);
20+
21+
const restaurantSchema = new Schema(
22+
{
23+
ownerId: { type: Schema.Types.ObjectId, required: true, ref: 'User', unique: true },
24+
name: { type: String, required: true, trim: true, minlength: 2, maxlength: 100 },
25+
description: { type: String, trim: true, maxlength: 500, default: '' },
26+
address: { type: addressSchema, required: true },
27+
cuisineTags: { type: [String], default: [] },
28+
logoUrl: { type: String, default: null },
29+
coverImageUrl: { type: String, default: null },
30+
isActive: { type: Boolean, default: true },
31+
},
32+
{ timestamps: true },
33+
);
34+
35+
export type RestaurantAttributes = InferSchemaType<typeof restaurantSchema>;
36+
export type RestaurantDocument = HydratedDocument<RestaurantAttributes>;
37+
38+
export const RestaurantModel = model('Restaurant', restaurantSchema);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { RestaurantModel, type RestaurantDocument } from './restaurant.model.js';
2+
import type { CreateRestaurantInput, UpdateRestaurantInput } from '../types/restaurant.types.js';
3+
4+
export const restaurantRepository = {
5+
async create(ownerId: string, data: CreateRestaurantInput): Promise<RestaurantDocument> {
6+
return RestaurantModel.create({ ...data, ownerId });
7+
},
8+
9+
async findById(id: string): Promise<RestaurantDocument | null> {
10+
return RestaurantModel.findById(id);
11+
},
12+
13+
async findActiveById(id: string): Promise<RestaurantDocument | null> {
14+
return RestaurantModel.findOne({ _id: id, isActive: true });
15+
},
16+
17+
async findByOwnerId(ownerId: string): Promise<RestaurantDocument | null> {
18+
return RestaurantModel.findOne({ ownerId, isActive: true });
19+
},
20+
21+
async listActive(page: number, limit: number): Promise<{ docs: RestaurantDocument[]; total: number }> {
22+
const [docs, total] = await Promise.all([
23+
RestaurantModel.find({ isActive: true })
24+
.sort({ createdAt: -1 })
25+
.skip((page - 1) * limit)
26+
.limit(limit),
27+
RestaurantModel.countDocuments({ isActive: true }),
28+
]);
29+
return { docs, total };
30+
},
31+
32+
async updateById(id: string, data: UpdateRestaurantInput): Promise<RestaurantDocument | null> {
33+
return RestaurantModel.findByIdAndUpdate(id, { $set: data }, { new: true, runValidators: true });
34+
},
35+
36+
async softDelete(id: string): Promise<RestaurantDocument | null> {
37+
return RestaurantModel.findByIdAndUpdate(id, { $set: { isActive: false } }, { new: true });
38+
},
39+
};
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Router } from 'express';
2+
import { authenticate } from '../../auth/middleware/authenticate.js';
3+
import { restaurantController } from '../controllers/restaurant.controller.js';
4+
5+
export const restaurantRouter = Router();
6+
7+
restaurantRouter.post('/', authenticate, restaurantController.create);
8+
restaurantRouter.get('/', restaurantController.list);
9+
restaurantRouter.get('/me', authenticate, restaurantController.getOwn);
10+
restaurantRouter.get('/:id', restaurantController.getById);
11+
restaurantRouter.patch('/:id', authenticate, restaurantController.update);
12+
restaurantRouter.delete('/:id', authenticate, restaurantController.delete);
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { AppError } from '../../../shared/errors/app-error.js';
2+
import { restaurantRepository } from '../repositories/restaurant.repository.js';
3+
import type { CreateRestaurantInput, UpdateRestaurantInput } from '../types/restaurant.types.js';
4+
5+
export const restaurantService = {
6+
async create(ownerId: string, data: CreateRestaurantInput) {
7+
const existing = await restaurantRepository.findByOwnerId(ownerId);
8+
if (existing) throw AppError.conflict('You already own a restaurant');
9+
return restaurantRepository.create(ownerId, data);
10+
},
11+
12+
async getById(id: string) {
13+
const restaurant = await restaurantRepository.findActiveById(id);
14+
if (!restaurant) throw AppError.notFound('Restaurant not found');
15+
return restaurant;
16+
},
17+
18+
async getByOwner(ownerId: string) {
19+
const restaurant = await restaurantRepository.findByOwnerId(ownerId);
20+
if (!restaurant) throw AppError.notFound('You do not own a restaurant');
21+
return restaurant;
22+
},
23+
24+
async list(page: number, limit: number) {
25+
return restaurantRepository.listActive(page, limit);
26+
},
27+
28+
async update(id: string, ownerId: string, data: UpdateRestaurantInput) {
29+
const restaurant = await restaurantRepository.findById(id);
30+
if (!restaurant) throw AppError.notFound('Restaurant not found');
31+
if (restaurant.ownerId.toString() !== ownerId) {
32+
throw new AppError('You are not the owner of this restaurant', 403);
33+
}
34+
return restaurantRepository.updateById(id, data);
35+
},
36+
37+
async delete(id: string, ownerId: string) {
38+
const restaurant = await restaurantRepository.findById(id);
39+
if (!restaurant) throw AppError.notFound('Restaurant not found');
40+
if (restaurant.ownerId.toString() !== ownerId) {
41+
throw new AppError('You are not the owner of this restaurant', 403);
42+
}
43+
return restaurantRepository.softDelete(id);
44+
},
45+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export interface RestaurantAddress {
2+
street?: string;
3+
city: string;
4+
country: string;
5+
coordinates?: {
6+
lat: number;
7+
lng: number;
8+
};
9+
}
10+
11+
export interface CreateRestaurantInput {
12+
name: string;
13+
description?: string;
14+
address: RestaurantAddress;
15+
cuisineTags?: string[];
16+
logoUrl?: string | null;
17+
coverImageUrl?: string | null;
18+
}
19+
20+
export interface UpdateRestaurantInput {
21+
name?: string;
22+
description?: string;
23+
address?: Partial<RestaurantAddress>;
24+
cuisineTags?: string[];
25+
logoUrl?: string | null;
26+
coverImageUrl?: string | null;
27+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { z } from 'zod';
2+
import { CUISINE_TAGS } from '@discoverly/shared';
3+
4+
const coordinatesSchema = z.object({
5+
lat: z.number().finite(),
6+
lng: z.number().finite(),
7+
});
8+
9+
const addressSchema = z.object({
10+
street: z.string().trim().optional(),
11+
city: z.string().trim().min(1, 'City is required'),
12+
country: z.string().trim().min(1, 'Country is required'),
13+
coordinates: coordinatesSchema.optional(),
14+
});
15+
16+
export const createRestaurantSchema = z.object({
17+
name: z.string().trim().min(2).max(100),
18+
description: z.string().trim().max(500).optional(),
19+
address: addressSchema,
20+
cuisineTags: z.array(z.enum(CUISINE_TAGS)).max(5).optional(),
21+
logoUrl: z.string().url().nullable().optional(),
22+
coverImageUrl: z.string().url().nullable().optional(),
23+
});
24+
25+
export const updateRestaurantSchema = z.object({
26+
name: z.string().trim().min(2).max(100).optional(),
27+
description: z.string().trim().max(500).optional(),
28+
address: addressSchema.partial().optional(),
29+
cuisineTags: z.array(z.enum(CUISINE_TAGS)).max(5).optional(),
30+
logoUrl: z.string().url().nullable().optional(),
31+
coverImageUrl: z.string().url().nullable().optional(),
32+
});

0 commit comments

Comments
 (0)