This commit is contained in:
parent
7258d283ed
commit
31f5bdc254
42 changed files with 21523 additions and 1040 deletions
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
#### Backend:
|
||||
|
||||
- Node.js & Express
|
||||
- NestJS
|
||||
- PostgreSQL
|
||||
- Prisma
|
||||
|
||||
|
|
|
|||
58
backend/.gitignore
vendored
58
backend/.gitignore
vendored
|
|
@ -1,2 +1,56 @@
|
|||
node_modules
|
||||
scratch
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
|
|
|||
4
backend/.prettierrc
Normal file
4
backend/.prettierrc
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
|
|
@ -21,6 +21,8 @@ RUN npm run build
|
|||
FROM base
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
RUN mkdir /app/logs
|
||||
RUN touch /app/logs/app.log
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
|
|
|||
98
backend/README.md
Normal file
98
backend/README.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ npm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ npm run start
|
||||
|
||||
# watch mode
|
||||
$ npm run start:dev
|
||||
|
||||
# production mode
|
||||
$ npm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ npm run test
|
||||
|
||||
# e2e tests
|
||||
$ npm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ npm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
||||
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ npm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
## Resources
|
||||
|
||||
Check out a few resources that may come in handy when working with NestJS:
|
||||
|
||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
||||
|
||||
## Support
|
||||
|
||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
||||
|
||||
## Stay in touch
|
||||
|
||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
||||
|
||||
## License
|
||||
|
||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
||||
35
backend/eslint.config.mjs
Normal file
35
backend/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// @ts-check
|
||||
import eslint from '@eslint/js';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
8
backend/nest-cli.json
Normal file
8
backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
21043
backend/package-lock.json
generated
21043
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,33 +1,87 @@
|
|||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon ./src/index.ts",
|
||||
"build": "tsc",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prod": "node ./dist/index.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"dev": "nest start --watch",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prod": "node ./dist/src/main.js",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.1.12",
|
||||
"@nestjs/core": "^11.1.12",
|
||||
"@nestjs/mapped-types": "2.1.0",
|
||||
"@nestjs/platform-express": "^11.1.12",
|
||||
"@prisma/adapter-pg": "7.2.0",
|
||||
"@prisma/client": "7.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.0.1",
|
||||
"express": "^5.1.0",
|
||||
"pg": "^8.17.2",
|
||||
"prisma": "7.2.0"
|
||||
"dotenv": "^17.2.3",
|
||||
"nestjs-pino": "^4.5.0",
|
||||
"pg": "^8.17.1",
|
||||
"pino-http": "^11.0.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "7.2.0",
|
||||
"prisma-cli": "^1.0.9",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.2.1",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@nestjs/cli": "^11.0.16",
|
||||
"@nestjs/schematics": "^11.0.9",
|
||||
"@nestjs/testing": "^11.1.12",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^22.19.7",
|
||||
"@types/pg": "^8.16.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"globals": "^16.5.0",
|
||||
"jest": "^30.2.0",
|
||||
"prettier": "^3.8.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-loader": "^9.5.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.2"
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.53.1"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
22
backend/src/app.controller.spec.ts
Normal file
22
backend/src/app.controller.spec.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
backend/src/app.controller.ts
Normal file
12
backend/src/app.controller.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
36
backend/src/app.module.ts
Normal file
36
backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { RecipesModule } from './recipes/recipes.module';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { createWriteStream } from 'fs';
|
||||
|
||||
const logStream = createWriteStream('/app/logs/app.log', { flags: 'a' });
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
RecipesModule,
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp:
|
||||
process.env.NODE_ENV === 'dev'
|
||||
? {
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
},
|
||||
},
|
||||
autoLogging: false,
|
||||
}
|
||||
: {
|
||||
stream: logStream,
|
||||
autoLogging: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
backend/src/app.service.ts
Normal file
8
backend/src/app.service.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
import { Request, Response } from "express";
|
||||
import RecipeModel from "../models/recipeModel";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
let model: RecipeModel;
|
||||
|
||||
export const initializeController = (prisma: PrismaClient) => {
|
||||
model = new RecipeModel(prisma);
|
||||
};
|
||||
|
||||
export const test = async (req: Request, res: Response): Promise<void> => {
|
||||
console.log("test");
|
||||
res.json({ env: process.env.NODE_ENV });
|
||||
};
|
||||
|
||||
export const getAllRecipes = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const recipes = await model.getAllRecipes();
|
||||
res.json(recipes);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
msg: "Failed to fetch all recipes",
|
||||
source: "recipeController",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const getRecipeById = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
try {
|
||||
const recipe = await model.findById(id);
|
||||
if (recipe) {
|
||||
res.json(recipe);
|
||||
} else {
|
||||
res.status(404).json({ msg: "Recipe not found" });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
msg: "Failed to fetch recipe",
|
||||
source: "recipeController",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const addRecipe = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
console.log(req.body);
|
||||
const createdRecipe = await model.addRecipe(req.body);
|
||||
res.status(201).json(createdRecipe);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
msg: "Failed to add recipe",
|
||||
source: "recipeController",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateRecipe = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
console.log(req.body);
|
||||
const id = parseInt(req.params.id, 10);
|
||||
try {
|
||||
const updatedRecipe = await model.updateRecipe(req.body, id);
|
||||
res.status(201).json(updatedRecipe);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
msg: "Failed to add recipe",
|
||||
source: "recipeController",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const setStars = async (req: Request, res: Response): Promise<void> => {
|
||||
const id = parseInt(req.body.id, 10);
|
||||
const stars = parseInt(req.body.stars, 10);
|
||||
try {
|
||||
const updatedRecipe = await model.setStars(id, stars);
|
||||
res.status(202).json(updatedRecipe);
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
msg: "Failed to set stars",
|
||||
source: "recipeController",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteRecipe = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> => {
|
||||
const id = parseInt(req.body.id, 10);
|
||||
try {
|
||||
await model.deleteRecipe(id);
|
||||
res.json({ success: "true" });
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
msg: "Failed to delete recipe",
|
||||
source: "recipeController",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
test,
|
||||
getAllRecipes,
|
||||
getRecipeById,
|
||||
addRecipe,
|
||||
updateRecipe,
|
||||
setStars,
|
||||
deleteRecipe,
|
||||
};
|
||||
9
backend/src/database/database.module.ts
Normal file
9
backend/src/database/database.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Global, Module } from '@nestjs/common';
|
||||
import { DatabaseService } from './database.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [DatabaseService],
|
||||
exports: [DatabaseService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
18
backend/src/database/database.service.spec.ts
Normal file
18
backend/src/database/database.service.spec.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DatabaseService } from './database.service';
|
||||
|
||||
describe('DatabaseService', () => {
|
||||
let service: DatabaseService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [DatabaseService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<DatabaseService>(DatabaseService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
17
backend/src/database/database.service.ts
Normal file
17
backend/src/database/database.service.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseService extends PrismaClient implements OnModuleInit {
|
||||
constructor() {
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL as string,
|
||||
});
|
||||
super({ adapter });
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import "dotenv/config";
|
||||
import express, { Express } from "express";
|
||||
import cors from "cors";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import appRoutes from "./routes/appRoutes";
|
||||
import { initializeController } from "./controllers/recipeController";
|
||||
|
||||
const app: Express = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
|
||||
initializeController(prisma);
|
||||
|
||||
function setupMiddleware(app: Express) {
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use("/api", appRoutes);
|
||||
}
|
||||
|
||||
setupMiddleware(app);
|
||||
|
||||
async function startServer() {
|
||||
try {
|
||||
app.listen(port, () => {
|
||||
console.log(`Server is running on http://localhost:${port}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error starting the server:", error);
|
||||
}
|
||||
}
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
try {
|
||||
await prisma.$disconnect();
|
||||
console.log("Prisma client disconnected");
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("Error disconnecting Prisma client:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
startServer();
|
||||
|
||||
module.exports = { app, prisma };
|
||||
15
backend/src/main.ts
Normal file
15
backend/src/main.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
app.useLogger(app.get(Logger));
|
||||
|
||||
if (process.env.NODE_ENV === 'dev') {
|
||||
app.enableCors();
|
||||
}
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
import { PrismaClient } from "@prisma/client";
|
||||
import Logger from "../utils/logger";
|
||||
|
||||
const logger = new Logger();
|
||||
|
||||
class RecipeModel {
|
||||
private prisma: PrismaClient;
|
||||
|
||||
constructor(prisma: PrismaClient) {
|
||||
this.prisma = prisma;
|
||||
}
|
||||
|
||||
async getAllRecipes(): Promise<any[]> {
|
||||
try {
|
||||
logger.info("index page view");
|
||||
return await this.prisma.recipes.findMany();
|
||||
} catch (err) {
|
||||
console.error("Error fetching all recipes:", err);
|
||||
throw new Error(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<any | null> {
|
||||
try {
|
||||
const recipe = await this.prisma.recipes.findUnique({
|
||||
where: { id },
|
||||
include: { recipeSteps: true, recipeIngredients: true },
|
||||
});
|
||||
if (!recipe) {
|
||||
logger.warn(`Recipe with id ${id} cannot be found`);
|
||||
return null;
|
||||
}
|
||||
const data = {
|
||||
details: {
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
author: recipe.author,
|
||||
cuisine: recipe.cuisine,
|
||||
stars: recipe.stars,
|
||||
prep_minutes: recipe.prep_minutes,
|
||||
cook_minutes: recipe.cook_minutes,
|
||||
},
|
||||
ingredients: recipe.recipeIngredients.map(
|
||||
(ing: { raw: string | null }) => ing.raw,
|
||||
),
|
||||
steps: recipe.recipeSteps.map(
|
||||
(step: {
|
||||
step_number: number | null;
|
||||
instruction: string | null;
|
||||
}) => ({
|
||||
step_number: step.step_number ?? 0, // Default to 0 if null
|
||||
instruction: step.instruction ?? "", // Default to empty string if null
|
||||
}),
|
||||
),
|
||||
};
|
||||
logger.info("recipe page view", {
|
||||
recipe_id: data.details.id,
|
||||
recipe_name: data.details.name,
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.log("Error finding recipe:", err);
|
||||
logger.error("Error finding recipe", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
throw new Error(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
async addRecipe(recipeData: {
|
||||
details: {
|
||||
name: string;
|
||||
author: string;
|
||||
cuisine: string;
|
||||
stars: number;
|
||||
prep_minutes: number;
|
||||
cook_minutes: number;
|
||||
};
|
||||
ingredients: string[];
|
||||
steps: { [key: string]: string };
|
||||
}): Promise<any> {
|
||||
const name = recipeData.details.name;
|
||||
const author = recipeData.details.author;
|
||||
const cuisine = recipeData.details.cuisine;
|
||||
const stars = recipeData.details.stars;
|
||||
const prep_minutes = recipeData.details.prep_minutes;
|
||||
const cook_minutes = recipeData.details.cook_minutes;
|
||||
const ingredients = recipeData.ingredients;
|
||||
const steps = recipeData.steps;
|
||||
try {
|
||||
const createdRecipe = await this.prisma.recipes.create({
|
||||
data: {
|
||||
name,
|
||||
author,
|
||||
cuisine,
|
||||
prep_minutes,
|
||||
cook_minutes,
|
||||
stars,
|
||||
recipeIngredients: {
|
||||
create: ingredients.map((ing) => ({ raw: ing })),
|
||||
},
|
||||
recipeSteps: {
|
||||
create: steps,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("New recipe created", {
|
||||
id: createdRecipe.id,
|
||||
name: createdRecipe.name,
|
||||
});
|
||||
return createdRecipe;
|
||||
} catch (err) {
|
||||
console.log("Error creating recipe:", err);
|
||||
logger.error("Error creating recipe", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
throw new Error("Failed to add recipe");
|
||||
}
|
||||
}
|
||||
|
||||
async updateRecipe(
|
||||
recipeData: {
|
||||
details: {
|
||||
name: string;
|
||||
author: string;
|
||||
cuisine: string;
|
||||
stars: number;
|
||||
prep_minutes: number;
|
||||
cook_minutes: number;
|
||||
};
|
||||
ingredients: string[];
|
||||
steps: { [key: string]: string };
|
||||
},
|
||||
id: number,
|
||||
): Promise<any> {
|
||||
const name = recipeData.details.name;
|
||||
const author = recipeData.details.author;
|
||||
const cuisine = recipeData.details.cuisine;
|
||||
const stars = recipeData.details.stars;
|
||||
const prep_minutes = recipeData.details.prep_minutes;
|
||||
const cook_minutes = recipeData.details.cook_minutes;
|
||||
const ingredients = recipeData.ingredients;
|
||||
const steps = recipeData.steps;
|
||||
try {
|
||||
await this.deleteRecipeData(id);
|
||||
const updatedRecipe = await this.prisma.recipes.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
author,
|
||||
cuisine,
|
||||
prep_minutes,
|
||||
cook_minutes,
|
||||
stars,
|
||||
recipeIngredients: {
|
||||
create: ingredients.map((ing) => ({ raw: ing })),
|
||||
},
|
||||
recipeSteps: {
|
||||
create: steps,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!updatedRecipe) {
|
||||
logger.warn(`Recipe with id ${id} cannot be found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info("Updated Recipe", {
|
||||
id: updatedRecipe.id,
|
||||
name: updatedRecipe.name,
|
||||
});
|
||||
return updatedRecipe;
|
||||
} catch (err) {
|
||||
console.log("Error updating recipe:", err);
|
||||
logger.error("Error updating recipe", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
throw new Error("Failed to update recipe");
|
||||
}
|
||||
}
|
||||
|
||||
async setStars(id: number, stars: number): Promise<{ message: string }> {
|
||||
try {
|
||||
await this.prisma.recipes.update({
|
||||
where: { id },
|
||||
data: { stars },
|
||||
});
|
||||
return { message: "Stars updated" };
|
||||
} catch (err) {
|
||||
console.error("Error updating stars:", err);
|
||||
logger.error("Error setting stars", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
throw new Error(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRecipe(id: number): Promise<{ message: string }> {
|
||||
try {
|
||||
await this.deleteRecipeData(id);
|
||||
const deletedRecipe = await this.prisma.recipes.delete({
|
||||
where: { id },
|
||||
});
|
||||
logger.info("Recipe deleted", {
|
||||
id: deletedRecipe.id,
|
||||
name: deletedRecipe.name,
|
||||
});
|
||||
return { message: "Recipe deleted successfully" };
|
||||
} catch (err) {
|
||||
console.error("Error deleting recipe:", err);
|
||||
logger.error("Error deleting recipe", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
throw new Error(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRecipeData(id: number): Promise<{ message: string }> {
|
||||
try {
|
||||
await this.prisma.recipe_ingredients.deleteMany({
|
||||
where: { recipe_id: id },
|
||||
});
|
||||
|
||||
await this.prisma.recipe_steps.deleteMany({
|
||||
where: { recipe_id: id },
|
||||
});
|
||||
return { message: "Recipe data deleted successfully" };
|
||||
} catch (err) {
|
||||
console.error("Error deleting recipe:", err);
|
||||
logger.error("Error deleting recipe", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
throw new Error(err instanceof Error ? err.message : "Unknown error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default RecipeModel;
|
||||
7
backend/src/recipes/create-recipe.dto.ts
Normal file
7
backend/src/recipes/create-recipe.dto.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Prisma } from '@prisma/client';
|
||||
|
||||
// also used for updateRecipe
|
||||
export type CreateRecipeDto = Prisma.recipesCreateInput & {
|
||||
recipeIngredients: Prisma.recipe_ingredientsCreateWithoutRecipesInput[];
|
||||
recipeSteps: Prisma.recipe_stepsCreateWithoutRecipesInput[];
|
||||
};
|
||||
20
backend/src/recipes/recipes.controller.spec.ts
Normal file
20
backend/src/recipes/recipes.controller.spec.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RecipesController } from './recipes.controller';
|
||||
import { RecipesService } from './recipes.service';
|
||||
|
||||
describe('RecipesController', () => {
|
||||
let controller: RecipesController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [RecipesController],
|
||||
providers: [RecipesService],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<RecipesController>(RecipesController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
45
backend/src/recipes/recipes.controller.ts
Normal file
45
backend/src/recipes/recipes.controller.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
} from '@nestjs/common';
|
||||
import { RecipesService } from './recipes.service';
|
||||
import type { CreateRecipeDto } from './create-recipe.dto';
|
||||
@Controller('recipe')
|
||||
export class RecipesController {
|
||||
constructor(private readonly recipeService: RecipesService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() createRecipeDto: CreateRecipeDto) {
|
||||
return this.recipeService.create(createRecipeDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.recipeService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.recipeService.findOne(+id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() updateRecipeDto: CreateRecipeDto) {
|
||||
return this.recipeService.update(+id, updateRecipeDto);
|
||||
}
|
||||
|
||||
@Patch(':id/stars')
|
||||
setStars(@Param('id') id: string, @Body('stars') stars: number) {
|
||||
return this.recipeService.setStars(+id, stars);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.recipeService.remove(+id);
|
||||
}
|
||||
}
|
||||
9
backend/src/recipes/recipes.module.ts
Normal file
9
backend/src/recipes/recipes.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { RecipesService } from './recipes.service';
|
||||
import { RecipesController } from './recipes.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [RecipesController],
|
||||
providers: [RecipesService],
|
||||
})
|
||||
export class RecipesModule {}
|
||||
18
backend/src/recipes/recipes.service.spec.ts
Normal file
18
backend/src/recipes/recipes.service.spec.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { RecipesService } from './recipes.service';
|
||||
|
||||
describe('RecipesService', () => {
|
||||
let service: RecipesService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [RecipesService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<RecipesService>(RecipesService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
222
backend/src/recipes/recipes.service.ts
Normal file
222
backend/src/recipes/recipes.service.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import type { CreateRecipeDto } from './create-recipe.dto';
|
||||
|
||||
@Injectable()
|
||||
export class RecipesService {
|
||||
constructor(private readonly databaseService: DatabaseService) {}
|
||||
|
||||
async create(createRecipeDto: CreateRecipeDto) {
|
||||
try {
|
||||
const createdRecipe = await this.databaseService.recipes.create({
|
||||
data: {
|
||||
name: createRecipeDto.name,
|
||||
author: createRecipeDto.author,
|
||||
cuisine: createRecipeDto.cuisine,
|
||||
stars: createRecipeDto.stars,
|
||||
prep_minutes: createRecipeDto.prep_minutes,
|
||||
cook_minutes: createRecipeDto.cook_minutes,
|
||||
recipeIngredients: {
|
||||
create: createRecipeDto.recipeIngredients.map((ingredient) => ({
|
||||
raw: ingredient.raw,
|
||||
})),
|
||||
},
|
||||
recipeSteps: {
|
||||
create: createRecipeDto.recipeSteps || [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Logger.log('New recipe created', {
|
||||
id: createdRecipe.id,
|
||||
name: createdRecipe.name,
|
||||
});
|
||||
|
||||
return createdRecipe;
|
||||
} catch (err) {
|
||||
Logger.error('Error creating recipe', {
|
||||
message: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
throw new Error('Failed to add recipe');
|
||||
}
|
||||
}
|
||||
|
||||
async findAll() {
|
||||
try {
|
||||
Logger.log('index page view');
|
||||
return await this.databaseService.recipes.findMany({});
|
||||
} catch (err) {
|
||||
Logger.error('Error fetching all recipes', {
|
||||
message: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
throw new Error(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
try {
|
||||
const recipe = await this.databaseService.recipes.findUnique({
|
||||
where: { id },
|
||||
include: { recipeSteps: true, recipeIngredients: true },
|
||||
});
|
||||
if (!recipe) {
|
||||
Logger.warn(`Recipe with id ${id} cannot be found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = {
|
||||
id: recipe.id,
|
||||
name: recipe.name,
|
||||
author: recipe.author,
|
||||
cuisine: recipe.cuisine,
|
||||
stars: recipe.stars,
|
||||
prep_minutes: recipe.prep_minutes,
|
||||
cook_minutes: recipe.cook_minutes,
|
||||
recipeIngredients: recipe.recipeIngredients.map((ing) => ing.raw),
|
||||
recipeSteps: recipe.recipeSteps.map((step) => ({
|
||||
step_number: step.step_number ?? 0,
|
||||
instruction: step.instruction ?? '',
|
||||
})),
|
||||
};
|
||||
|
||||
Logger.log('Recipe page view', {
|
||||
recipe_id: data.id,
|
||||
recipe_name: data.name,
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (err) {
|
||||
Logger.error('Error finding recipe', {
|
||||
message: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
throw new Error(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: number, updateRecipeDto: CreateRecipeDto) {
|
||||
try {
|
||||
const updatedRecipe = await this.databaseService.$transaction(
|
||||
async (prisma) => {
|
||||
await prisma.recipe_ingredients.deleteMany({
|
||||
where: { recipe_id: id },
|
||||
});
|
||||
await prisma.recipe_steps.deleteMany({
|
||||
where: { recipe_id: id },
|
||||
});
|
||||
|
||||
const recipeUpdate = await prisma.recipes.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: updateRecipeDto.name,
|
||||
author: updateRecipeDto.author,
|
||||
cuisine: updateRecipeDto.cuisine,
|
||||
stars: updateRecipeDto.stars,
|
||||
prep_minutes: updateRecipeDto.prep_minutes,
|
||||
cook_minutes: updateRecipeDto.cook_minutes,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const ingredientsData = updateRecipeDto.recipeIngredients.map(
|
||||
(ingredient) => ({
|
||||
raw: ingredient.raw ?? '',
|
||||
quantity: ingredient.quantity ?? null,
|
||||
unit: ingredient.unit ?? null,
|
||||
notes: ingredient.notes ?? null,
|
||||
recipe_id: id,
|
||||
}),
|
||||
);
|
||||
|
||||
const stepsData = updateRecipeDto.recipeSteps.map((step) => ({
|
||||
step_number: step.step_number ?? null,
|
||||
instruction: step.instruction ?? '',
|
||||
recipe_id: id,
|
||||
}));
|
||||
|
||||
if (ingredientsData.length > 0) {
|
||||
await prisma.recipe_ingredients.createMany({
|
||||
data: ingredientsData,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (stepsData.length > 0) {
|
||||
await prisma.recipe_steps.createMany({
|
||||
data: stepsData,
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
return recipeUpdate;
|
||||
},
|
||||
);
|
||||
|
||||
Logger.log('Updated Recipe', {
|
||||
id: updatedRecipe.id,
|
||||
name: updatedRecipe.name,
|
||||
});
|
||||
|
||||
return updatedRecipe;
|
||||
} catch (err) {
|
||||
console.error('Update method caught error:', err);
|
||||
Logger.error('Error updating recipe', {
|
||||
message: err instanceof Error ? err.message : 'Unknown error',
|
||||
stack: err instanceof Error ? err.stack : 'No stack available',
|
||||
id,
|
||||
updateRecipeDto,
|
||||
});
|
||||
throw new Error('Failed to update recipe');
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteRecipeData(id: number) {
|
||||
try {
|
||||
await this.databaseService.recipe_ingredients.deleteMany({
|
||||
where: { recipe_id: id },
|
||||
});
|
||||
await this.databaseService.recipe_steps.deleteMany({
|
||||
where: { recipe_id: id },
|
||||
});
|
||||
Logger.log(`Recipe data deleted successfully for recipe id: ${id}`);
|
||||
} catch (err) {
|
||||
Logger.error('Error deleting recipe data', {
|
||||
message: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
throw new Error(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
async setStars(id: number, stars: number) {
|
||||
try {
|
||||
await this.databaseService.recipes.update({
|
||||
where: { id },
|
||||
data: { stars },
|
||||
});
|
||||
return { message: 'Stars updated' };
|
||||
} catch (err) {
|
||||
Logger.error('Error setting stars', {
|
||||
message: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
throw new Error(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
try {
|
||||
await this.deleteRecipeData(id);
|
||||
const deletedRecipe = await this.databaseService.recipes.delete({
|
||||
where: { id },
|
||||
});
|
||||
Logger.log('Recipe deleted', {
|
||||
id: deletedRecipe.id,
|
||||
name: deletedRecipe.name,
|
||||
});
|
||||
return { message: 'Recipe deleted successfully' };
|
||||
} catch (err) {
|
||||
Logger.error('Error deleting recipe', {
|
||||
message: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
throw new Error(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import express, { Router } from "express";
|
||||
import recipeController from "../controllers/recipeController";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/test", recipeController.test);
|
||||
|
||||
router.get("/recipes", recipeController.getAllRecipes);
|
||||
|
||||
router.get("/recipe/:id", recipeController.getRecipeById);
|
||||
|
||||
router.post("/add-recipe", recipeController.addRecipe);
|
||||
|
||||
router.post("/update-recipe/:id", recipeController.updateRecipe);
|
||||
|
||||
router.post("/set-stars", recipeController.setStars);
|
||||
|
||||
router.delete("/delete-recipe", recipeController.deleteRecipe);
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import fs from "fs";
|
||||
|
||||
class Logger {
|
||||
private filePath: string;
|
||||
|
||||
constructor(filePath: string = "/logs/app.log") {
|
||||
this.filePath = filePath;
|
||||
}
|
||||
|
||||
private log(level: string, message: string, params?: object) {
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
level: level,
|
||||
message: message,
|
||||
params: params,
|
||||
};
|
||||
fs.appendFile(this.filePath, JSON.stringify(logEntry) + "\n", (err) => {
|
||||
if (err) throw err;
|
||||
});
|
||||
}
|
||||
|
||||
info(message: string, params: object = {}) {
|
||||
this.log("info", message, params);
|
||||
}
|
||||
|
||||
warn(message: string, params: object = {}) {
|
||||
this.log("warn", message, params);
|
||||
}
|
||||
|
||||
error(message: string, params: object = {}) {
|
||||
this.log("error", message, params);
|
||||
}
|
||||
}
|
||||
|
||||
export default Logger;
|
||||
25
backend/test/app.e2e-spec.ts
Normal file
25
backend/test/app.e2e-spec.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { AppModule } from './../src/app.module';
|
||||
|
||||
describe('AppController (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
||||
9
backend/test/jest-e2e.json
Normal file
9
backend/test/jest-e2e.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
||||
4
backend/tsconfig.build.json
Normal file
4
backend/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
|
|
@ -1,16 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"resolvePackageJsonExports": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"isolatedModules": true,
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"scripts"
|
||||
]
|
||||
"strictNullChecks": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ services:
|
|||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- DB_NAME=${DB_NAME}
|
||||
- DATABASE_URL=${DATABASE_URL}
|
||||
- NODE_ENV=dev
|
||||
frontend:
|
||||
container_name: recipes_frontend_dev
|
||||
restart: unless-stopped
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ services:
|
|||
- POSTGRES_DB=${DB_NAME}
|
||||
volumes:
|
||||
- ./db:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
backend:
|
||||
container_name: recipes_backend
|
||||
image: forgejo.fredzernia.com/fred/recipe_app_backend:latest
|
||||
|
|
@ -18,7 +20,7 @@ services:
|
|||
build:
|
||||
context: ./backend
|
||||
volumes:
|
||||
- ./logs:/logs
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- DB_USER=${DB_USER}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
|
|
|
|||
6
frontend/package-lock.json
generated
6
frontend/package-lock.json
generated
|
|
@ -2196,9 +2196,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -27,14 +27,14 @@ const RecipeForm: React.FC<RecipeFormProps> = ({ onSubmit, initialData }) => {
|
|||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setRecipeName(initialData.details.name || "");
|
||||
setRecipeCuisine(initialData.details.cuisine || "");
|
||||
setAuthor(initialData.details.author || "");
|
||||
setStars(initialData.details.stars || 0);
|
||||
setPrepMinutes(initialData.details.prep_minutes || 5);
|
||||
setCookMinutes(initialData.details.cook_minutes || 5);
|
||||
setIngredients(initialData.ingredients);
|
||||
setSteps(initialData.steps);
|
||||
setRecipeName(initialData.name || "");
|
||||
setRecipeCuisine(initialData.cuisine || "");
|
||||
setAuthor(initialData.author || "");
|
||||
setStars(initialData.stars || 0);
|
||||
setPrepMinutes(initialData.prep_minutes || 5);
|
||||
setCookMinutes(initialData.cook_minutes || 5);
|
||||
setIngredients(initialData.recipeIngredients);
|
||||
setSteps(initialData.recipeSteps);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
|
|
@ -53,19 +53,17 @@ const RecipeForm: React.FC<RecipeFormProps> = ({ onSubmit, initialData }) => {
|
|||
}
|
||||
|
||||
const recipeData = {
|
||||
details: {
|
||||
name: recipeName,
|
||||
cuisine: recipeCuisine.toLowerCase(),
|
||||
author: author,
|
||||
prep_minutes: prepMinutes,
|
||||
cook_minutes: cookMinutes,
|
||||
stars: stars,
|
||||
},
|
||||
ingredients: ingredients,
|
||||
steps: steps.map((step) => ({
|
||||
recipeIngredients: ingredients.map((ing) => ing.trim()),
|
||||
recipeSteps: steps.map((step) => ({
|
||||
id: undefined,
|
||||
step_number: step.step_number,
|
||||
instruction: step.instruction,
|
||||
instruction: step.instruction.trim(),
|
||||
})),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,23 +1,58 @@
|
|||
function About() {
|
||||
|
||||
return (
|
||||
<div className="about page-outer">
|
||||
<div>
|
||||
<h2 className="text-xl text-[var(--color-secondaryTextDark)]">This app uses the following components:</h2>
|
||||
<h2 className="mt-4 font-bold text-xl text-[var(--color-secondaryTextDark)]">Frontend:</h2>
|
||||
<ul><li>React</li><li>TypeScript</li></ul>
|
||||
<h2 className="mt-4 font-bold text-xl text-[var(--color-secondaryTextDark)]">Backend:</h2>
|
||||
<ul><li>Node.js & Express</li><li>PostgreSQL</li><li>Prisma</li></ul>
|
||||
<h2 className="mt-4 font-bold text-xl text-[var(--color-secondaryTextDark)]">Containerization:</h2>
|
||||
<ul><li>Docker</li></ul>
|
||||
<h2 className="mt-4 font-bold text-xl text-[var(--color-secondaryTextDark)]">Styling/UI:</h2>
|
||||
<ul><li>Tailwind CSS</li></ul>
|
||||
<p className="mt-4 text-[var(--color-secondaryTextDark)]">More about me <a className="text-[var(--color-textLink)]" target="_blank" href="https://fredzernia.com">here</a> |
|
||||
Code for this app <a className="text-[var(--color-textLink)]" target="_blank" href="https://forgejo.fredzernia.com/fred/recipe_app">here</a></p>
|
||||
<h2 className="text-xl text-[var(--color-secondaryTextDark)]">
|
||||
This app uses the following components:
|
||||
</h2>
|
||||
<h2 className="mt-4 font-bold text-xl text-[var(--color-secondaryTextDark)]">
|
||||
Frontend:
|
||||
</h2>
|
||||
<ul>
|
||||
<li>React</li>
|
||||
<li>TypeScript</li>
|
||||
</ul>
|
||||
<h2 className="mt-4 font-bold text-xl text-[var(--color-secondaryTextDark)]">
|
||||
Backend:
|
||||
</h2>
|
||||
<ul>
|
||||
<li>NestJS</li>
|
||||
<li>PostgreSQL</li>
|
||||
<li>Prisma</li>
|
||||
</ul>
|
||||
<h2 className="mt-4 font-bold text-xl text-[var(--color-secondaryTextDark)]">
|
||||
Containerization:
|
||||
</h2>
|
||||
<ul>
|
||||
<li>Docker</li>
|
||||
</ul>
|
||||
<h2 className="mt-4 font-bold text-xl text-[var(--color-secondaryTextDark)]">
|
||||
Styling/UI:
|
||||
</h2>
|
||||
<ul>
|
||||
<li>Tailwind CSS</li>
|
||||
</ul>
|
||||
<p className="mt-4 text-[var(--color-secondaryTextDark)]">
|
||||
More about me{" "}
|
||||
<a
|
||||
className="text-[var(--color-textLink)]"
|
||||
target="_blank"
|
||||
href="https://fredzernia.com"
|
||||
>
|
||||
here
|
||||
</a>{" "}
|
||||
| Code for this app{" "}
|
||||
<a
|
||||
className="text-[var(--color-textLink)]"
|
||||
target="_blank"
|
||||
href="https://forgejo.fredzernia.com/fred/recipe_app"
|
||||
>
|
||||
here
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div >
|
||||
)
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default About
|
||||
export default About;
|
||||
|
|
|
|||
|
|
@ -12,9 +12,8 @@ import TimeDisplay from "../components/TimeDisplay.tsx";
|
|||
|
||||
function RecipePage() {
|
||||
const [recipe, setRecipe] = useState<Recipe>({
|
||||
details: {},
|
||||
ingredients: [],
|
||||
steps: [],
|
||||
recipeIngredients: [],
|
||||
recipeSteps: [],
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -23,8 +22,8 @@ function RecipePage() {
|
|||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const { id } = useParams();
|
||||
const isWebSource =
|
||||
recipe && recipe.details && recipe.details.author
|
||||
? /http|com/.test(recipe.details.author) //etc
|
||||
recipe && recipe.author
|
||||
? /http|com/.test(recipe.author) //etc
|
||||
: false;
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -36,7 +35,7 @@ function RecipePage() {
|
|||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
handleDelete(recipe.details.id);
|
||||
handleDelete(recipe.id);
|
||||
closeModal();
|
||||
};
|
||||
|
||||
|
|
@ -44,12 +43,12 @@ function RecipePage() {
|
|||
const loadRecipe = async () => {
|
||||
try {
|
||||
const recipe = await getRecipeById(id);
|
||||
if (!recipe.details) {
|
||||
if (!recipe.name) {
|
||||
setError("Sorry, this recipe no longer exists");
|
||||
} else {
|
||||
setRecipe(recipe);
|
||||
setStars(recipe.details?.stars ?? 0);
|
||||
setInitialStars(recipe.details?.stars ?? 0);
|
||||
setStars(recipe.stars ?? 0);
|
||||
setInitialStars(recipe.stars ?? 0);
|
||||
if (process.env.NODE_ENV === "dev") {
|
||||
console.log(recipe);
|
||||
}
|
||||
|
|
@ -119,11 +118,11 @@ function RecipePage() {
|
|||
</button>
|
||||
</div>
|
||||
<h3 className="text-center max-w-lg px-4 text-2xl lg:text-3xl font-bold text-[var(--color-textDark)]">
|
||||
{recipe.details.name}
|
||||
{recipe.name}
|
||||
</h3>
|
||||
<div className="modify-buttons">
|
||||
<button
|
||||
onClick={() => navigate(`/edit-recipe/${recipe.details.id}`)}
|
||||
onClick={() => navigate(`/edit-recipe/${recipe.id}`)}
|
||||
className="ar-button bg-[var(--color-buttonBg)] text-[var(--color-textLight)] py-1 px-1 rounded hover:bg-[var(--color-buttonBgHover)] mr-2 self-start"
|
||||
>
|
||||
🔧
|
||||
|
|
@ -138,12 +137,12 @@ function RecipePage() {
|
|||
</div>
|
||||
<div className="mt-1">
|
||||
<p className="text-[var(--color-textDark)] italic text-lg">
|
||||
{recipe.details.cuisine}
|
||||
{recipe.cuisine}
|
||||
</p>
|
||||
<p>
|
||||
prep: <TimeDisplay minutes={recipe.details.prep_minutes ?? 0} />{" "}
|
||||
prep: <TimeDisplay minutes={recipe.prep_minutes ?? 0} />{" "}
|
||||
| cook:{" "}
|
||||
<TimeDisplay minutes={recipe.details.cook_minutes ?? 0} />
|
||||
<TimeDisplay minutes={recipe.cook_minutes ?? 0} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -154,7 +153,7 @@ function RecipePage() {
|
|||
Ingredients:
|
||||
</h4>
|
||||
<ul className="space-y-2">
|
||||
{recipe.ingredients.map((ingredient: string, index) => (
|
||||
{recipe.recipeIngredients.map((ingredient: string, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="text-[var(--color-secondaryTextDark)] flex items-start"
|
||||
|
|
@ -171,17 +170,17 @@ function RecipePage() {
|
|||
Instructions:
|
||||
</h4>
|
||||
<ol className="space-y-3">
|
||||
{recipe.steps &&
|
||||
Object.keys(recipe.steps || {}).map((stepNumber) => (
|
||||
{recipe.recipeSteps &&
|
||||
Object.keys(recipe.recipeSteps || {}).map((stepNumber) => (
|
||||
<li
|
||||
key={stepNumber}
|
||||
className="text-[var(--color-secondaryTextDark)] flex items-start"
|
||||
>
|
||||
<span className="bg-[var(--color-buttonBg)] text-[var(--color-textLight)] rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold mr-3 mt-0.5 flex-shrink-0">
|
||||
{recipe.steps[parseInt(stepNumber)].step_number}
|
||||
{recipe.recipeSteps[parseInt(stepNumber)].step_number}
|
||||
</span>
|
||||
<span className="leading-relaxed text-left">
|
||||
{recipe.steps[parseInt(stepNumber)].instruction}
|
||||
{recipe.recipeSteps[parseInt(stepNumber)].instruction}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -192,9 +191,9 @@ function RecipePage() {
|
|||
<div className="border-t-2 border-[var(--color-primaryBorder)] pt-4">
|
||||
<div className="flex justify-between items-center text-sm text-[var(--color-textDark)]">
|
||||
{isWebSource ? (
|
||||
<span>Source: {recipe.details.author}</span>
|
||||
<span>Source: {recipe.author}</span>
|
||||
) : (
|
||||
<span>From the kitchen of {recipe.details.author}</span>
|
||||
<span>From the kitchen of {recipe.author}</span>
|
||||
)}
|
||||
<span>
|
||||
<StarRating
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ function RecipeSteps() {
|
|||
};
|
||||
loadRecipeSteps();
|
||||
}, []);
|
||||
console.log(recipeSteps)
|
||||
// console.log(recipeSteps)
|
||||
return (
|
||||
<div className='page-outer'>
|
||||
{loading ? (
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ const baseUrl =
|
|||
process.env.NODE_ENV !== "dev" ? "/api" : "http://localhost:3000/api";
|
||||
|
||||
export const getRecipes = async () => {
|
||||
console.log(`${baseUrl}/recipes`);
|
||||
const response = await fetch(`${baseUrl}/recipes`);
|
||||
// console.log(`${baseUrl}/recipe`);
|
||||
const response = await fetch(`${baseUrl}/recipe`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
};
|
||||
|
|
@ -23,30 +23,36 @@ export const getRecipeIngredients = async () => {
|
|||
export const getRecipeById = async (id) => {
|
||||
const response = await fetch(`${baseUrl}/recipe/${id}`);
|
||||
const data = await response.json();
|
||||
if (!data || !data.details) {
|
||||
if (!data || !data.name) {
|
||||
return { details: null };
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
export const addRecipe = async (recipeData) => {
|
||||
console.log(JSON.stringify(recipeData));
|
||||
recipeData.recipeIngredients = recipeData.recipeIngredients.map((str) => ({
|
||||
raw: str,
|
||||
}));
|
||||
// console.log(JSON.stringify(recipeData));
|
||||
// return
|
||||
const response = await fetch(`${baseUrl}/add-recipe`, {
|
||||
const response = await fetch(`${baseUrl}/recipe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(recipeData),
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log(data);
|
||||
// console.log(data);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const updateRecipe = async (id, recipeData) => {
|
||||
console.log("updateRecipe");
|
||||
console.log(recipeData);
|
||||
const response = await fetch(`${baseUrl}/update-recipe/${id}`, {
|
||||
method: "POST",
|
||||
// console.log("updateRecipe");
|
||||
// console.log(recipeData);
|
||||
recipeData.recipeIngredients = recipeData.recipeIngredients.map((str) => ({
|
||||
raw: str,
|
||||
}));
|
||||
const response = await fetch(`${baseUrl}/recipe/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(recipeData),
|
||||
});
|
||||
|
|
@ -55,22 +61,22 @@ export const updateRecipe = async (id, recipeData) => {
|
|||
};
|
||||
|
||||
export const setDBStars = async (id, stars) => {
|
||||
console.log(JSON.stringify({ id: id, stars: stars }));
|
||||
// console.log(JSON.stringify({ id: id, stars: stars }));
|
||||
// return
|
||||
const response = await fetch(`${baseUrl}/set-stars`, {
|
||||
method: "POST",
|
||||
const response = await fetch(`${baseUrl}/recipe/${id}/stars`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: id, stars: stars }),
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log(data);
|
||||
// console.log(data);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteRecipe = async (id) => {
|
||||
console.log(id);
|
||||
// console.log(id);
|
||||
// return
|
||||
const response = await fetch(`${baseUrl}/delete-recipe`, {
|
||||
const response = await fetch(`${baseUrl}/recipe/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ interface Step {
|
|||
}
|
||||
|
||||
interface Recipe {
|
||||
details: {
|
||||
id?: number;
|
||||
name?: string;
|
||||
author?: string;
|
||||
|
|
@ -13,9 +12,8 @@ interface Recipe {
|
|||
cuisine?: string;
|
||||
prep_minutes?: number;
|
||||
cook_minutes?: number;
|
||||
};
|
||||
ingredients: string[];
|
||||
steps: Step[];
|
||||
recipeIngredients: string[];
|
||||
recipeSteps: Step[];
|
||||
}
|
||||
|
||||
// smaller Recipe type returned by backend at /recipes for all
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ pkgs.mkShell {
|
|||
buildInputs = [
|
||||
pkgs.prisma-engines
|
||||
pkgs.prisma
|
||||
pkgs.nodePackages."@nestjs/cli"
|
||||
];
|
||||
shellHook = ''
|
||||
export PKG_CONFIG_PATH="${pkgs.openssl.dev}/lib/pkgconfig"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue