diff --git a/.env.example b/.env.example index aedd7c2d..3264927f 100644 --- a/.env.example +++ b/.env.example @@ -9,4 +9,6 @@ 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 + +MAPS_CO_GEOCODE_API_KEY= \ No newline at end of file diff --git a/.env.local b/.env.local index 9cd74618..7d5f9e01 100644 --- a/.env.local +++ b/.env.local @@ -10,4 +10,6 @@ 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 + +MAPS_CO_GEOCODE_API_KEY= \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6c3ae692..b72af896 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-fastify": "^10.3.8", + "@nestjs/schedule": "^4.0.2", "@nestjs/swagger": "^7.3.1", "@prisma/client": "^5.13.0", "bcrypt": "^5.1.1", @@ -2100,6 +2101,19 @@ } } }, + "node_modules/@nestjs/schedule": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.0.2.tgz", + "integrity": "sha512-po9oauE7fO0CjhDKvVC2tzEgjOUwhxYoIsXIVkgfu+xaDMmzzpmXY2s1LT4oP90Z+PaTtPoAHmhslnYmo4mSZg==", + "dependencies": { + "cron": "3.1.7", + "uuid": "9.0.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.1.tgz", @@ -2543,6 +2557,11 @@ "@types/node": "*" } }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -4124,6 +4143,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cron": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.7.tgz", + "integrity": "sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==", + "dependencies": { + "@types/luxon": "~3.4.0", + "luxon": "~3.4.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -7000,6 +7028,14 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", + "integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.5", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", @@ -9632,6 +9668,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", diff --git a/package.json b/package.json index 5e154b46..70abf4c3 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@nestjs/passport": "^10.0.3", "@nestjs/platform-express": "^10.0.0", "@nestjs/platform-fastify": "^10.3.8", + "@nestjs/schedule": "^4.0.2", "@nestjs/swagger": "^7.3.1", "@prisma/client": "^5.13.0", "bcrypt": "^5.1.1", diff --git a/prisma/migrations/20240511043849_/migration.sql b/prisma/migrations/20240511043849_/migration.sql new file mode 100644 index 00000000..8dc95d1f --- /dev/null +++ b/prisma/migrations/20240511043849_/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "shelters" +ADD COLUMN "city" TEXT, +ADD COLUMN "street" TEXT, +ADD COLUMN "street_number" TEXT, +ADD COLUMN "neighbourhood" TEXT, +ADD COLUMN "state_district" TEXT, +ADD COLUMN "zip_code" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cdbf8a5c..24d24f46 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -96,6 +96,12 @@ model Shelter { latitude Float? longitude Float? verified Boolean @default(value: false) + city String? + street String? + streetNumber String? @map("street_number") + neighbourhood String? + stateDistrict String? @map("state_district") + zipCode String? @map("zip_code") createdAt String @map("created_at") @db.VarChar(32) updatedAt String? @map("updated_at") @db.VarChar(32) diff --git a/src/shelter/populateShelterCity.cron.ts b/src/shelter/populateShelterCity.cron.ts new file mode 100644 index 00000000..5cb65232 --- /dev/null +++ b/src/shelter/populateShelterCity.cron.ts @@ -0,0 +1,110 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +import { PrismaService } from 'src/prisma/prisma.service'; + +type ReverseGeoCodeInput = { + latitude: number; + longitude: number; +} + +type ReverseGeoCodeOutput = { + city?: string; + street?: string; + streetNumber?: string; + neighbourhood?: string; + stateDistrict?: string; + zipCode?: string; +} + +@Injectable() +export class PopulateShelterCity { + private readonly logger = new Logger(PopulateShelterCity.name); + private readonly geocodeApiKey = process.env.MAPS_CO_GEOCODE_API_KEY; + constructor(private readonly prismaService: PrismaService) {} + + @Cron(CronExpression.EVERY_MINUTE) + async populateShelterCity() { + const runId = Date.now(); + this.logger.log(`Running Populate Shelter City CRON (${runId})`); + + const shelter = await this.prismaService.shelter.findFirst({ + where: { + city: null, + latitude: { not: null }, + longitude: { not: null }, + }, + select: { + id: true, + latitude: true, + longitude: true, + }, + }); + + if (!shelter) { + this.logger.log(`${runId} > No pending shelters found. Skipping...`); + return; + } + this.logger.log( + `${runId} > Processing data for shelter with ID '${shelter.id}'`, + ); + + const { latitude, longitude } = shelter; + if (!latitude || !longitude) { + this.logger.log(`${runId} > Inconsistent lat/long data. Skipping...`); + return; + } + + let geocodeData: + | { + city?: string; + postCode?: string; + } + | undefined = undefined; + + try { + geocodeData = await this.reverseGeocode({ latitude, longitude }); + if (!geocodeData.city) { + this.logger.log( + `${runId} > Missing city from geocode data. Skipping...`, + ); + return; + } + } catch (error) { + this.logger.error(error); + this.logger.log(`${runId} > Error fetching geodata. Skipping...`); + geocodeData = undefined; + } + if (!geocodeData) return; + + await this.prismaService.shelter.update({ + where: { id: shelter.id }, + data: geocodeData, + }); + this.logger.log(`${runId} > Successfully updated geocode data.`); + } + + private async reverseGeocode({ + latitude, + longitude, + }: ReverseGeoCodeInput): Promise { + const reverseGeocodeURI = `https://geocode.maps.co/reverse?lat=${latitude}&lon=${longitude}&api_key=${this.geocodeApiKey}`; + + const response = await fetch(reverseGeocodeURI, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + const data = await response.json(); + + return { + city: data?.address.city, + street: data?.address.road, + streetNumber: data?.address.house_number, + neighbourhood: data?.address.suburb, + stateDistrict: data?.address.state_district, + zipCode: data?.address.postcode, + }; + } +} diff --git a/src/shelter/shelter.module.ts b/src/shelter/shelter.module.ts index 7859237a..114e7312 100644 --- a/src/shelter/shelter.module.ts +++ b/src/shelter/shelter.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; +import { ScheduleModule } from '@nestjs/schedule'; +import { PopulateShelterCity } from './populateShelterCity.cron'; import { ShelterService } from './shelter.service'; import { ShelterController } from './shelter.controller'; import { PrismaModule } from '../prisma/prisma.module'; @Module({ - imports: [PrismaModule], - providers: [ShelterService], + imports: [PrismaModule, ScheduleModule.forRoot()], + providers: [ShelterService, PopulateShelterCity], controllers: [ShelterController], }) export class ShelterModule {} diff --git a/src/shelter/shelter.service.ts b/src/shelter/shelter.service.ts index 1ea54d35..29dccea3 100644 --- a/src/shelter/shelter.service.ts +++ b/src/shelter/shelter.service.ts @@ -147,6 +147,12 @@ export class ShelterService { verified: true, latitude: true, longitude: true, + city: true, + street: true, + streetNumber: true, + neighbourhood: true, + stateDistrict: true, + zipCode: true, createdAt: true, updatedAt: true, shelterSupplies: { diff --git a/src/shelter/types/types.ts b/src/shelter/types/types.ts index 87ecea56..63be27ea 100644 --- a/src/shelter/types/types.ts +++ b/src/shelter/types/types.ts @@ -12,6 +12,11 @@ const ShelterSchema = z.object({ name: z.string().transform(capitalize), pix: z.string().nullable().optional(), address: z.string().transform(capitalize), + city: z.string().transform(capitalize).optional(), + street: z.string().transform(capitalize).optional(), + streetNumber: z.string().nullable().optional(), + neighbourhood: z.string().transform(capitalize).optional(), + postCode: z.string().nullable().optional(), petFriendly: z.boolean().nullable().optional(), shelteredPeople: z.number().nullable().optional(), latitude: z.number().nullable().optional(),