diff --git a/.env.example b/.env.example index aedd7c2d..9e013376 100644 --- a/.env.example +++ b/.env.example @@ -9,4 +9,8 @@ DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_ SECRET_KEY= HOST=::0.0.0.0 -PORT=4000 \ No newline at end of file +PORT=4000 + +CSV_IMPORTER_USE_IA_TO_PREDICT_SUPPLY_CATEGORIES=false +# Opcional (Utilizado para detectar tipos de categorias ao importar abrigos) +GEMINI_API_KEY= diff --git a/.env.local b/.env.local index 9cd74618..a5b5d958 100644 --- a/.env.local +++ b/.env.local @@ -10,4 +10,8 @@ DATABASE_URL="postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_ SECRET_KEY=batata HOST=::0.0.0.0 -PORT=4000 \ No newline at end of file +PORT=4000 + +CSV_IMPORTER_USE_IA_TO_PREDICT_SUPPLY_CATEGORIES=false +# Opcional (Utilizado para detectar tipos de categorias ao importar abrigos) +GEMINI_API_KEY= \ No newline at end of file diff --git a/env.d.ts b/env.d.ts new file mode 100644 index 00000000..35eb4b5c --- /dev/null +++ b/env.d.ts @@ -0,0 +1,20 @@ +declare global { + declare namespace NodeJS { + export interface ProcessEnv { + TZ?: string; + DB_HOST: string; + DB_PORT: string; + DB_DATABASE_NAME: string; + DB_USER: string; + DB_PASSWORD: string; + DATABASE_URL: string; + SECRET_KEY: string; + HOST: string; + PORT: string; + CSV_IMPORTER_USE_IA_TO_PREDICT_SUPPLY_CATEGORIES: boolean; + GEMINI_API_KEY?: string; + } + } +} + +export {}; diff --git a/jest.config.ts b/jest.config.ts index 6bc3f4c4..86a65ee4 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -13,6 +13,7 @@ const config: Config = { '^src/(.*)$': '/$1', '^@/(.*)$': '/$1', '^test/(.*)$': '/../$1', + '^examples/(.*)$': '/../test/examples/$1', }, testEnvironment: 'node', }; diff --git a/package-lock.json b/package-lock.json index 455fac11..d1fb3450 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "UNLICENSED", "dependencies": { "@fastify/static": "^7.0.3", + "@google/generative-ai": "^0.11.1", "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.2.0", @@ -19,18 +20,23 @@ "@nestjs/swagger": "^7.3.1", "@prisma/client": "^5.13.0", "bcrypt": "^5.1.1", + "csv": "^6.3.9", "date-fns": "^3.6.0", + "fastify-multer": "^2.0.3", + "multer": "^1.4.5-lts.1", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "zod": "^3.23.6" }, "devDependencies": { + "@anatine/zod-mock": "^3.13.4", "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/query-string": "^6.3.0", "@types/supertest": "^6.0.0", @@ -65,6 +71,19 @@ "node": ">=6.0.0" } }, + "node_modules/@anatine/zod-mock": { + "version": "3.13.4", + "resolved": "https://registry.npmjs.org/@anatine/zod-mock/-/zod-mock-3.13.4.tgz", + "integrity": "sha512-yO/KeuyYsEDCTcQ+7CiRuY3dnafMHIZUMok6Ci7aERRCTQ+/XmsiPk/RnMx5wlLmWBTmX9kw+PavbMsjM+sAJA==", + "dev": true, + "dependencies": { + "randexp": "^0.5.3" + }, + "peerDependencies": { + "@faker-js/faker": "^7.0.0 || ^8.0.0", + "zod": "^3.21.4" + } + }, "node_modules/@angular-devkit/core": { "version": "17.1.2", "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.1.2.tgz", @@ -948,6 +967,23 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@faker-js/faker": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", + "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "peer": true, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=6.14.13" + } + }, "node_modules/@fastify/accept-negotiator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@fastify/accept-negotiator/-/accept-negotiator-1.1.0.tgz", @@ -966,6 +1002,17 @@ "fast-uri": "^2.0.0" } }, + "node_modules/@fastify/busboy": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-1.2.1.tgz", + "integrity": "sha512-7PQA7EH43S0CxcOa9OeAnaeA0oQ+e/DHNPZwSQM9CQHW76jle5+OvLdibRp/Aafs9KXbLhxyjOTkRjWUbQEd3Q==", + "dependencies": { + "text-decoding": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/@fastify/cors": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz", @@ -1057,6 +1104,14 @@ "glob": "^10.3.4" } }, + "node_modules/@google/generative-ai": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/@google/generative-ai/-/generative-ai-0.11.1.tgz", + "integrity": "sha512-ZiUiJJbl55TXcvu73+Kf/bUhzcRTH/bsGBeYZ9ULqU0imXg3POcd+NVYM9j+TGq4MA73UYwHPmJHwmy+QZEzyQ==", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -2069,6 +2124,23 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/platform-express/node_modules/multer": { + "version": "1.4.4-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", + "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/@nestjs/platform-fastify": { "version": "10.3.8", "resolved": "https://registry.npmjs.org/@nestjs/platform-fastify/-/platform-fastify-10.3.8.tgz", @@ -2556,6 +2628,15 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "dev": true }, + "node_modules/@types/multer": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.11.tgz", + "integrity": "sha512-svK240gr6LVWvv3YGyhLlA+6LRRWA4mnGIU7RcNmgjBYFl6665wcXrRfxGp5tEPVHUNm5FMcmq7too9bxCwX/w==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "20.12.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.8.tgz", @@ -4138,6 +4219,35 @@ "node": ">= 8" } }, + "node_modules/csv": { + "version": "6.3.9", + "resolved": "https://registry.npmjs.org/csv/-/csv-6.3.9.tgz", + "integrity": "sha512-eiN+Qu8NwSLxZYia6WzB8xlX/rAQ/8EgK5A4dIF7Bz96mzcr5dW1jlcNmjG0QWySWKfPdCerH3RQ96ZqqsE8cA==", + "dependencies": { + "csv-generate": "^4.4.1", + "csv-parse": "^5.5.6", + "csv-stringify": "^6.5.0", + "stream-transform": "^3.3.2" + }, + "engines": { + "node": ">= 0.1.90" + } + }, + "node_modules/csv-generate": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/csv-generate/-/csv-generate-4.4.1.tgz", + "integrity": "sha512-O/einO0v4zPmXaOV+sYqGa02VkST4GP5GLpWBNHEouIU7pF3kpGf3D0kCCvX82ydIY4EKkOK+R8b1BYsRXravg==" + }, + "node_modules/csv-parse": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.5.6.tgz", + "integrity": "sha512-uNpm30m/AGSkLxxy7d9yRXpJQFrZzVWLFBkS+6ngPcZkw/5k3L/jjFuj7tVnEpRn+QgmiXr21nDlhCiUK4ij2A==" + }, + "node_modules/csv-stringify": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.5.0.tgz", + "integrity": "sha512-edlXFVKcUx7r8Vx5zQucsuMg4wb/xT6qyz+Sr1vnLrdXqlLD1+UKyWNyZ9zn6mUW1ewmGxrpVwAcChGF0HQ/2Q==" + }, "node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -4329,6 +4439,15 @@ "node": ">=6.0.0" } }, + "node_modules/drange": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5018,6 +5137,70 @@ "toad-cache": "^3.3.0" } }, + "node_modules/fastify-multer": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/fastify-multer/-/fastify-multer-2.0.3.tgz", + "integrity": "sha512-QnFqrRgxmUwWHTgX9uyQSu0C/hmVCfcxopqjApZ4uaZD5W9MJ+nHUlW4+9q7Yd3BRxDIuHvgiM5mjrh6XG8cAA==", + "dependencies": { + "@fastify/busboy": "^1.0.0", + "append-field": "^1.0.0", + "concat-stream": "^2.0.0", + "fastify-plugin": "^2.0.1", + "mkdirp": "^1.0.4", + "on-finished": "^2.3.0", + "type-is": "~1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/fastify-multer/node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/fastify-multer/node_modules/fastify-plugin": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-2.3.4.tgz", + "integrity": "sha512-I+Oaj6p9oiRozbam30sh39BiuiqBda7yK2nmSPVwDCfIBlKnT8YB3MY+pRQc2Fcd07bf6KPGklHJaQ2Qu81TYQ==", + "dependencies": { + "semver": "^7.3.2" + } + }, + "node_modules/fastify-multer/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fastify-multer/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fastify-plugin": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz", @@ -7263,9 +7446,9 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multer": { - "version": "1.4.4-lts.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.4-lts.1.tgz", - "integrity": "sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==", + "version": "1.4.5-lts.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz", + "integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==", "dependencies": { "append-field": "^1.0.0", "busboy": "^1.0.0", @@ -8082,6 +8265,28 @@ "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" }, + "node_modules/randexp": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "dev": true, + "dependencies": { + "drange": "^1.0.2", + "ret": "^0.2.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/randexp/node_modules/ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -8841,6 +9046,11 @@ "node": ">= 0.8" } }, + "node_modules/stream-transform": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-3.3.2.tgz", + "integrity": "sha512-v64PUnPy9Qw94NGuaEMo+9RHQe4jTBYf+NkTtqkCgeuiNo8NlL0LtLR7fkKWNVFtp3RhIm5Dlxkgm5uz7TDimQ==" + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -9262,6 +9472,11 @@ "node": "*" } }, + "node_modules/text-decoding": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/text-decoding/-/text-decoding-1.0.0.tgz", + "integrity": "sha512-/0TJD42KDnVwKmDK6jj3xP7E2MG7SHAOG4tyTgyUCRPdHwvkquYNLEQltmdMa3owq3TkddCVcTsoctJI8VQNKA==" + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", diff --git a/package.json b/package.json index 70a6153f..69d128a1 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@fastify/static": "^7.0.3", + "@google/generative-ai": "^0.11.1", "@nestjs/common": "^10.0.0", "@nestjs/core": "^10.0.0", "@nestjs/jwt": "^10.2.0", @@ -34,18 +35,23 @@ "@nestjs/swagger": "^7.3.1", "@prisma/client": "^5.13.0", "bcrypt": "^5.1.1", + "csv": "^6.3.9", "date-fns": "^3.6.0", + "fastify-multer": "^2.0.3", + "multer": "^1.4.5-lts.1", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "zod": "^3.23.6" }, "devDependencies": { + "@anatine/zod-mock": "^3.13.4", "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", "@types/jest": "^29.5.2", + "@types/multer": "^1.4.11", "@types/node": "^20.3.1", "@types/query-string": "^6.3.0", "@types/supertest": "^6.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index 82663d34..ee4b53ea 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,7 @@ import { SupplyCategoriesModule } from './supply-categories/supply-categories.mo import { ShelterManagersModule } from './shelter-managers/shelter-managers.module'; import { ShelterSupplyModule } from './shelter-supply/shelter-supply.module'; import { PartnersModule } from './partners/partners.module'; +import { ShelterCsvImporterModule } from './shelter-csv-importer/shelter-csv-importer.module'; import { SupportersModule } from './supporters/supporters.module'; @Module({ @@ -26,6 +27,7 @@ import { SupportersModule } from './supporters/supporters.module'; ShelterSupplyModule, PartnersModule, SupportersModule, + ShelterCsvImporterModule, ], controllers: [], providers: [ diff --git a/src/interceptors/file-upload.interceptor.ts b/src/interceptors/file-upload.interceptor.ts new file mode 100644 index 00000000..950758f4 --- /dev/null +++ b/src/interceptors/file-upload.interceptor.ts @@ -0,0 +1,54 @@ +import { + CallHandler, + ExecutionContext, + Inject, + mixin, + NestInterceptor, + Optional, + Type, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import FastifyMulter from 'fastify-multer'; +import { Options, Multer } from 'multer'; + + +export function FastifyFileInterceptor( + fieldName: string, + localOptions: Options, +): Type { + class MixinInterceptor implements NestInterceptor { + protected multer: Multer; + + constructor( + @Optional() + @Inject('MULTER_MODULE_OPTIONS') + options: Multer, + ) { + this.multer = (FastifyMulter as any)({ ...options, ...localOptions }); + } + + async intercept( + context: ExecutionContext, + next: CallHandler, + ): Promise> { + const ctx = context.switchToHttp(); + + await new Promise((resolve, reject) => + this.multer.single(fieldName)( + ctx.getRequest(), + ctx.getResponse(), + (error: any) => { + if (error) { + return reject(error); + } + resolve(); + }, + ), + ); + + return next.handle(); + } + } + const Interceptor = mixin(MixinInterceptor); + return Interceptor as Type; +} diff --git a/src/main.ts b/src/main.ts index 649e75d4..825120ff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,17 +3,20 @@ import { FastifyAdapter, NestFastifyApplication, } from '@nestjs/platform-fastify'; -import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; - +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import multer from 'fastify-multer'; import { AppModule } from './app.module'; async function bootstrap() { const fastifyAdapter = new FastifyAdapter(); + const app = await NestFactory.create( AppModule, fastifyAdapter, ); + app.register(multer().contentParser); + const config = new DocumentBuilder() .setTitle('SOS - Rio Grande do Sul') .setDescription('...') diff --git a/src/shelter-csv-importer/dto/file.dto.ts b/src/shelter-csv-importer/dto/file.dto.ts new file mode 100644 index 00000000..5cfc3df2 --- /dev/null +++ b/src/shelter-csv-importer/dto/file.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class FileDtoStub { + @ApiProperty({ type: 'string', format: 'binary', required: false }) + file?: string; + @ApiProperty({ required: false }) + csvUrl?: string; +} diff --git a/src/shelter-csv-importer/shelter-csv-importer.helpers.ts b/src/shelter-csv-importer/shelter-csv-importer.helpers.ts new file mode 100644 index 00000000..435fbd00 --- /dev/null +++ b/src/shelter-csv-importer/shelter-csv-importer.helpers.ts @@ -0,0 +1,216 @@ +import { + GoogleGenerativeAI, + HarmBlockThreshold, + HarmCategory, +} from '@google/generative-ai'; +import { BadRequestException, Logger } from '@nestjs/common'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; +import { parse as createParser } from 'csv'; +import { FileFilterCallback } from 'fastify-multer/lib/interfaces'; +import { Readable } from 'node:stream'; + +const logger = new Logger('ShelterCsvImporterHelpers'); + +export function createCsvParser() { + return createParser( + { columns: true, relaxColumnCount: true }, + function (err, data) { + if (err) { + logger.error(err); + return null; + } + return data; + }, + ); +} + +export function csvImporterFilter( + _req: Express.Request, + file: Express.Multer.File, + callback: FileFilterCallback, +) { + if (!file.originalname.match(/\.(csv)$/)) { + return callback( + new BadRequestException('Apenas arquivos .csv são aceitos no momento!'), + false, + ); + } + callback(null, true); +} +export function translatePrismaError(err: PrismaClientKnownRequestError) { + switch (err.code) { + case 'P2002': + case 'P2003': + case 'P2004': { + let msg = `PrismaError(${err.code}): Constraint error ocurred`; + if (err.meta?.modelName && err?.meta.target) { + msg += ` for `; + if (Array.isArray(err?.meta.target)) { + for (const prop of err?.meta.target) { + msg += `${err.meta?.modelName}."${prop}"`; + } + } else { + msg += `${err.meta.target}`; + } + } + + return msg; + } + + default: + return err.message; + } +} + +export async function detectSupplyCategoryUsingAI( + input: unknown, + categoriesAvailable: string[], +): Promise> { + if (typeof input !== 'string') { + logger.warn(`Input inesperado recebido: ${input}`); + return {}; + } + const apiKey = process.env.GEMINI_API_KEY; + + if (!apiKey) { + throw new Error('Required ENV variable: GEMINI_API_KEY'); + } + const genAI = new GoogleGenerativeAI(apiKey); + + const model = genAI.getGenerativeModel({ + model: 'gemini-1.5-pro-latest', + }); + + const generationConfig = { + temperature: 1, + topP: 0.95, + topK: 64, + maxOutputTokens: 8192, + responseMimeType: 'application/json', + }; + + const safetySettings = [ + { + category: HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH, + }, + { + category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH, + }, + { + category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH, + }, + { + category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold: HarmBlockThreshold.BLOCK_ONLY_HIGH, + }, + ]; + + const chatSession = model.startChat({ + generationConfig, + safetySettings, + history: [ + { + role: 'user', + parts: [ + { + text: `Você é um classificador de itens donativos para tragédias ambientais.\nCategorize os INPUT em uma das seguintes categorias: ${categoriesAvailable.join(', ')}. Retorne um JSON com a seguitne assinatura: Record. Cada produto pode ter apenas uma categoria.`, + }, + { + text: 'Lanternas, Roupas para crianças, Comidas Não Perecíveis, Pratos, Lençóis, Cadeiras (tipo de praia), Absorventes higiênicos, Kits de primeiros socorros, Fórmula infantil, Colchões, Caixas de papelão, Lixeiras, Carne Gado, Gaze, Termômetro, Álcool, Repelente, Massa, Descarpack Caixa, Mamadeira, Soro, Chupeta/bico, Lenços umedecidos, Sabonetes, Escova de dentes, Shampoo e Condicionador, Plus Size/GG, Pás, Desodorante, Voluntário - Noite, Shampoo, Veterinários, Luvas, Remédios básicos, Roupas íntimas, Medicamentos prescritos, Turnos de manhã, Turnos de noite, Fraldas, Ventilador, Roupa Íntima (adulto) Feminina E Masculina, Luvas De Limpeza, Toalhas de banho, Ventiladores, Vassouras, Sacos de lixo, Pomadas, Feijão, Tênis, Chinelos, Oléo, Roupas para frio, Leite em pó, Gelo, Azitotromicina, Papel higiênico, Sacolinhas plasticas para kits, Gás, Ração para animais, Antibiótico, Suco de caixinha, Jornal (para xixi e coco), Caixa de areia (gato), Voluntários Para Animais (não Necessariamente Veterinário/a), Alimentos Diets, Luz de Emergência, Lanterna, Lonas, Água, Travesseiros/almofada, Chinelos masculinos, Água potável, Itens de limpeza, Roupa Íntima Infantil, Roupas Masculinas G E Gg, Azeite, Roupa íntima masculina e feminina, Roupas femininas G/GG, Roupa plus size, Arroz, Roupas grandes, Esponjas De Louça, Roupas para adultos, Sapato Infantil, Turno da madrugada, Sabão em pó, Pasta de dente, Caminhão Pipa, Fralda RN, Produtos de desinfecção (cloro, álcool), Copos, Voluntário - Madrugada, Roupas Femininas, Roupa Masculina, Roupas Pluz Size, Rolo Lona Preta 40m x 6m (mais grossa), Lona, Fraldas Geriatricas, Cordas, Casinha para cachorro, Lar Temporário Para Animais, Ponto De Resgate, Caixas de transporte para pets, Álcool em gel, Coleiras, Mascara, Cesta Básica, Roupa Masculina Gg, Patê Para Cachorro, Roupa Infantil Menino, Fita Durex larga, Papagaio - Urinar, Papagaio, Cama Geriatria, Escova de cabelo, Toalhas, Cadeira De Rodas, Leitos para animais, SACOS DE AREIA, Caixa De Transporte, Areia de gato, Macarrão, Desinfetante, Café, Pente/Escova de cabelo, Condicionador, Gilete, Alimentos para consumo rápido (Leite, bolacha, suco, etc), Colher Descartável, Lanches, Tapete higiênico, Medicamentos veterinários, Cobertores, Pão, Pão De Sanduíche, Sucos, Papel toalha, TELAS / CHIQUEIROS, Banana, Cebola, Frutas, Alface, Tomate, Luva G, Jogo de cartas, baralho, dama e dominó, etc, Seringa De Insulina, Seringa 3 E 5ml Com Agulha 70x30, ROUPAS INTIMAS - CUECAS - G, Bermuda Masculina, Hipoglós, Talheres, Tapetes higiênicos, Guardanapo de papel, Médicos, Psicólogos, Papelão, Voluntário - Manhã, Roupa Infantil 8-10 Anos, Roupa Masculina Adulta, Assistentes Sociais, Leite, Fralda P, Embalagem descartável para as marmitas, Sabonete infantil, Turnos de tarde, Antipulgas - Animais Pequeno Porte, Higiene Pessoal, Produtos de higiene, Garfo e faca, Pano de chão, Pano de prato, Potes (marmitex), Pilha Aa, Guarda-sol, Pomada para assadura, Fraldas Adultas, Meias, Luvas descartáveis, Baldes, Travesseiro, Talher Descartável, Detergente, Água Sanitária, Chimia, Sabonete Líquido, Luva de Latex Descartável, Pano De Chão / Saco Alvejado, Probiótico Para Animais, Escova Para Limpar Mamadeira, Molho de tomate, Açúcar, Voluntário - Tarde, Roupas leves, Toucas, Luvas Descartáveis, Lenço umedecido, Luminárias com pedestal para área saúde 1m altura pelo menos, Ganchos para montar varal, Freezer para armazenar comida, Máquina de lavar roupa, Luvas para limpeza, Bermudas, Assento para vaso sanitário, Calçado masculino, Jornal/Papelão, Frios, Carne Frango, Farinha, Travesseiros, Fronhas, Elástico Para Cabelo, Roupas plus size feminina, Malas de Viagem, Banheiro Químico (apenas Chuveiro), Bonés, Produtos de limpeza, Pano De Limpeza, Bolachinha, Vassouras e rodos, Fralda XG e XXG, Creme de pentear, Fita adesiva (durex), Roupa íntima feminina, Ração gato, Capas de chuva, Toalha de Banho, Guarda-chuva, Farinha de trigo, Gatorade/Isotônico, Latas de lixo, Massinha De Modelar, Roupas plus size masculino, Saco De Lixo De Vários Tamanhos, Baralhos, Erva Mate, Touca Descartável Sal, Polenta, Calçados, Itens de higienLanternas, Roupas para crianças, Comidas Não Perecíveis, Pratos, Lençóis, Cadeiras (tipo de praia), Absorventes higiênicos, Kits de primeiros socorros, Fórmula infantil, Colchões, Caixas de papelão, Lixeiras, Carne Gado, Gaze, Termômetro, Álcool, Repelente, Massa, Descarpack Caixa, Mamadeira, Soro, Chupeta/bico, Lenços umedecidos, Sabonetes, Escova de dentes, Shampoo e Condicionador, Plus Size/GG, Pás, Desodorante, Voluntário - Noite, Shampoo, Veterinários, Luvas, Remédios básicos, Roupas íntimas, Medicamentos prescritos, Turnos de manhã, Turnos de noite, Fraldas, Ventilador, Roupa Íntima (adulto) Feminina E Masculina, Luvas De Limpeza, Toalhas de banho, Ventiladores, Vassouras, Sacos de lixo, Pomadas, Feijão, Tênis, Chinelos, Oléo, Roupas para frio, Leite em pó, Gelo, Azitotromicina, Papel higiênico, Sacolinhas plasticas para kits, Gás, Ração para animais, Antibiótico, Suco de caixinha, Jornal (para xixi e coco), Caixa de areia (gato), Voluntários Para Animais (não Necessariamente Veterinário/a), Alimentos Diets, Luz de Emergência, Lanterna, Lonas, Água, Travesseiros/almofada, Chinelos masculinos, Água potável, Itens de limpeza, Roupa Íntima Infantil, Roupas Masculinas G E Gg, Azeite, Roupa íntima masculina e feminina, Roupas femininas G/GG, Roupa plus size, Arroz, Roupas grandes, Esponjas De Louça, Roupas para adultos, Sapato Infantil, Turno da madrugada, Sabão em pó, Pasta de dente, Caminhão Pipa, Fralda RN, Produtos de desinfecção (cloro, álcool), Copos, Voluntário - Madrugada, Roupas Femininas, Roupa Masculina, Roupas Pluz Size, Rolo Lona Preta 40m x 6m (mais grossa), Lona, Fraldas Geriatricas, Cordas, Casinha para cachorro, Lar Temporário Para Animais, Ponto De Resgate, Caixas de transporte para pets, Álcool em gel, Coleiras, Mascara, Cesta Básica, Roupa Masculina Gg, Patê Para Cachorro, Roupa Infantil Menino, Fita Durex larga, Papagaio - Urinar, Papagaio, Cama Geriatria, Escova de cabelo, Toalhas, Cadeira De Rodas, Leitos para animais, SACOS DE AREIA, Caixa De Transporte, Areia de gato, Macarrão, Desinfetante, Café, Pente/Escova de cabelo, Condicionador, Gilete, Alimentos para consumo rápido (Leite, bolacha, suco, etc), Colher Descartável, Lanches, Tapete higiênico, Medicamentos veterinários, Cobertores, Pão, Pão De Sanduíche, Sucos, Papel toalha, TELAS / CHIQUEIROS, Banana, Cebola, Frutas, Alface, Tomate, Luva G, Jogo de cartas, baralho, dama e dominó, etc, Seringa De Insulina, Seringa 3 E 5ml Com Agulha 70x30, ROUPAS INTIMAS - CUECAS - G, Bermuda Masculina, Hipoglós, Talheres, Tapetes higiênicos, Guardanapo de papel, Médicos, Psicólogos, Papelão, Voluntário - Manhã, Roupa Infantil 8-10 Anos, Roupa Masculina Adulta, Assistentes Sociais, Leite, Fralda P, Embalagem descartável para as marmitas, Sabonete infantil, Turnos de tarde, Antipulgas - Animais Pequeno Porte, Higiene Pessoal, Produtos de higiene, Garfo e faca, Pano de chão, Pano de prato, Potes (marmitex), Pilha Aa, Guarda-sol, Pomada para assadura, Fraldas Adultas, Meias, Luvas descartáveis, Baldes, Travesseiro, Talher Descartável, Detergente, Água Sanitária, Chimia, Sabonete Líquido, Luva de Latex Descartável, Pano De Chão / Saco Alvejado, Probiótico Para Animais, Escova Para Limpar Mamadeira, Molho de tomate, Açúcar, Voluntário - Tarde, Roupas leves, Toucas, Luvas Descartáveis, Lenço umedecido, Luminárias com pedestal para área saúde 1m altura pelo menos, Ganchos para montar varal, Freezer para armazenar comida, Máquina de lavar roupa, Luvas para limpeza, Bermudas, Assento para vaso sanitário, Calçado masculino, Jornal/Papelão, Frios, Carne Frango, Farinha, Travesseiros, Fronhas, Elástico Para Cabelo, Roupas plus size feminina, Malas de Viagem, Banheiro Químico (apenas Chuveiro), Bonés, Produtos de limpeza, Pano De Limpeza, Bolachinha, Vassouras e rodos, Fralda XG e XXG, Creme de pentear, Fita adesiva (durex), Roupa íntima feminina, Ração gato, Capas de chuva, Toalha de Banho, Guarda-chuva, Farinha de trigo, Gatorade/Isotônico, Latas de lixo, Massinha De Modelar, Roupas plus size masculino, Saco De Lixo De Vários Tamanhos, Baralhos, Erva Mate, Touca Descartável, Sal, Polenta, Calçados, Itens de higiena pessoal, Achocolatado pronto, Roupas de Camas, Sacolas Para Montar Kits, Sacolas ou sacos plásticos, Técnico De Enfermagem, Enfermeirosa pessoal, Achocolatado pronto, Roupas de Camas, Sacolas Para Montar Kits, Sacolas ou sacos plásticos, Técnico De Enfermagem, Enfermeiros', + }, + ], + }, + { + role: 'model', + parts: [ + { + text: '{"medicamentos": ["Kits de primeiros socorros", "Gaze", "Termômetro", "Álcool", "Repelente", "Soro", "Remédios básicos", "Medicamentos prescritos", "Pomadas", "Azitotromicina", "Antibiótico", "Álcool em gel", "Pomada para assadura", "Desinfetante", "Medicamentos veterinários", "Seringa De Insulina", "Seringa 3 E 5ml Com Agulha 70x30", "Hipoglós", "Antipulgas - Animais Pequeno Porte", "Probiótico Para Animais"], "cuidados com animais": ["Ração para animais", "Caixa de areia (gato)", "Voluntários Para Animais (não Necessariamente Veterinário/a)", "Coleiras", "Patê Para Cachorro", "Casinha para cachorro", "Lar Temporário Para Animais", "Caixas de transporte para pets", "Leitos para animais", "SACOS DE AREIA", "Caixa De Transporte", "Areia de gato", "TELAS / CHIQUEIROS", "Tapetes higiênicos", "Ração gato"], "especialistas e profissionais": ["Veterinários", "Voluntário - Noite", "Turnos de manhã", "Turnos de noite", "Turno da madrugada", "Voluntário - Madrugada", "Voluntário - Manhã", "Médicos", "Psicólogos", "Assistentes Sociais", "Turnos de tarde", "Voluntário - Tarde", "Técnico De Enfermagem", "Enfermeiros"], "acomodações e descanso": ["Lençóis", "Cadeiras (tipo de praia)", "Colchões", "Lonas", "Travesseiros/almofada", "Rolo Lona Preta 40m x 6m (mais grossa)", "Lona", "Cordas", "Cama Geriatria", "Cobertores", "Guarda-sol", "Travesseiro", "Luminárias com pedestal para área saúde 1m altura pelo menos", "Ganchos para montar varal", "Bermudas", "Capas de chuva", "Guarda-chuva", "Roupas de Camas"], "equipamentos de emergência": ["Lanternas", "Luz de Emergência", "Lanterna", "Ventilador", "Ventiladores", "Caminhão Pipa", "Fita Durex larga", "Cadeira De Rodas", "Pilha Aa", "Baldes"], "voluntariado": ["Voluntário - Noite", "Turnos de manhã", "Turnos de noite", "Turno da madrugada", "Voluntário - Madrugada", "Voluntário - Manhã", "Turnos de tarde", "Voluntário - Tarde"], "itens descartáveis": ["Pratos", "Descarpack Caixa", "Mamadeira", "Chupeta/bico", "Fraldas", "Sacos de lixo", "Sacolinhas plasticas para kits", "Jornal (para xixi e coco)", "Copos", "Papagaio - Urinar", "Colher Descartável", "Papel toalha", "Guardanapo de papel", "Embalagem descartável para as marmitas", "Garfo e faca", "Potes (marmitex)", "Talher Descartável", "Luva de Latex Descartável", "Escova Para Limpar Mamadeira", "Toucas", "Luvas Descartáveis", "Lenço umedecido", "Assento para vaso sanitário", "Jornal/Papelão", "Touca Descartável", "Sacolas Para Montar Kits", "Sacolas ou sacos plásticos"], "higiene pessoal": ["Absorventes higiênicos", "Lenços umedecidos", "Sabonetes", "Escova de dentes", "Shampoo e Condicionador", "Pás", "Desodorante", "Shampoo", "Roupas íntimas", "Roupa Íntima (adulto) Feminina E Masculina", "Toalhas de banho", "Papel higiênico", "Roupa Íntima Infantil", "Sabão em pó", "Pasta de dente", "Produtos de desinfecção (cloro, álcool)", "Escova de cabelo", "Toalhas", "Pente/Escova de cabelo", "Condicionador", "Gilete", "Tapete higiênico", "Sabonete infantil", "Higiene Pessoal", "Produtos de higiene", "Pano de chão", "Pano de prato", "Detergente", "Água Sanitária", "Chimia", "Sabonete Líquido", "Pano De Chão / Saco Alvejado", "Esponjas De Louça", "Lenço umedecido", "Luvas para limpeza", "Produtos de limpeza", "Pano De Limpeza", "Creme de pentear", "Toalha de Banho", "Itens de higiena pessoal"], "alimentos e água": ["Roupas para crianças", "Comidas Não Perecíveis", "Fórmula infantil", "Carne Gado", "Massa", "Feijão", "Oléo", "Leite em pó", "Gelo", "Suco de caixinha", "Alimentos Diets", "Água", "Chinelos masculinos", "Água potável", "Azeite", "Arroz", "Macarrão", "Café", "Alimentos para consumo rápido (Leite, bolacha, suco, etc)", "Lanches", "Pão", "Pão De Sanduíche", "Sucos", "Banana", "Cebola", "Frutas", "Alface", "Tomate", "Leite", "Molho de tomate", "Açúcar", "Frios", "Carne Frango", "Farinha", "Bolachinha", "Farinha de trigo", "Gatorade/Isotônico", "Massinha De Modelar", "Sal", "Polenta", "Achocolatado pronto"], "material de limpeza": ["Caixas de papelão", "Lixeiras", "Luvas De Limpeza", "Vassouras", "Sacos de lixo", "Itens de limpeza", "Produtos de desinfecção (cloro, álcool)", "Desinfetante", "Vassouras e rodos", "Latas de lixo", "Saco De Lixo De Vários Tamanhos"], "vestuário": ["Roupas para crianças", "Plus Size/GG", "Luvas", "Roupas para frio", "Roupas Masculinas G E Gg", "Roupa íntima masculina e feminina", "Roupas femininas G/GG", "Roupa plus size", "Roupas grandes", "Roupas para adultos", "Sapato Infantil", "Roupas Femininas", "Roupa Masculina", "Roupas Pluz Size", "Fraldas Geriatricas", "Mascara", "Roupa Masculina Gg", "Roupa Infantil Menino", "Roupas INTIMAS - CUECAS - G", "Bermuda Masculina", "Roupa Infantil 8-10 Anos", "Roupa Masculina Adulta", "Fralda P", "Fraldas Adultas", "Meias", "Roupas leves", "Bermudas", "Calçado masculino", "Roupas plus size feminina", "Bonés", "Roupa íntima feminina", "Fralda XG e XXG", "Roupas plus size masculino", "Calçados", "Roupa Íntima Infantil"], "veículos de resgate e transporte": ["Caminhão Pipa"], "eletrodomésticos e eletrônicos": ["Ventilador", "Ventiladores", "Luz de Emergência", "Lanterna", "Freezer para armazenar comida", "Máquina de lavar roupa", "Luminárias com pedestal para área saúde 1m altura pelo menos"], "mobílias": ["Cadeiras (tipo de praia)", "Colchões", "Cama Geriatria"], "jogos e passatempo": ["Jogo de cartas, baralho, dama e dominó, etc", "Baralhos"], "cosméticos": ["Shampoo e Condicionador", "Pás", "Desodorante", "Shampoo", "Creme de pentear"]}\n', + }, + ], + }, + ], + }); + + let promptOutput: Record = {}; + try { + const result = await chatSession.sendMessage(input); + const response = result.response.text(); + promptOutput = JSON.parse(response); + } catch (error) { + logger.error(error); + } + + return promptOutput; +} + +export const emptyReadable = () => + new Readable({ + read() { + this.push(null); + }, + }); + +export function csvResponseToReadable(response: Response) { + const contentType = response.headers.get('content-type'); + console.log( + '🚀 ~ ShelterCsvImporterService ~ awaitfetch ~ contentType:', + contentType, + ); + const reader = response.body?.getReader(); + + if ( + !reader || + !contentType || + !contentType.toLowerCase().includes('text/csv') + ) { + logger.warn( + `reader não encontrado ou content-type não permitido. "${contentType}"`, + ); + return emptyReadable(); + } + + return new Readable({ + async read() { + const result = await reader.read(); + if (!result.done) { + this.push(Buffer.from(result.value)); + } else { + this.push(null); + return; + } + }, + }); +} +/** + * Classe utilitária apenas com propósito de facilitar logging de processamento em stream + */ +export class AtomicCounter { + private _successCount = 0; + private _totalCount = 0; + private _failureCount = 0; + + public get totalCount() { + return this._totalCount; + } + public get successCount() { + return this._successCount; + } + public get failureCount() { + return this._failureCount; + } + + incrementSuccess(amount?: number) { + this._successCount += amount ?? 1; + } + + increment(amount?: number) { + this._totalCount += amount ?? 1; + } + + incrementFailure(amount?: number) { + this._failureCount += amount ?? 1; + } +} diff --git a/src/shelter-csv-importer/shelter-csv-importer.module.ts b/src/shelter-csv-importer/shelter-csv-importer.module.ts new file mode 100644 index 00000000..5bd05368 --- /dev/null +++ b/src/shelter-csv-importer/shelter-csv-importer.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ShelterCsvImporterService } from './shelter-csv-importer.service'; + +@Module({ + imports: [PrismaModule], + providers: [ShelterCsvImporterService], + exports: [ShelterCsvImporterService], +}) +export class ShelterCsvImporterModule {} diff --git a/src/shelter-csv-importer/shelter-csv-importer.service.spec.ts b/src/shelter-csv-importer/shelter-csv-importer.service.spec.ts new file mode 100644 index 00000000..e2a3d870 --- /dev/null +++ b/src/shelter-csv-importer/shelter-csv-importer.service.spec.ts @@ -0,0 +1,115 @@ +import { generateMock } from '@anatine/zod-mock'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Readable } from 'node:stream'; +import { ShelterSchema } from 'src/shelter/types/types'; +import { SupplyCategorySchema } from 'src/supply-categories/types'; +import { SupplySchema } from 'src/supply/types'; +import { PrismaService } from '../prisma/prisma.service'; +import * as helpers from './shelter-csv-importer.helpers'; +import { ShelterCsvImporterService } from './shelter-csv-importer.service'; + +describe('ShelterCsvImporterService', () => { + let service: ShelterCsvImporterService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ShelterCsvImporterService], + }) + .useMocker((token) => { + if (token !== PrismaService) return; + + return { + shelter: { + create: jest.fn().mockResolvedValue(generateMock(ShelterSchema)), + }, + supply: { + findMany: jest.fn().mockResolvedValue([generateMock(SupplySchema)]), + }, + supplyCategory: { + findMany: jest + .fn() + .mockResolvedValue([generateMock(SupplyCategorySchema)]), + }, + $transaction: jest + .fn(PrismaService.prototype.$transaction) + .mockResolvedValue([ + [generateMock(SupplyCategorySchema)], + [generateMock(SupplySchema)], + ]), + }; + }) + .compile(); + + service = module.get(ShelterCsvImporterService); + }); + + test('test_shelterToCsv_withoutRequiredInputs', async () => { + await expect(service.execute({ headers: {} } as any)).rejects.toThrow( + 'Um dos campos `csvUrl` ou `fileStream` é obrigatório', + ); + }); + + test('test_shelterToCsv_withValidFileStream', async () => { + const mockFileStream = new Readable(); + mockFileStream.push('name,address,lat,lng,number,street, capacity\n'); + mockFileStream.push('name,address,1.0214,1.54525, 1234, Street,10.123'); + mockFileStream.push(null); + + jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + body: mockFileStream, + } as any); + + jest + .spyOn(helpers, 'detectSupplyCategoryUsingAI') + .mockResolvedValueOnce( + require('examples/gemini_prompt_response_example.json'), + ); + + const result = await service.execute({ + fileStream: mockFileStream, + headers: { + nameField: 'name', + addressField: 'address', + latitudeField: 'lat', + longitudeField: 'lng', + streetField: 'street', + streetNumberField: 'number', + capacityField: 'capacity', + }, + }); + + expect(result.failureCount).toBe(0); + expect(result.successCount).toBeGreaterThan(0); + expect(result.totalCount).toBeGreaterThan(0); + }); + + test('test_shelterToCsv_missingSuppliesCategorization', async () => { + const mockFileStream = new Readable(); + mockFileStream.push('name,address,lat,lng,supplies, UnknownSupply\n'); + mockFileStream.push('name,address,1.0214,1.54525, UnknownSupply'); + mockFileStream.push(null); + + jest.spyOn(global, 'fetch').mockResolvedValueOnce({ + body: mockFileStream, + } as any); + + jest.spyOn(helpers, 'detectSupplyCategoryUsingAI').mockResolvedValue({ + UnknownSupply: ['NewCategory'], + }); + + const result = await service.execute({ + fileStream: mockFileStream, + headers: { + nameField: 'name', + addressField: 'address', + latitudeField: 'lat', + shelterSuppliesField: 'supplies', + longitudeField: 'lng', + }, + }); + + expect(result.failureCount).toBe(0); + expect(result.totalCount).toBeGreaterThan(0); + expect(result.successCount).toBeGreaterThan(0); + }); +}); diff --git a/src/shelter-csv-importer/shelter-csv-importer.service.ts b/src/shelter-csv-importer/shelter-csv-importer.service.ts new file mode 100644 index 00000000..b8c59be0 --- /dev/null +++ b/src/shelter-csv-importer/shelter-csv-importer.service.ts @@ -0,0 +1,270 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; +import { Duplex, Readable, Transform, Writable } from 'node:stream'; +import { TransformStream } from 'node:stream/web'; + +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { + AtomicCounter, + createCsvParser, + detectSupplyCategoryUsingAI, + csvResponseToReadable, + translatePrismaError, +} from './shelter-csv-importer.helpers'; +import { + CsvToShelterTransformStream, + ShelterEnhancedStreamTransformer, +} from './shelter-csv.transformer'; +import { CSV_DEFAULT_HEADERS, ShelterColumHeader, ShelterInput } from './types'; +import { ShelterCsvImporterExecutionArgs, ShelterValidInput } from './types'; + +@Injectable() +export class ShelterCsvImporterService { + private readonly logger = new Logger(ShelterCsvImporterService.name); + constructor(private readonly prismaService: PrismaService) {} + + /** + * ```js + * // Pode ser uma stream de arquivo + * const fileSourceStream = createReadStream(__dirname + '/planilha_porto_alegre - comida.csv'); + * // Ou uma url de um arquivo CSV + * const csvUrl = 'https://docs.google.com/spreadsheets/d/18hY52i65lIdLE2UsugjnKdnE_ubrBCI6nCR0XQurSBk/gviz/tq?tqx=out:csv&sheet=planilha_porto_alegre'; + * // passar os headers + * parseCsv({fileStream: fileSourceStream, csvUrl,headers:{ nameField:'nome' ,addressField:'endereco',latitudeField:'lat'}}) + * ``` + * + */ + async execute({ + headers = CSV_DEFAULT_HEADERS, + csvUrl, + fileStream, + dryRun = false, + useIAToPredictSupplyCategories = Boolean( + process.env.CSV_IMPORTER_USE_IA_TO_PREDICT_SUPPLY_CATEGORIES, + ), + useBatchTransaction = false, + onEntity, + }: ShelterCsvImporterExecutionArgs) { + this.validateInput(csvUrl, fileStream); + + const efffectiveColumnNames = this.getEffectiveColumnNames(headers); + + const atomicCounter = new AtomicCounter(); + + const output: Record[] = []; + + let csvSourceStream = csvUrl + ? csvResponseToReadable(await fetch(csvUrl)) + : fileStream!; + + const { + entitiesToCreate, + categoriesAvailable, + shelterSupliesByCategory, + suppliesAvailable, + } = await this.preProcessPipeline( + csvSourceStream, + efffectiveColumnNames, + atomicCounter, + useIAToPredictSupplyCategories, + ); + + // const transactionArgs: ReturnType[] = [] + const transactionArgs: Prisma.ShelterCreateArgs[] = []; + + await Readable.toWeb(Readable.from(entitiesToCreate)) + .pipeThrough( + new ShelterEnhancedStreamTransformer({ + categoriesAvailable, + shelterSupliesByCategory, + suppliesAvailable, + counter: atomicCounter, + }), + ) + .pipeThrough( + new TransformStream({ + // transform(chunk,controller){ + // } + }), + ) + .pipeTo( + new WritableStream({ + write: async (shelter: ShelterValidInput) => { + if (dryRun) { + onEntity?.(shelter); + atomicCounter.incrementSuccess(); + return; + } + + if (useBatchTransaction) { + transactionArgs.push(this.getShelterCreateArgs(shelter)); + return; + } + + await this.prismaService.shelter + .create(this.getShelterCreateArgs(shelter)) + .then((d) => { + atomicCounter.incrementSuccess(); + onEntity?.(d); + output.push(d); + }) + .catch((e: Error) => { + atomicCounter.incrementFailure(); + if (e instanceof PrismaClientKnownRequestError) { + this.logger.error(translatePrismaError(e)); + } else { + this.logger.error(e); + } + }); + }, + close: async () => { + if (useBatchTransaction && !dryRun) { + try { + const transactionResult = await this.prismaService.$transaction( + transactionArgs.map(this.prismaService.shelter.create), + ); + output.push(...transactionResult); + atomicCounter.incrementSuccess(transactionResult.length); + atomicCounter.incrementFailure( + transactionResult.length - transactionArgs.length, + ); + } catch (err) { + this.logger.error('Erro ao executar transaction', err); + atomicCounter.incrementFailure( + transactionArgs.length - atomicCounter.successCount, + ); + } + } + this.logger.log( + `${atomicCounter.successCount} de ${atomicCounter.totalCount} entidades processadas com sucesso. ${atomicCounter.failureCount} com erro.`, + ); + }, + }), + ); + + return { + successCount: atomicCounter.successCount, + totalCount: atomicCounter.totalCount, + failureCount: atomicCounter.failureCount, + data: output, + }; + } + + private getEffectiveColumnNames(headers: Partial) { + const efffectiveColumnNames = {} as ShelterColumHeader; + Object.entries(CSV_DEFAULT_HEADERS).forEach(([key, value]) => { + efffectiveColumnNames[key] = + typeof headers[key] === 'string' ? headers[key] : value; + }); + return efffectiveColumnNames; + } + + private validateInput(csvUrl?: string, fileStream?: Readable) { + const validInput = (csvUrl && URL.canParse(csvUrl)) || fileStream != null; + + if (!validInput) { + this.logger.warn('Um dos campos `csvUrl` ou `fileStream` é obrigatório'); + throw new Error('Um dos campos `csvUrl` ou `fileStream` é obrigatório'); + } + } + + private async preProcessPipeline( + csvSourceStream: Readable, + headers: ShelterColumHeader, + atomicCounter: AtomicCounter, + useIAToPredictSupplyCategories: boolean, + ) { + const [categories, supplies] = await this.prismaService.$transaction([ + this.prismaService.supplyCategory.findMany({}), + this.prismaService.supply.findMany({ distinct: ['name'] }), + ]); + + const entitiesToCreate: ShelterInput[] = []; + const missingShelterSupplies = new Set(); + + const suppliesAvailable = supplies.reduce((acc, item) => { + acc.set(item.name.trim().toLowerCase(), item.id); + return acc; + }, new Map()); + + const categoriesAvailable = categories.reduce((acc, item) => { + acc.set(item.name.trim().toLowerCase(), item.id); + return acc; + }, new Map()); + + let shelterSupliesByCategory: Record = {}; + await Readable.toWeb(csvSourceStream) + .pipeThrough(Transform.toWeb(createCsvParser())) + .pipeThrough(new CsvToShelterTransformStream(headers)) + .pipeThrough( + new TransformStream({ + transform(shelter, controller) { + atomicCounter.increment(); + if (shelter?.supplies?.length) { + for (const supply of shelter.supplies) { + if (suppliesAvailable.has(supply)) { + continue; + } + if (!missingShelterSupplies.has(supply)) { + missingShelterSupplies.add(supply); + } + } + } + entitiesToCreate.push(shelter); + controller.enqueue(shelter); + }, + }), + ) + .pipeTo( + new WritableStream({ + async close() { + if (!useIAToPredictSupplyCategories) { + return; + } + const missingSheltersString = Array.from( + missingShelterSupplies, + ).join(', '); + shelterSupliesByCategory = await detectSupplyCategoryUsingAI( + missingSheltersString, + Array.from(categoriesAvailable.keys()), + ); + }, + }), + ); + return { + entitiesToCreate, + categoriesAvailable, + shelterSupliesByCategory, + suppliesAvailable, + }; + } + + private getShelterCreateArgs( + shelter: ShelterValidInput, + ): Prisma.ShelterCreateArgs { + return { + data: Object.assign(shelter, { + createdAt: new Date().toISOString(), + }), + include: { + shelterSupplies: { + select: { + supply: { + select: { + id: true, + name: true, + supplyCategory: { + select: { + name: true, + id: true, + }, + }, + }, + }, + }, + }, + }, + }; + } +} diff --git a/src/shelter-csv-importer/shelter-csv.transformer.ts b/src/shelter-csv-importer/shelter-csv.transformer.ts new file mode 100644 index 00000000..5a0c404f --- /dev/null +++ b/src/shelter-csv-importer/shelter-csv.transformer.ts @@ -0,0 +1,162 @@ +import { Logger } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { TransformStream } from 'node:stream/web'; +import { CreateShelterSchema } from '../shelter/types/types'; +import { AtomicCounter } from './shelter-csv-importer.helpers'; +import { + COLON_REGEX, EnhancedTransformArgs, + ShelterColumHeader, + ShelterInput +} from './types'; + +/** + * JSON -> ShelterInput + * @see ShelterInput + */ +export class CsvToShelterTransformStream extends TransformStream< + unknown, + ShelterInput +> { + private readonly logger = new Logger(CsvToShelterTransformStream.name); + /** + * Espera um Ojeto contento a assinatura da entidade esperada + * @param columnNames dicionário com nomes das colunas a serem mapeadas + */ + constructor(columnNames: ShelterColumHeader) { + + super({ + transform: async (chunk, controller) => { + if (!chunk || (chunk && typeof chunk !== 'object')) { + this.logger.warn('Invalid chunk received', chunk); + return; + } + let supplies: string[] = []; + + if ( + typeof chunk[columnNames.shelterSuppliesField] === 'string' + ) { + supplies = (chunk[columnNames.shelterSuppliesField]) + .split(COLON_REGEX) + .filter(Boolean) + .map((s) => s.trim()); + } + + const shelter: ShelterInput = { + verified: false, + // Removendo duplicidades + supplies: [...new Set(supplies)], + }; + + Object.keys(Prisma.ShelterScalarFieldEnum).forEach((key) => { + shelter[key] ??= chunk[columnNames[`${key}Field`]]; + }); + + controller.enqueue(shelter); + } + }); + } +} +/** + * Valida o schema do Input, enriquece o modelo e adicionas as relações encontradas + */ +export class ShelterEnhancedStreamTransformer extends TransformStream< + ShelterInput, + ReturnType +> { + private counter!: AtomicCounter; + private readonly logger = new Logger(CsvToShelterTransformStream.name); + /** + * + */ + constructor({ + shelterSupliesByCategory, + suppliesAvailable, + categoriesAvailable, + counter, + }: EnhancedTransformArgs) { + super({ + transform: async (shelter: ShelterInput, controller) => { + this.counter ??= counter || new AtomicCounter(); + if (!shelter.supplies) { + try { + controller.enqueue(CreateShelterSchema.parse(shelter)); + } catch (error) { + this.counter.incrementFailure(); + this.logger.error(error, shelter); + } + return; + } + + const missingSupplyNames = new Set(); + + const toCreate: Prisma.ShelterSupplyCreateNestedManyWithoutShelterInput['create'] = + []; + + for (const supplyName of missingSupplyNames.values()) { + for (const [categoryName, values] of Object.entries( + shelterSupliesByCategory, + )) { + const indexFound = values.findIndex( + (item) => item.toLowerCase() === supplyName.toLowerCase(), + ); + if (indexFound !== -1) { + toCreate.push({ + supplyId: suppliesAvailable.get(supplyName.toLowerCase())!, + createdAt: new Date().toISOString(), + supply: { + connect: { + id: suppliesAvailable.get(supplyName.toLowerCase())!, + name: supplyName, + }, + connectOrCreate: { + where: { + id: suppliesAvailable.get(supplyName.toLowerCase()), + }, + create: { + name: supplyName, + createdAt: new Date().toISOString(), + supplyCategory: { + connect: { + name: categoryName, + id: categoriesAvailable.get( + categoryName.toLowerCase(), + ), + }, + }, + }, + }, + }, + }); + } + } + } + shelter.shelterSupplies = { + create: toCreate, + }; + + if (shelter.latitude) { + shelter.latitude = Number.parseFloat(`${shelter.latitude}`); + } + if (shelter.longitude) { + shelter.longitude = Number.parseFloat(`${shelter.longitude}`); + } + + Object.keys(shelter).forEach((key) => { + if ( + typeof shelter[key] === 'string' && + shelter[key].trim().length === 0 + ) { + shelter[key] = null; + } + }); + + await CreateShelterSchema.parseAsync(shelter) + .then((s) => controller.enqueue(s)) + .catch((e) => { + this.counter.incrementFailure(); + this.logger.error(e.message, shelter); + }); + }, + }); + } +} diff --git a/src/shelter-csv-importer/types.ts b/src/shelter-csv-importer/types.ts new file mode 100644 index 00000000..6d6df232 --- /dev/null +++ b/src/shelter-csv-importer/types.ts @@ -0,0 +1,114 @@ +import { AtomicCounter } from './shelter-csv-importer.helpers'; + +import { Prisma } from '@prisma/client'; +import { Readable } from 'node:stream'; +import { CreateShelterSchema } from '../shelter/types/types'; + +type ShelterKey = Exclude< + Prisma.ShelterScalarFieldEnum, + 'createdAt' | 'updatedAt' | 'prioritySum' | 'verified' | 'id' +>; + +export type ShelterColumHeader = Record<`${ShelterKey}Field`, string> & { + shelterSuppliesField: string; +}; + +interface ParseCsvArgsBaseArgs> { + /** + * link do arquivo CSV + */ + csvUrl?: string; + /** + * stream proveniente de algum arquivo CSV + */ + fileStream?: Readable; + /** + * mapeamento de quais cabeçalhos do csv serão usados como colunas da tabela. + */ + headers?: Partial; + /** + * se true, não salvará nada no banco + */ + dryRun?: boolean; +} + +export type ParseCsvArgs = + | (ParseCsvArgsBaseArgs & { csvUrl: string; fileStream?: Readable }) + | (ParseCsvArgsBaseArgs & { fileStream: Readable; csvUrl?: string }); +export interface EnhancedTransformArgs { + /** + * KeyValue contento as categorias e os supplies contidos naquela categorias (detectados por IA) + */ + shelterSupliesByCategory: Record; + /** + * KeyValue com Suprimentos já encontrados na base atual que podem ser utilizadas em relações + * @example + * { "Óleo de cozinha": "eb1d5056-8b9b-455d-a179-172a747e3f20", + * "Álcool em gel": "a3e3bdf8-0be4-4bdc-a3b0-b40ba931be5f" + * } + */ + suppliesAvailable: Map; + /** + * KeyValue com Categorias já encontradas na base atual que podem ser utilizadas em relações + * @example + * { "Higiene Pessoal": "718d5be3-69c3-4216-97f1-12b690d0eb97", + * "Alimentos e Água": "a3e3bdf8-0be4-4bdc-a3b0-b40ba931be5f" + * } + */ + categoriesAvailable: Map; + counter?: AtomicCounter; +} +export type ShelterInput = Partial< + Prisma.ShelterCreateInput & { supplies: string[] } +>; + +/** + * Exemplo de Padrão utilizado: + * `nome_do_local, endereco, whatsapp, lat, lng, itens_disponiveis, itens_em_falta` + */ +export const CSV_DEFAULT_HEADERS: ShelterColumHeader = { + nameField: 'nome_do_local', + addressField: 'endereco', + contactField: 'whatsapp', + latitudeField: 'lat', + longitudeField: 'lng', + shelterSuppliesField: 'itens_em_falta', + capacityField: 'capacidade', + cityField: 'cidade', + neighbourhoodField: 'bairro', + petFriendlyField: 'pet_friendly', + pixField: 'pix', + shelteredPeopleField: 'pessoas_abrigadas', + streetField: 'rua', + streetNumberField: 'numero', + zipCodeField: 'cep', +}; + +/** + * Regex que ignora vírgulas dentro de parenteses no split + */ +export const COLON_REGEX = /(? { + id?: string; +} +export type ShelterCsvImporterExecutionArgs = + ParseCsvArgs & { + /** + * Se deverá usar alguma LLM para tentar categorizar as categorias dos suprimentos + * @implNote por enquanto apenas `Gemini` foi implementada. + */ + useIAToPredictSupplyCategories?: boolean; + /** + * + * callback executado após cada entidade ser criada ou ser validada (caso `dryRun` seja true) + * + * ** NÃO será executada caso `useBatchTransaction` seja true + */ + onEntity?: (shelter: ShelterValidInput) => void; + /** + * Se true, guardará todas as criações em memória e executará elas em um batch `$transaction` + * [Prisma $transaction docs](https://www.prisma.io/docs/concepts/components/prisma-client/transactions). + */ + useBatchTransaction?: boolean; + }; diff --git a/src/shelter/shelter.controller.spec.ts b/src/shelter/shelter.controller.spec.ts index eab66b66..937eab15 100644 --- a/src/shelter/shelter.controller.spec.ts +++ b/src/shelter/shelter.controller.spec.ts @@ -2,6 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PrismaService } from 'src/prisma/prisma.service'; import { ShelterController } from './shelter.controller'; import { ShelterService } from './shelter.service'; +import { ShelterCsvImporterService } from 'src/shelter-csv-importer/shelter-csv-importer.service'; describe('ShelterController', () => { let controller: ShelterController; @@ -9,7 +10,7 @@ describe('ShelterController', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ShelterController], - providers: [ShelterService], + providers: [ShelterService, ShelterCsvImporterService], }) .useMocker((token) => { if (token === PrismaService) { diff --git a/src/shelter/shelter.controller.ts b/src/shelter/shelter.controller.ts index 24603857..f0a4535d 100644 --- a/src/shelter/shelter.controller.ts +++ b/src/shelter/shelter.controller.ts @@ -8,22 +8,37 @@ import { Post, Put, Query, + Req, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiConsumes, ApiTags } from '@nestjs/swagger'; -import { ShelterService } from './shelter.service'; -import { ServerResponse } from '../utils'; -import { StaffGuard } from '@/guards/staff.guard'; -import { ApplyUser } from '@/guards/apply-user.guard'; import { UserDecorator } from '@/decorators/UserDecorator/user.decorator'; +import { ApplyUser } from '@/guards/apply-user.guard'; +import { StaffGuard } from '@/guards/staff.guard'; +import { FastifyFileInterceptor } from '@/interceptors/file-upload.interceptor'; +import { ServerResponse } from '../utils'; +import { ShelterService } from './shelter.service'; + +import { AdminGuard } from '@/guards/admin.guard'; +import { createReadStream, rmSync } from 'fs'; +import { diskStorage } from 'multer'; +import { FileDtoStub } from 'src/shelter-csv-importer/dto/file.dto'; +import { csvImporterFilter } from 'src/shelter-csv-importer/shelter-csv-importer.helpers'; +import { ShelterCsvImporterService } from 'src/shelter-csv-importer/shelter-csv-importer.service'; +import { Readable } from 'stream'; @ApiTags('Abrigos') @Controller('shelters') export class ShelterController { private logger = new Logger(ShelterController.name); - constructor(private readonly shelterService: ShelterService) {} + constructor( + private readonly shelterService: ShelterService, + private readonly shelterCsvImporter: ShelterCsvImporterService, + ) {} @Get('') async index(@Query() query) { @@ -95,4 +110,37 @@ export class ShelterController { throw new HttpException(err?.code ?? err?.name ?? `${err}`, 400); } } + + @UseGuards(AdminGuard) + @Post('import-csv') + @ApiConsumes('multipart/form-data', 'text/csv') + @UseInterceptors( + FastifyFileInterceptor('file', { + storage: diskStorage({ + filename: (_req, file, cb) => cb(null, file.originalname), + }), + fileFilter: csvImporterFilter, + }), + ) + async importFromCsv( + @Req() _req: Request, + @UploadedFile() file: Express.Multer.File, + @Body() body: FileDtoStub, + ) { + let fileStream!: Readable; + if (file?.path) { + fileStream = createReadStream(file.path); + } + + const res = await this.shelterCsvImporter.execute({ + fileStream, + csvUrl: body?.csvUrl, + useBatchTransaction: true, + }); + if (file?.path) { + rmSync(file.path); + } + + return res; + } } diff --git a/src/shelter/shelter.module.ts b/src/shelter/shelter.module.ts index 7859237a..f03f1a8a 100644 --- a/src/shelter/shelter.module.ts +++ b/src/shelter/shelter.module.ts @@ -3,9 +3,10 @@ import { Module } from '@nestjs/common'; import { ShelterService } from './shelter.service'; import { ShelterController } from './shelter.controller'; import { PrismaModule } from '../prisma/prisma.module'; +import { ShelterCsvImporterModule } from 'src/shelter-csv-importer/shelter-csv-importer.module'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, ShelterCsvImporterModule], providers: [ShelterService], controllers: [ShelterController], }) diff --git a/test/examples/gemini_prompt_response_example.json b/test/examples/gemini_prompt_response_example.json new file mode 100644 index 00000000..b36881e6 --- /dev/null +++ b/test/examples/gemini_prompt_response_example.json @@ -0,0 +1,291 @@ +{ + "medicamentos": [ + "Kits de primeiros socorros", + "Gaze", + "Termômetro", + "Álcool", + "Repelente", + "Soro", + "Remédios básicos", + "Medicamentos prescritos", + "Pomadas", + "Azitotromicina", + "Antibiótico", + "Álcool em gel", + "Pomada para assadura", + "Desinfetante", + "Medicamentos veterinários", + "Seringa De Insulina", + "Seringa 3 E 5ml Com Agulha 70x30", + "Hipoglós", + "Antipulgas - Animais Pequeno Porte", + "Probiótico Para Animais" + ], + "cuidados com animais": [ + "Ração para animais", + "Caixa de areia (gato)", + "Voluntários Para Animais (não Necessariamente Veterinário/a)", + "Coleiras", + "Patê Para Cachorro", + "Casinha para cachorro", + "Lar Temporário Para Animais", + "Caixas de transporte para pets", + "Leitos para animais", + "SACOS DE AREIA", + "Caixa De Transporte", + "Areia de gato", + "TELAS / CHIQUEIROS", + "Tapetes higiênicos", + "Ração gato" + ], + "especialistas e profissionais": [ + "Veterinários", + "Voluntário - Noite", + "Turnos de manhã", + "Turnos de noite", + "Turno da madrugada", + "Voluntário - Madrugada", + "Voluntário - Manhã", + "Médicos", + "Psicólogos", + "Assistentes Sociais", + "Turnos de tarde", + "Voluntário - Tarde", + "Técnico De Enfermagem", + "Enfermeiros" + ], + "acomodações e descanso": [ + "Lençóis", + "Cadeiras (tipo de praia)", + "Colchões", + "Lonas", + "Travesseiros/almofada", + "Rolo Lona Preta 40m x 6m (mais grossa)", + "Lona", + "Cordas", + "Cama Geriatria", + "Cobertores", + "Guarda-sol", + "Travesseiro", + "Luminárias com pedestal para área saúde 1m altura pelo menos", + "Ganchos para montar varal", + "Bermudas", + "Capas de chuva", + "Guarda-chuva", + "Roupas de Camas" + ], + "equipamentos de emergência": [ + "Lanternas", + "Luz de Emergência", + "Lanterna", + "Ventilador", + "Ventiladores", + "Caminhão Pipa", + "Fita Durex larga", + "Cadeira De Rodas", + "Pilha Aa", + "Baldes" + ], + "voluntariado": [ + "Voluntário - Noite", + "Turnos de manhã", + "Turnos de noite", + "Turno da madrugada", + "Voluntário - Madrugada", + "Voluntário - Manhã", + "Turnos de tarde", + "Voluntário - Tarde" + ], + "itens descartáveis": [ + "Pratos", + "Descarpack Caixa", + "Mamadeira", + "Chupeta/bico", + "Fraldas", + "Sacos de lixo", + "Sacolinhas plasticas para kits", + "Jornal (para xixi e coco)", + "Copos", + "Papagaio - Urinar", + "Colher Descartável", + "Papel toalha", + "Guardanapo de papel", + "Embalagem descartável para as marmitas", + "Garfo e faca", + "Potes (marmitex)", + "Talher Descartável", + "Luva de Latex Descartável", + "Escova Para Limpar Mamadeira", + "Toucas", + "Luvas Descartáveis", + "Lenço umedecido", + "Assento para vaso sanitário", + "Jornal/Papelão", + "Touca Descartável", + "Sacolas Para Montar Kits", + "Sacolas ou sacos plásticos" + ], + "higiene pessoal": [ + "Absorventes higiênicos", + "Lenços umedecidos", + "Sabonetes", + "Escova de dentes", + "Shampoo e Condicionador", + "Pás", + "Desodorante", + "Shampoo", + "Roupas íntimas", + "Roupa Íntima (adulto) Feminina E Masculina", + "Toalhas de banho", + "Papel higiênico", + "Roupa Íntima Infantil", + "Sabão em pó", + "Pasta de dente", + "Produtos de desinfecção (cloro, álcool)", + "Escova de cabelo", + "Toalhas", + "Pente/Escova de cabelo", + "Condicionador", + "Gilete", + "Tapete higiênico", + "Sabonete infantil", + "Higiene Pessoal", + "Produtos de higiene", + "Pano de chão", + "Pano de prato", + "Detergente", + "Água Sanitária", + "Chimia", + "Sabonete Líquido", + "Pano De Chão / Saco Alvejado", + "Esponjas De Louça", + "Lenço umedecido", + "Luvas para limpeza", + "Produtos de limpeza", + "Pano De Limpeza", + "Creme de pentear", + "Toalha de Banho", + "Itens de higiena pessoal" + ], + "alimentos e água": [ + "Roupas para crianças", + "Comidas Não Perecíveis", + "Fórmula infantil", + "Carne Gado", + "Massa", + "Feijão", + "Oléo", + "Leite em pó", + "Gelo", + "Suco de caixinha", + "Alimentos Diets", + "Água", + "Chinelos masculinos", + "Água potável", + "Azeite", + "Arroz", + "Macarrão", + "Café", + "Alimentos para consumo rápido (Leite, bolacha, suco, etc)", + "Lanches", + "Pão", + "Pão De Sanduíche", + "Sucos", + "Banana", + "Cebola", + "Frutas", + "Alface", + "Tomate", + "Leite", + "Molho de tomate", + "Açúcar", + "Frios", + "Carne Frango", + "Farinha", + "Bolachinha", + "Farinha de trigo", + "Gatorade/Isotônico", + "Massinha De Modelar", + "Sal", + "Polenta", + "Achocolatado pronto" + ], + "material de limpeza": [ + "Caixas de papelão", + "Lixeiras", + "Luvas De Limpeza", + "Vassouras", + "Sacos de lixo", + "Itens de limpeza", + "Produtos de desinfecção (cloro, álcool)", + "Desinfetante", + "Vassouras e rodos", + "Latas de lixo", + "Saco De Lixo De Vários Tamanhos" + ], + "vestuário": [ + "Roupas para crianças", + "Plus Size/GG", + "Luvas", + "Roupas para frio", + "Roupas Masculinas G E Gg", + "Roupa íntima masculina e feminina", + "Roupas femininas G/GG", + "Roupa plus size", + "Roupas grandes", + "Roupas para adultos", + "Sapato Infantil", + "Roupas Femininas", + "Roupa Masculina", + "Roupas Pluz Size", + "Fraldas Geriatricas", + "Mascara", + "Roupa Masculina Gg", + "Roupa Infantil Menino", + "Roupas INTIMAS - CUECAS - G", + "Bermuda Masculina", + "Roupa Infantil 8-10 Anos", + "Roupa Masculina Adulta", + "Fralda P", + "Fraldas Adultas", + "Meias", + "Roupas leves", + "Bermudas", + "Calçado masculino", + "Roupas plus size feminina", + "Bonés", + "Roupa íntima feminina", + "Fralda XG e XXG", + "Roupas plus size masculino", + "Calçados", + "Roupa Íntima Infantil" + ], + "veículos de resgate e transporte": [ + "Caminhão Pipa" + ], + "eletrodomésticos e eletrônicos": [ + "Ventilador", + "Ventiladores", + "Luz de Emergência", + "Lanterna", + "Freezer para armazenar comida", + "Máquina de lavar roupa", + "Luminárias com pedestal para área saúde 1m altura pelo menos" + ], + "mobílias": [ + "Cadeiras (tipo de praia)", + "Colchões", + "Cama Geriatria" + ], + "jogos e passatempo": [ + "Jogo de cartas, baralho, dama e dominó, etc", + "Baralhos" + ], + "cosméticos": [ + "Shampoo e Condicionador", + "Pás", + "Desodorante", + "Shampoo", + "Creme de pentear" + ] +} \ No newline at end of file diff --git a/test/examples/sheet_example.csv b/test/examples/sheet_example.csv new file mode 100644 index 00000000..c40aca59 --- /dev/null +++ b/test/examples/sheet_example.csv @@ -0,0 +1,7 @@ +nome_do_local,endereco,whatsapp,lat,lng,itens_disponiveis,itens_em_falta +Centro Humanista,"Av. baltazar de oliveira garcia, 2132 - Porto Alegre",https://chat.whatsapp.com/Fs967Q6vvIn6F77pBUTnyF,-300.106.249,-511.227.617,Água potável,"Lanternas, Roupas para crianças, Comidas Não Perecíveis, Pratos, Lençóis, Cadeiras (tipo de praia), Absorventes higiênicos, Kits de primeiros socorros, Fórmula infantil, Colchões" +Igreja Hebrom - Canoas,Av Esperança 1560,51 992726416,-2.989.020.757.796.020,-5.114.125.020.208.300,Água potável,"Caixas de papelão , Lixeiras, Carne Gado, Gaze, Termômetro, luvas, Álcool, Repelente, Massa, Descarpack Caixa" +Igreja Reviver,"Av. Edgar Pires De Castro, 803 - Hípica",(51) 98493-2401,-301.566.507,-511.838.467,Água potável,"Pomadas, Feijão , Tênis, Chinelos, Oléo, Roupas para frio, Leite em pó, Vassouras, Luvas, Roupas íntimas" +Associação Moradores Jardim Luciana - São Leopoldo,"Rua Tio Tiete, 510 - Jardim Luciana",,-2.970.870.574,-5.119.128.243,Água potável,"Medicamentos prescritos, Comidas Não Perecíveis, Turnos de manhã, Pratos, Lençóis, Absorventes higiênicos, Turnos de noite, Fraldas, Fórmula infantil, Colchões" +Paróquia Santa Clara,"Estrada João De Oliveira Remião, 4444 - Lomba Do Pinheiro",,-301.081.531,-51.112.884,Água potável,"Comidas Não Perecíveis, Ventilador, Roupa Íntima (adulto) Feminina E Masculina, Luvas De Limpeza, Toalhas de banho, Ventiladores, Repelente, Vassouras, Massa, Sacos de lixo" +Usina do Gasômetro,"na praça em frente ao Gasômetro, astrás do parque Harmonia",,,,Água potável,"Gelo, Azitotromicina, Papel higiênico, Absorventes higiênicos, Chinelos, Sacolinhas plasticas para kits , Gás, Ração para animais, Antibiótico , Suco de caixinha " \ No newline at end of file