From 610108950d71040c9f513bb6ac99a662adc0d439 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Loureiro?= Date: Wed, 11 Jun 2025 18:33:42 -0300 Subject: [PATCH] feat: add orphanage management functionality with image upload and validation --- backend/src/config/upload.ts | 13 +++ .../src/controllers/OrphanagesController.ts | 74 ++++++++++++++++++ backend/src/database/connection.ts | 3 + backend/src/database/database.sqlite | Bin 0 -> 24576 bytes .../1749673967883-InitialMigration.ts | 24 ++++++ backend/src/errors/handler.ts | 24 ++++++ backend/src/models/Image.ts | 15 ++++ backend/src/models/Orphanage.ts | 48 ++++++++++++ backend/src/routes.ts | 21 +++++ backend/src/server.ts | 22 ++++++ backend/src/views/images_view.ts | 15 ++++ backend/src/views/orphanages_view.ts | 23 ++++++ 12 files changed, 282 insertions(+) create mode 100644 backend/src/config/upload.ts create mode 100644 backend/src/controllers/OrphanagesController.ts create mode 100644 backend/src/database/connection.ts create mode 100644 backend/src/database/database.sqlite create mode 100644 backend/src/database/migrations/1749673967883-InitialMigration.ts create mode 100644 backend/src/errors/handler.ts create mode 100644 backend/src/models/Image.ts create mode 100644 backend/src/models/Orphanage.ts create mode 100644 backend/src/routes.ts create mode 100644 backend/src/server.ts create mode 100644 backend/src/views/images_view.ts create mode 100644 backend/src/views/orphanages_view.ts diff --git a/backend/src/config/upload.ts b/backend/src/config/upload.ts new file mode 100644 index 0000000..5fd8523 --- /dev/null +++ b/backend/src/config/upload.ts @@ -0,0 +1,13 @@ +import multer from 'multer'; +import path from 'path'; + +export default { + storage: multer.diskStorage({ + destination: path.join(__dirname, '..','..','uploads'), + filename: (request, file, cb) => { + const fileName = `${Date.now()}_${file.originalname}`; + + cb(null, fileName); + } + }) +} \ No newline at end of file diff --git a/backend/src/controllers/OrphanagesController.ts b/backend/src/controllers/OrphanagesController.ts new file mode 100644 index 0000000..85acbac --- /dev/null +++ b/backend/src/controllers/OrphanagesController.ts @@ -0,0 +1,74 @@ + +import { Request, Response } from 'express'; +import { getRepository } from 'typeorm'; +import Orphanage from '../models/Orphanage'; +import OrphanageView from '../views/orphanages_view'; +import * as Yup from 'yup'; + +export default { + + async getAll(request: Request, response: Response) { + return response.json(OrphanageView.renderMany(await getRepository(Orphanage).find({ + relations: ['images'] + }))); + }, + + async getIndex(request: Request, response: Response) { + return response.json(OrphanageView.render(await getRepository(Orphanage).findOneOrFail(request.params.id, { + relations: ['images'] + }))); + }, + + async create(request: Request, response: Response) { + const { + name, + latitude, + longitude, + about, + instructions, + opening_hours, + open_on_weekends, + phone + } = request.body; + + const requestImages = request.files as Express.Multer.File[]; + const images = requestImages.map(image => { + return { path: image.filename}; + }) + + const data = { + name, + latitude: Number(latitude), + longitude: Number(longitude), + about, + instructions, + opening_hours, + open_on_weekends: open_on_weekends === 'true', + phone, + images, + }; + + const schema = Yup.object().shape({ + name: Yup.string().required(), + latitude: Yup.number().required(), + longitude: Yup.number().required(), + about: Yup.string().required().max(300), + opening_hours: Yup.string().required(), + open_on_weekends: Yup.boolean().required(), + phone: Yup.string().required(), + images: Yup.array(Yup.object().shape({ + path: Yup.string().required() + })) + }); + + await schema.validate(data, { + abortEarly: false, + }); + + const orphanage = getRepository(Orphanage).create(data); + + await getRepository(Orphanage).save(orphanage); + + return response.status(201).json(orphanage); + } +} \ No newline at end of file diff --git a/backend/src/database/connection.ts b/backend/src/database/connection.ts new file mode 100644 index 0000000..3827f2b --- /dev/null +++ b/backend/src/database/connection.ts @@ -0,0 +1,3 @@ +import { createConnection } from 'typeorm'; + +createConnection(); \ No newline at end of file diff --git a/backend/src/database/database.sqlite b/backend/src/database/database.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..fe4cc491d9b0cff51eeeca813cf51c6ef5863799 GIT binary patch literal 24576 zcmeI)zi;DI8~|{;Nn1)A!d6u*uUSZRaj_G}aZU&ka&8r_O-h;$)eYgrd5I0SpU*$; z)By=fVQ0D(!Eh2A>_{h-?HJhj4;YwX=j$ZbB)y@hQX6LQ};A@$>KyV-*B2S%$)T5Sl& z`};+b^J!$|$Riq<77gYT_?bFw+&iLIvtuyRjsO2H}xu&`?^VoIkMaC7|p%bm?WR$bM+NS z$JjMGMyp{Qky%PO?LmRGTjZXxZvcO*P3nzq6Cz+gKD?I<$z@#OEKaX(Zf}dBVuJCA=1wM_qO~g87PEm>)%lMs^erAj5!}FeXl3k&F6b7VeF%%R~D-j%GJ0 z%Zqohy)X)5GqSk{VABs%KFI~2Fzz$Y_C`I6$H81`X|akVSC4zW$Bcc>ya8nFbM7$e z&G*K)xHs2Vr84VC;c#hX^UfXdWUL-Obl|DD7qW*j^GtT-GPU4;B{83P7NKO}t6XY4 zl4sd*ovu9!Kohz+J6l?)DK|@at&xmu7ryk=^`Z0j2)@SieP0;n%Y;JmY%XU16kx{< z1yBG5Pyhu`00mG01yBG5Pyhu`;C2bTE2f3zJ@Lt>zliHi&yH;B9K3k{0PnSGTD1&8 z*UOpgzd|beT*yAp{)O)VZr31VYf%6NPyhu`00mG01yBG5Pyhw~mjZ8!sV#9bZzh#V zrMA~*6GJjfa5DIqLmm^K2E9>tEw0@}A78;Y_b z;X@WM6N+)d2C+HG2{nbG9Yz;FdC&s_HO5JH9wrMJ?BryJ)H!irnFLRI;!-=yBt-Mk$kaDS1lGKVUC(r+>?8id(x9qoYNY?kyukpY6 zamN}_00mG01yBG5Pyhu`00mG01yJBt3*1emh10dkTZ_m|Sb7Yk+O-eZeKA@zM}{6oD|l1mjyhFzCvBq_3_ zs!}B>%tm)%vj4)JHMys;vR0F|s#GdfbV(g3npD#iCc!^dP)(|@x9TlCdbC3 b01BW03ZMWApa2S>01BW03ZMWAypq6wz>GVr literal 0 HcmV?d00001 diff --git a/backend/src/database/migrations/1749673967883-InitialMigration.ts b/backend/src/database/migrations/1749673967883-InitialMigration.ts new file mode 100644 index 0000000..d6abdd5 --- /dev/null +++ b/backend/src/database/migrations/1749673967883-InitialMigration.ts @@ -0,0 +1,24 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class InitialMigration1749673967883 implements MigrationInterface { + name = 'InitialMigration1749673967883' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "orphanages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(50) NOT NULL, "latitude" decimal(10,8) NOT NULL, "longitude" decimal(10,8) NOT NULL, "about" varchar(300) NOT NULL, "instructions" varchar(300), "opening_hours" varchar(20) NOT NULL, "open_on_weekends" boolean NOT NULL, "phone" varchar(15) NOT NULL)`); + await queryRunner.query(`CREATE TABLE "images" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "path" varchar NOT NULL, "orphanageId" integer)`); + await queryRunner.query(`CREATE TABLE "temporary_images" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "path" varchar NOT NULL, "orphanageId" integer, CONSTRAINT "FK_96b2848afc17474a8c87a0b8caf" FOREIGN KEY ("orphanageId") REFERENCES "orphanages" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)`); + await queryRunner.query(`INSERT INTO "temporary_images"("id", "path", "orphanageId") SELECT "id", "path", "orphanageId" FROM "images"`); + await queryRunner.query(`DROP TABLE "images"`); + await queryRunner.query(`ALTER TABLE "temporary_images" RENAME TO "images"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "images" RENAME TO "temporary_images"`); + await queryRunner.query(`CREATE TABLE "images" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "path" varchar NOT NULL, "orphanageId" integer)`); + await queryRunner.query(`INSERT INTO "images"("id", "path", "orphanageId") SELECT "id", "path", "orphanageId" FROM "temporary_images"`); + await queryRunner.query(`DROP TABLE "temporary_images"`); + await queryRunner.query(`DROP TABLE "images"`); + await queryRunner.query(`DROP TABLE "orphanages"`); + } + +} diff --git a/backend/src/errors/handler.ts b/backend/src/errors/handler.ts new file mode 100644 index 0000000..c063b05 --- /dev/null +++ b/backend/src/errors/handler.ts @@ -0,0 +1,24 @@ +import { ErrorRequestHandler } from 'express'; +import { ValidationError } from 'yup'; + +interface ValidationErrors { + [key: string]: string[]; +} + +const errorHandler: ErrorRequestHandler = (error, request, response, next) => { + if (error instanceof ValidationError) { + let errors: ValidationErrors = {}; + + error.inner.forEach(err => { + errors[err.name] = err.errors; + }); + + return response.status(400).json({ message: 'Validation fails', errors}) + } + + console.log(error); + + return response.status(500).json({message: 'Internal Server Error'}) +}; + +export default errorHandler; \ No newline at end of file diff --git a/backend/src/models/Image.ts b/backend/src/models/Image.ts new file mode 100644 index 0000000..2b6a8cc --- /dev/null +++ b/backend/src/models/Image.ts @@ -0,0 +1,15 @@ +import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn } from 'typeorm'; +import Orphanage from './Orphanage'; + +@Entity('images') +export default class Image { + @PrimaryGeneratedColumn('increment') + id: number; + + @Column() + path: string; + + @ManyToOne(() => Orphanage, orphanage => orphanage.images) + @JoinColumn({ name: 'orphanageId' }) + orphanage: Orphanage; +} \ No newline at end of file diff --git a/backend/src/models/Orphanage.ts b/backend/src/models/Orphanage.ts new file mode 100644 index 0000000..1a9ee0a --- /dev/null +++ b/backend/src/models/Orphanage.ts @@ -0,0 +1,48 @@ +import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm'; +import Image from './Image'; + +@Entity('orphanages') +export default class Orphanage { + @PrimaryGeneratedColumn('increment') + id: number; + + @Column({ + length: 50 + }) + name: string; + + @Column('decimal', { precision: 10, scale: 8 }) + latitude: number; + + @Column('decimal', { precision: 10, scale: 8 }) + longitude: number; + + @Column({ + length: 300 + }) + about: string; + + @Column({ + length: 300, + nullable: true + }) + instructions: string; + + @Column({ + length: 20 + }) + opening_hours: string; + + @Column() + open_on_weekends: boolean; + + @Column({ + length: 15 + }) + phone: string; + + @OneToMany(() => Image, image => image.orphanage, { + cascade: ['insert', 'update'] + }) + images: Image[]; +} \ No newline at end of file diff --git a/backend/src/routes.ts b/backend/src/routes.ts new file mode 100644 index 0000000..15aa9d4 --- /dev/null +++ b/backend/src/routes.ts @@ -0,0 +1,21 @@ + +import { Router } from 'express'; +import multer from 'multer'; + +import OrphanagesController from './controllers/OrphanagesController'; +import UploadConfig from './config/upload'; + +const routes = Router(); +const upload = multer(UploadConfig); + +routes.get('/users', (request, response) => { + return response.json({message: ["João"]}); +}); + +routes.post('/orphanages', upload.array('images'), OrphanagesController.create); +routes.get('/orphanages', OrphanagesController.getAll); +routes.get('/orphanages/:id', OrphanagesController.getIndex); + + + +export default routes; \ No newline at end of file diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..071a810 --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,22 @@ +import 'dotenv/config'; +import express from 'express'; +import path from 'path'; +import 'express-async-errors'; +import cors from 'cors'; + +import './database/connection'; // This line now automatically runs the connection +import errorHandler from './errors/handler'; +import routes from './routes'; + +const app = express(); + +app.use(cors()); +app.use(express.json()); +app.use('/uploads', express.static(path.join(__dirname, '..', 'uploads'))); +app.use(routes); +app.use(errorHandler); + +app.listen(process.env.PORT || 3101, () => { + console.log('Server is running on http://localhost:' + (process.env.PORT || 3101)); + console.log('Environment:', process.env.NODE_ENV || 'development'); +}); \ No newline at end of file diff --git a/backend/src/views/images_view.ts b/backend/src/views/images_view.ts new file mode 100644 index 0000000..ee92219 --- /dev/null +++ b/backend/src/views/images_view.ts @@ -0,0 +1,15 @@ +import 'dotenv/config'; +import Image from '../models/Image'; + +export default { + render(image: Image) { + return { + id: image.id, + url: `${process.env.BACKEND_URL}/uploads/${image.path}` + }; + }, + + renderMany(images: Image[]) { + return images.map(image => this.render(image)); + } +} \ No newline at end of file diff --git a/backend/src/views/orphanages_view.ts b/backend/src/views/orphanages_view.ts new file mode 100644 index 0000000..728cab0 --- /dev/null +++ b/backend/src/views/orphanages_view.ts @@ -0,0 +1,23 @@ +import Orphanage from '../models/Orphanage'; +import ImagesView from './images_view'; + +export default { + render(orphanage: Orphanage) { + return { + id: orphanage.id, + name: orphanage.name, + latitude: Number(orphanage.latitude), + longitude: Number(orphanage.longitude), + about: orphanage.about, + instructions: orphanage.instructions, + opening_hours: orphanage.opening_hours, + open_on_weekends: orphanage.open_on_weekends, + phone: orphanage.phone, + images: ImagesView.renderMany(orphanage.images), + }; + }, + + renderMany(orphanages: Orphanage[]) { + return orphanages.map(orphanage => this.render(orphanage)); + } +} \ No newline at end of file