diff --git a/app/env.example b/app/env.example index 3a6cb9c49..d969549ca 100644 --- a/app/env.example +++ b/app/env.example @@ -17,3 +17,4 @@ VERIFICATION_TOKEN_SECRET="80c70dfdeedf2c01757b880d39c79214e915c786dd48d5473c9c0 HUGGINGFACE_API_KEY=hf_MY_SECRET_KEY OPENAI_API_KEY=sk-MY_SECRET_KEY CHAT_PROVIDER=huggingface +NEXT_PUBLIC_OPENCLIMATE_API_URL="https://openclimate.openearth.dev" diff --git a/app/migrations/20240318105130-region-population.cjs b/app/migrations/20240318105130-region-population.cjs new file mode 100644 index 000000000..cb3e1f097 --- /dev/null +++ b/app/migrations/20240318105130-region-population.cjs @@ -0,0 +1,16 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn( + "Population", + "region_population", + Sequelize.BIGINT, + ); + }, + + async down(queryInterface) { + await queryInterface.removeColumn("Population", "region_population"); + }, +}; diff --git a/app/src/app/[lng]/onboarding/setup/page.tsx b/app/src/app/[lng]/onboarding/setup/page.tsx index d691d2a63..4a0376bf3 100644 --- a/app/src/app/[lng]/onboarding/setup/page.tsx +++ b/app/src/app/[lng]/onboarding/setup/page.tsx @@ -5,7 +5,7 @@ import WizardSteps from "@/components/wizard-steps"; import { set } from "@/features/city/openclimateCitySlice"; import { useTranslation } from "@/i18n/client"; import { useAppDispatch, useAppSelector } from "@/lib/hooks"; -import type { CityAttributes, OCCityArributes } from "@/models/City"; +import type { CityAttributes } from "@/models/City"; import { useAddCityMutation, useAddCityPopulationMutation, @@ -15,6 +15,7 @@ import { useSetUserInfoMutation, } from "@/services/api"; import { getShortenNumberUnit, shortenNumber } from "@/util/helpers"; +import { OCCityAttributes } from "@/util/types"; import { ArrowBackIcon, CheckIcon, @@ -28,8 +29,10 @@ import { Card, Flex, FormControl, + FormErrorIcon, FormErrorMessage, FormLabel, + HStack, Heading, Icon, Input, @@ -38,13 +41,14 @@ import { InputRightElement, Select, Text, + useOutsideClick, useSteps, useToast, } from "@chakra-ui/react"; import type { TFunction } from "i18next"; import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import type { FieldErrors, SubmitHandler, @@ -59,62 +63,185 @@ const CityMap = dynamic(() => import("@/components/CityMap"), { ssr: false }); type Inputs = { city: string; year: number; + cityPopulation: number; + cityPopulationYear: number; + regionPopulation: number; + regionPopulationYear: number; + countryPopulation: number; + countryPopulationYear: number; }; +type PopulationEntry = { + year: number; + population: number; + datasource_id: string; +}; + +type OnboardingData = { + name: string; + locode: string; + year: number; +}; + +const numberOfYearsDisplayed = 10; + +/// Finds entry which has the year closest to the selected inventory year +function findClosestYear( + populationData: PopulationEntry[] | undefined, + year: number, +): PopulationEntry | null { + if (!populationData || populationData?.length === 0) { + return null; + } + return populationData.reduce( + (prev, curr) => { + // don't allow years outside of dropdown range + if (curr.year < year - numberOfYearsDisplayed + 1) { + return prev; + } + if (!prev) { + return curr; + } + let prevDelta = Math.abs(year - prev.year); + let currDelta = Math.abs(year - curr.year); + return prevDelta < currDelta ? prev : curr; + }, + null as PopulationEntry | null, + ); +} + function SetupStep({ errors, register, t, setValue, + watch, + ocCityData, + setOcCityData, + setData, }: { errors: FieldErrors; register: UseFormRegister; t: TFunction; setValue: any; + watch: Function; + ocCityData?: OCCityAttributes; + setOcCityData: (cityData: OCCityAttributes) => void; + setData: (data: OnboardingData) => void; }) { const currentYear = new Date().getFullYear(); - const years = Array.from({ length: 7 }, (_x, i) => currentYear - i); + const years = Array.from( + { length: numberOfYearsDisplayed }, + (_x, i) => currentYear - i, + ); + const dispatch = useAppDispatch(); const [onInputClicked, setOnInputClicked] = useState(false); - const [cityInputQuery, setCityInputQuery] = useState(""); const [isCityNew, setIsCityNew] = useState(false); - const [isYearSelected, setIsYearSelected] = useState(false); - const [yearValue, setYearValue] = useState(); - const dispatch = useAppDispatch(); + const [locode, setLocode] = useState(null); - const handleInputOnChange = (e: React.ChangeEvent) => { - e.preventDefault(); - setCityInputQuery(e.target.value); - setOnInputClicked(true); - }; + const yearInput = watch("year"); + const year: number | null = yearInput ? parseInt(yearInput) : null; + const cityInputQuery = watch("city"); + const cityPopulationYear = watch("cityPopulationYear"); + const regionPopulationYear = watch("regionPopulationYear"); + const countryPopulationYear = watch("countryPopulationYear"); - const handleSetCity = (city: OCCityArributes) => { - setCityInputQuery(city.name); + const handleSetCity = (city: OCCityAttributes) => { + setValue("city", city.name); setOnInputClicked(false); dispatch(set(city)); + setLocode(city.actor_id); + setOcCityData(city); + + if (year) { + setData({ + name: city.name, + locode: city.actor_id, + year: year!, + }); + } - // TODO: chech whether city exists or not setIsCityNew(true); }; - const handleYear = (e: any) => { - setIsYearSelected(true); - setYearValue(e.target.value); - }; - useEffect(() => { - setValue("city", cityInputQuery); - }, [cityInputQuery, setValue]); + if (year && ocCityData) { + setData({ + name: ocCityData.name, + locode: ocCityData.actor_id, + year: year!, + }); + } + }, [year, ocCityData, setData]); useEffect(() => { - if (cityInputQuery.length === 0) { + if (!cityInputQuery || cityInputQuery.length === 0) { setOnInputClicked(false); setIsCityNew(false); } - if (!yearValue) { - setIsYearSelected(false); + }, [cityInputQuery]); + + useEffect(() => { + // reset population data when locode changes to prevent keeping data from previous city + setValue("cityPopulationYear", null); + setValue("cityPopulation", null); + setValue("regionPopulation", null); + setValue("regionYear", null); + setValue("countryPopulation", null); + setValue("countryYear", null); + }, [locode, setValue]); + + const { data: cityData } = useGetOCCityDataQuery(locode!, { + skip: !locode, + }); + const countryLocode = + locode && locode.length > 0 ? locode.split(" ")[0] : null; + const { data: countryData } = useGetOCCityDataQuery(countryLocode!, { + skip: !countryLocode, + }); + const regionLocode = cityData?.is_part_of; + const { data: regionData } = useGetOCCityDataQuery(regionLocode!, { + skip: !regionLocode, + }); + + // react to API data changes and different year selections + useEffect(() => { + if (cityData && year) { + const population = findClosestYear(cityData.population, year); + if (!population) { + console.error("Failed to find population data for city"); + return; + } + setValue("cityPopulation", population?.population); + setValue("cityPopulationYear", population?.year); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cityData, year, setValue]); + + useEffect(() => { + if (regionData && year) { + const population = findClosestYear(regionData.population, year); + if (!population) { + console.error("Failed to find population data for region"); + return; + } + setValue("regionPopulation", population?.population); + setValue("regionPopulationYear", population?.year); + } + }, [regionData, year, setValue]); + + useEffect(() => { + if (countryData && year) { + const population = findClosestYear(countryData.population, year); + if (!population) { + console.error("Failed to find population data for region"); + return; + } + setValue("countryPopulation", population?.population); + setValue("countryPopulationYear", population?.year); } - }, [cityInputQuery, yearValue]); + }, [countryData, year, setValue]); // import custom redux hooks const { @@ -122,7 +249,7 @@ function SetupStep({ isLoading, isSuccess, } = useGetOCCityQuery(cityInputQuery, { - skip: cityInputQuery.length <= 2 ? true : false, + skip: cityInputQuery?.length <= 2 ? true : false, }); const renderParentPath = (path: []) => { @@ -142,41 +269,48 @@ function SetupStep({ return pathString; }; + // using useOutsideClick instead of onBlur input attribute + // to fix clicking city dropdown entries not working + const cityInputRef = useRef(null); + useOutsideClick({ + ref: cityInputRef, + handler: () => setTimeout(() => setOnInputClicked(false), 0), + }); + return ( <> -
+ {t("setup-heading")} {t("setup-details")} -
-
+ + -
- + + {t("select-city")} - + setOnInputClicked(true)} /> {isCityNew && ( )} @@ -190,7 +324,7 @@ function SetupStep({ {isLoading &&

Fetching Cities...

} {isSuccess && cities && - cities.map((city: any) => { + cities.map((city: OCCityAttributes) => { return ( handleSetCity(city)} @@ -237,7 +371,6 @@ function SetupStep({ {...register("year", { required: t("inventory-year-required"), })} - onChange={handleYear} > {years.map((year: number, i: number) => (
+ + + {t("city-population-title")} + + + + {errors.cityPopulation && errors.cityPopulation.message} + + + + + {t("population-year")} + + + + {cityPopulationYear && ( + + )} + + + + + {errors.cityPopulationYear && + errors.cityPopulationYear.message} + + + + + + {t("region-population-title")} + + + + {errors.regionPopulation && errors.regionPopulation.message} + + + + {t("population-year")} + + + + {regionPopulationYear && ( + + )} + + + + + {errors.regionPopulationYear && + errors.regionPopulationYear.message} + + + + + + {t("country-population-title")} + + + + {errors.countryPopulation && errors.countryPopulation.message} + + + + {t("population-year")} + + + + {countryPopulationYear && ( + + )} + + + + + {errors.countryPopulationYear && + errors.countryPopulationYear.message} + + + + + + + {t("information-required")} + +
{t("gpc-basic-message")} -
+ ); } @@ -281,7 +593,7 @@ function ConfirmStep({ t: TFunction; locode: string; area: number; - population: number; + population?: number; }) { return ( <> @@ -366,6 +678,7 @@ export default function OnboardingSetup({ register, getValues, setValue, + watch, formState: { errors, isSubmitting }, } = useForm(); @@ -380,45 +693,13 @@ export default function OnboardingSetup({ const [addInventory] = useAddInventoryMutation(); const [setUserInfo] = useSetUserInfoMutation(); - const [data, setData] = useState<{ - name: string; - locode: string; - year: number; - }>({ name: "", locode: "", year: -1 }); - - const [isConfirming, setConfirming] = useState(false); - const [populationData, setPopulationData] = useState<{ - year: number; - population: number; - datasourceId: string; - }>({ year: 0, population: 0, datasourceId: "" }); - const [countryPopulation, setCountryPopulation] = useState(0); - - const storedData = useAppSelector((state) => state.openClimateCity); - - const onSubmit: SubmitHandler = async (newData) => { - const year = Number(newData.year); - if (!newData.city || !storedData.city?.actor_id || year < 0) { - return; - } - - setData({ - name: newData.city, - locode: storedData.city?.actor_id, - year, - }); - - goToNext(); - }; - - const { data: cityData } = useGetOCCityDataQuery(data.locode, { - skip: !data.locode, - }); - const countryLocode = - data.locode.length > 0 ? data.locode.split(" ")[0] : null; - const { data: countryData } = useGetOCCityDataQuery(countryLocode!, { - skip: !countryLocode, + const [data, setData] = useState({ + name: "", + locode: "", + year: -1, }); + const [ocCityData, setOcCityData] = useState(); + const [isConfirming, setConfirming] = useState(false); const makeErrorToast = (title: string, description?: string) => { toast({ @@ -431,67 +712,44 @@ export default function OnboardingSetup({ }); }; - const [ocCityData, setOcCityData] = useState<{ - area: number; - region: string; - country: string; - }>(); - - useEffect(() => { - if (cityData) { - const population = cityData?.data.population.filter( - (item: any) => item.year === data.year, - ); - const populationObject = { - year: population[0]?.year, - population: population[0]?.population, - datasourceId: population[0]?.datasource_id, - }; - setPopulationData(populationObject); - - const cityObject = { - area: cityData.data?.territory?.area ?? 0, - region: - storedData.city?.root_path_geo.filter( - (item: any) => item.type === "adm1", - )[0]?.name ?? "", - country: - storedData.city?.root_path_geo.filter( - (item: any) => item.type === "country", - )[0]?.name ?? "", - }; - - setOcCityData(cityObject); - } - }, [cityData, storedData.city?.root_path_geo, data.year]); - - useEffect(() => { - if (countryData) { - const population = countryData?.data.population.filter( - (item: any) => item.year === data.year, - ); - setCountryPopulation(population[0]?.population); - } - }, [countryData, data.year]); + const cityPopulation = watch("cityPopulation"); + const regionPopulation = watch("regionPopulation"); + const countryPopulation = watch("countryPopulation"); + const cityPopulationYear = watch("cityPopulationYear"); + const regionPopulationYear = watch("regionPopulationYear"); + const countryPopulationYear = watch("countryPopulationYear"); const onConfirm = async () => { // save data in backend setConfirming(true); let city: CityAttributes | null = null; + + let area = ocCityData?.area ?? 0; + let region = + ocCityData?.root_path_geo.filter((item: any) => item.type === "adm1")[0] + ?.name ?? ""; + let country = + ocCityData?.root_path_geo.filter( + (item: any) => item.type === "country", + )[0]?.name ?? ""; + try { city = await addCity({ name: data.name, - locode: data.locode, - area: ocCityData?.area!, - region: ocCityData?.region!, - country: ocCityData?.country!, + locode: data.locode!, + area, + region, + country, }).unwrap(); await addCityPopulation({ cityId: city.cityId, locode: city.locode!, - population: populationData.population, - countryPopulation: countryPopulation, - year: data.year, + cityPopulation: cityPopulation!, + cityPopulationYear: cityPopulationYear!, + regionPopulation: regionPopulation!, + regionPopulationYear: regionPopulationYear!, + countryPopulation: countryPopulation!, + countryPopulationYear: countryPopulationYear!, }).unwrap(); } catch (err: any) { makeErrorToast("Failed to add city!", err.data?.error?.message); @@ -518,6 +776,24 @@ export default function OnboardingSetup({ } }; + const onSubmit: SubmitHandler = async (newData) => { + const year = Number(newData.year); + + setData({ + name: newData.city, + locode: data.locode!, + year, + }); + + if (!newData.city || !ocCityData?.actor_id || year < 0 || !data.locode) { + // TODO show user toast? These should normally be caught by validation logic + console.error("Missing data, can't go to next step!"); + return; + } + + goToNext(); + }; + return ( <>
@@ -539,6 +815,10 @@ export default function OnboardingSetup({ errors={errors} setValue={setValue} register={register} + watch={watch} + ocCityData={ocCityData} + setOcCityData={setOcCityData} + setData={setData} t={t} /> )} @@ -548,7 +828,7 @@ export default function OnboardingSetup({ t={t} locode={data.locode} area={ocCityData?.area!} - population={populationData.population} + population={cityPopulation} /> )}
diff --git a/app/src/app/api/v0/city/[city]/population/route.ts b/app/src/app/api/v0/city/[city]/population/route.ts index 9c1a1fa2b..77b1c2cce 100644 --- a/app/src/app/api/v0/city/[city]/population/route.ts +++ b/app/src/app/api/v0/city/[city]/population/route.ts @@ -2,25 +2,71 @@ import UserService from "@/backend/UserService"; import { db } from "@/models"; import { apiHandler } from "@/util/api"; import { createPopulationRequest } from "@/util/validation"; +import createHttpError from "http-errors"; import { NextResponse } from "next/server"; export const POST = apiHandler(async (req, { session, params }) => { const body = createPopulationRequest.parse(await req.json()); const city = await UserService.findUserCity(params.city, session); - let population = await db.models.Population.findOne({ + if (!city) { + throw new createHttpError.NotFound("City not found"); + } + + const { cityId } = city; + + let cityPopulation = await db.models.Population.findOne({ where: { - cityId: city.cityId, - year: body.year, + cityId, + year: body.cityPopulationYear, }, }); + if (cityPopulation) { + cityPopulation.population = body.cityPopulation; + await cityPopulation.save(); + } else { + cityPopulation = await db.models.Population.create({ + cityId, + population: body.cityPopulation, + year: body.cityPopulationYear, + }); + } - if (!population) { - population = await db.models.Population.create({ - ...body, - cityId: city.cityId, + let regionPopulation = await db.models.Population.findOne({ + where: { + cityId, + year: body.regionPopulationYear, + }, + }); + if (regionPopulation) { + regionPopulation.regionPopulation = body.regionPopulation; + await regionPopulation.save(); + } else { + regionPopulation = await db.models.Population.create({ + cityId, + regionPopulation: body.regionPopulation, + year: body.regionPopulationYear, }); } - return NextResponse.json({ data: population }); + let countryPopulation = await db.models.Population.findOne({ + where: { + cityId, + year: body.countryPopulationYear, + }, + }); + if (countryPopulation) { + countryPopulation.countryPopulation = body.countryPopulation; + await countryPopulation.save(); + } else { + countryPopulation = await db.models.Population.create({ + cityId, + countryPopulation: body.countryPopulation, + year: body.countryPopulationYear, + }); + } + + return NextResponse.json({ + data: { cityPopulation, regionPopulation, countryPopulation }, + }); }); diff --git a/app/src/app/api/v0/datasource/[inventoryId]/route.ts b/app/src/app/api/v0/datasource/[inventoryId]/route.ts index 445032dbd..f2aeeadc7 100644 --- a/app/src/app/api/v0/datasource/[inventoryId]/route.ts +++ b/app/src/app/api/v0/datasource/[inventoryId]/route.ts @@ -14,6 +14,15 @@ import { z } from "zod"; import { logger } from "@/services/logger"; import { Publisher } from "@/models/Publisher"; +const maxPopulationYearDifference = 5; +const downscaledByCountryPopulation = "global_api_downscaled_by_population"; +const downscaledByRegionPopulation = + "global_api_downscaled_by_region_population"; +const populationScalingRetrievalMethods = [ + downscaledByCountryPopulation, + downscaledByRegionPopulation, +]; + export const GET = apiHandler(async (_req: NextRequest, { params }) => { const inventory = await db.models.Inventory.findOne({ where: { inventoryId: params.inventoryId }, @@ -71,26 +80,40 @@ export const GET = apiHandler(async (_req: NextRequest, { params }) => { const applicableSources = DataSourceService.filterSources(inventory, sources); // determine scaling factor for downscaled sources - let populationScaleFactor = 1; + let countryPopulationScaleFactor = 1; + let regionPopulationScaleFactor = 1; let populationIssue: string | null = null; if ( - sources.some( - (source) => - source.retrievalMethod === "global_api_downscaled_by_population", + sources.some((source) => + populationScalingRetrievalMethods.includes(source.retrievalMethod ?? ""), ) ) { const population = await db.models.Population.findOne({ where: { cityId: inventory.cityId, - year: inventory.year, + year: { + [Op.between]: [ + inventory.year! - maxPopulationYearDifference, + inventory.year! + maxPopulationYearDifference, + ], + }, }, + order: [["year", "DESC"]], // favor more recent population entries }); - if (!population?.population || !population?.countryPopulation) { - populationIssue = - "City is missing population/ country population for the inventory year"; + // TODO allow country downscaling to work if there is no region population? + if ( + !population || + !population.population || + !population.countryPopulation || + !population.regionPopulation + ) { + // City is missing population/ region population/ country population for the inventory year + populationIssue = "missing_population"; } else { - populationScaleFactor = + countryPopulationScaleFactor = population.population / population.countryPopulation; + regionPopulationScaleFactor = + population.population / population.regionPopulation; } } @@ -107,8 +130,11 @@ export const GET = apiHandler(async (_req: NextRequest, { params }) => { } let scaleFactor = 1.0; let issue: string | null = null; - if (source.retrievalMethod === "global_api_downscaled_by_population") { - scaleFactor = populationScaleFactor; + if (source.retrievalMethod === downscaledByCountryPopulation) { + scaleFactor = countryPopulationScaleFactor; + issue = populationIssue; + } else if (source.retrievalMethod === downscaledByRegionPopulation) { + scaleFactor = regionPopulationScaleFactor; issue = populationIssue; } return { source, data: { ...data, scaleFactor, issue } }; diff --git a/app/src/components/recent-searches.tsx b/app/src/components/recent-searches.tsx index 96a3d3533..8054659aa 100644 --- a/app/src/components/recent-searches.tsx +++ b/app/src/components/recent-searches.tsx @@ -19,7 +19,7 @@ const RecentSearches = () => { path: "Argentina {" > "} Capital Federal", }, ]; - const hasRecentSearches = true; + const hasRecentSearches = false; return ( diff --git a/app/src/features/city/openclimateCityDataSlice.ts b/app/src/features/city/openclimateCityDataSlice.ts index 00ccf9bd8..94c5ef55b 100644 --- a/app/src/features/city/openclimateCityDataSlice.ts +++ b/app/src/features/city/openclimateCityDataSlice.ts @@ -1,5 +1,4 @@ import { RootState } from "@/lib/store"; -import { OCCityArributes } from "@/models/City"; import { PayloadAction, createSlice } from "@reduxjs/toolkit"; export interface CityDataAttributes { diff --git a/app/src/features/city/openclimateCitySlice.ts b/app/src/features/city/openclimateCitySlice.ts index 34aadbb19..e9ebdcc4d 100644 --- a/app/src/features/city/openclimateCitySlice.ts +++ b/app/src/features/city/openclimateCitySlice.ts @@ -1,9 +1,9 @@ import { RootState } from "@/lib/store"; -import { OCCityArributes } from "@/models/City"; +import { OCCityAttributes } from "@/util/types"; import { PayloadAction, createSlice } from "@reduxjs/toolkit"; interface CityState { - city?: OCCityArributes; + city?: OCCityAttributes; } const initialState = { @@ -15,7 +15,7 @@ export const openclimateCitySlice = createSlice({ // state type is inferred from the initial state initialState, reducers: { - set: (state, action: PayloadAction) => { + set: (state, action: PayloadAction) => { state.city = action.payload; }, clear: (state) => { diff --git a/app/src/i18n/locales/en/onboarding.json b/app/src/i18n/locales/en/onboarding.json index bc0557cf5..d19d7a666 100644 --- a/app/src/i18n/locales/en/onboarding.json +++ b/app/src/i18n/locales/en/onboarding.json @@ -14,7 +14,7 @@ "inventory-year": "Inventory year", "inventory-year-placeholder": "Select year", "inventory-year-required": "Year is required", - "gpc-basic-message": "Only GPC Basic Inventories are supported momentarily", + "gpc-basic-message": "Only GPC Basic Inventories are supported momentarily. For population values the closest year to the city inventory is recommended, as it will be used for calculations and included in your GPC report.", "save-button": "Save and Continue", "search-city-button": "Search for another City", "confirm-button": "Confirm and Continue", @@ -28,5 +28,17 @@ "done-heading": "Your City Inventory Profile
Was Successfully Created", "inventory-title": "GPC Basic Emission Inventory - Year {{year}}", "done-details": "You created your city profile to start your GPC Basic GHG inventory.", - "check-dashboard": "Check Dashboard" + "check-dashboard": "Check Dashboard", + + "information-required": "This information is required and affects your GHG inventory accuracy.", + "year-placeholder": "Year", + "required": "Required", + "population-required": "Please enter value & year for population", + "city-population-title": "City population", + "city-population-placeholder": "City population number", + "population-year": "Population year", + "region-population-title": "Region population", + "region-population-placeholder": "Region population number", + "country-population-title": "Country population", + "country-population-placeholder": "Country population number" } diff --git a/app/src/i18n/locales/es/onboarding.json b/app/src/i18n/locales/es/onboarding.json index 3bf04c033..d6ef3f086 100644 --- a/app/src/i18n/locales/es/onboarding.json +++ b/app/src/i18n/locales/es/onboarding.json @@ -14,13 +14,13 @@ "inventory-year": "Año del inventario", "inventory-year-placeholder": "Seleccionar año", "inventory-year-required": "El año es requerido", - "gpc-basic-message": "Solo los inventarios básicos de GPC son compatibles momentáneamente", + "gpc-basic-message": "Solo los inventarios básicos de GPC son compatibles momentáneamente. Utilice datos del año más cercano al año de inventario seleccionado.", "save-button": "Guardar y Continuar", "search-city-button": "Buscar otra Ciudad", "confirm-button": "Confirmar y Continuar", "confirm-heading": "Confirmar Información de la Ciudad", - "confirm-details": "Revisa y confirma esta información sobre tu ciudad. Si hay un error, por favor envíanos un correo electrónico para editarlo.

Utilizamos <2>fuentes de datos abiertos para completar previamente el perfil de la ciudad.", + "confirm-details": "Revisa y confirma esta información sobre tu ciudad. Si hay un error, por favor envíanos un correo electrónico para editarlo.

Utilizamos <2>fuentes de datos abiertos para completar previamente el perfil de la ciudad.", "total-population": "Población total", "total-land-area": "Superficie total del terreno", "geographical-boundaries": "Límites geográficos", @@ -28,5 +28,17 @@ "done-heading": "Inventario creado con éxito
", "inventory-title": "Inventario Básico de Emisiones GPC - Año {{year}}", "done-details": "Creaste tu perfil de ciudad para comenzar tu inventario de GEI con metodología GPC Básico.", - "check-dashboard": "Ir al Inicio" + "check-dashboard": "Ir al Inicio", + + "information-required": "Esta información es obligatoria y afecta la precisión de su inventario de GEI.", + "year-placeholder": "Año", + "required": "Necesario", + "population-required": "Por favor ingrese el valor y el año de la población", + "city-population-title": "Población de la ciudad", + "city-population-placeholder": "Número de población de la ciudad", + "population-year": "Año de población", + "region-population-title": "Población de la región", + "region-population-placeholder": "Número de población de la región", + "country-population-title": "Población del país", + "country-population-placeholder": "Número de población del país" } diff --git a/app/src/models/City.ts b/app/src/models/City.ts index ec2356834..e8a79aa41 100644 --- a/app/src/models/City.ts +++ b/app/src/models/City.ts @@ -17,13 +17,6 @@ export interface CityAttributes { lastUpdated?: Date; } -export interface OCCityArributes { - actor_id: string; - name: string; - is_part_of: string; - root_path_geo: any; -} - export type CityPk = "cityId"; export type CityId = City[CityPk]; export type CityOptionalAttributes = diff --git a/app/src/models/Population.ts b/app/src/models/Population.ts index 086009cde..bdd8ba814 100644 --- a/app/src/models/Population.ts +++ b/app/src/models/Population.ts @@ -7,6 +7,7 @@ export interface PopulationAttributes { cityId: string; population?: number; countryPopulation?: number; + regionPopulation?: number; year: number; created?: Date; lastUpdated?: Date; @@ -18,6 +19,7 @@ export type PopulationId = Population[PopulationPk]; export type PopulationOptionalAttributes = | "population" | "countryPopulation" + | "regionPopulation" | "created" | "lastUpdated" | "datasourceId"; @@ -33,6 +35,7 @@ export class Population cityId!: string; population?: number; countryPopulation?: number; + regionPopulation?: number; year!: number; created?: Date; lastUpdated?: Date; @@ -74,6 +77,11 @@ export class Population allowNull: true, field: "country_population", }, + regionPopulation: { + type: DataTypes.BIGINT, + allowNull: true, + field: "region_population", + }, year: { type: DataTypes.INTEGER, allowNull: false, diff --git a/app/src/services/api.ts b/app/src/services/api.ts index e902ece3b..2add72727 100644 --- a/app/src/services/api.ts +++ b/app/src/services/api.ts @@ -178,9 +178,12 @@ export const api = createApi({ { cityId: string; locode: string; - population: number; + cityPopulation: number; + regionPopulation: number; countryPopulation: number; - year: number; + cityPopulationYear: number; + regionPopulationYear: number; + countryPopulationYear: number; } >({ query: (data) => { @@ -383,7 +386,7 @@ export const api = createApi({ export const openclimateAPI = createApi({ reducerPath: "openclimateapi", baseQuery: fetchBaseQuery({ - baseUrl: process.env.OPENCLIMATE_API_URL, + baseUrl: process.env.NEXT_PUBLIC_OPENCLIMATE_API_URL, }), endpoints: (builder) => ({ getOCCity: builder.query({ @@ -394,6 +397,9 @@ export const openclimateAPI = createApi({ }), getOCCityData: builder.query({ query: (locode) => `/api/v1/actor/${locode}`, + transformResponse: (response: any) => { + return response.data; + }, }), }), }); diff --git a/app/src/util/types.d.ts b/app/src/util/types.d.ts index a15158e51..eaca69b53 100644 --- a/app/src/util/types.d.ts +++ b/app/src/util/types.d.ts @@ -80,6 +80,14 @@ type EmissionsFactorResponse = EmissionsFactorWithDataSources[]; type InventoryWithCity = InventoryAttributes & { city: CityAttributes }; +interface OCCityAttributes { + actor_id: string; + name: string; + is_part_of: string; + root_path_geo: any; + area: number; +} + declare module "next-auth" { interface Session { user: { diff --git a/app/src/util/validation.ts b/app/src/util/validation.ts index f8f63785d..88479ea52 100644 --- a/app/src/util/validation.ts +++ b/app/src/util/validation.ts @@ -105,9 +105,12 @@ export type CreateUserRequest = z.infer; export const createPopulationRequest = z.object({ cityId: z.string().uuid(), - population: z.number().optional(), - countryPopulation: z.number().optional(), - year: z.number(), + cityPopulation: z.number().gte(0), + regionPopulation: z.number().gte(0), + countryPopulation: z.number().gte(0), + cityPopulationYear: z.number().gte(0), + regionPopulationYear: z.number().gte(0), + countryPopulationYear: z.number().gte(0), datasourceId: z.string().optional(), }); diff --git a/app/tests/api/city.test.ts b/app/tests/api/city.test.ts index eb6859621..7248d02eb 100644 --- a/app/tests/api/city.test.ts +++ b/app/tests/api/city.test.ts @@ -51,7 +51,10 @@ describe("City API", () => { before(async () => { setupTests(); await db.initialize(); - [user] = await db.models.User.upsert({ userId: testUserID, name: "TEST_USER" }); + [user] = await db.models.User.upsert({ + userId: testUserID, + name: "TEST_USER", + }); Auth.getServerSession = mock.fn(() => Promise.resolve(mockSession)); }); diff --git a/app/tests/api/population.test.ts b/app/tests/api/population.test.ts new file mode 100644 index 000000000..bc9900527 --- /dev/null +++ b/app/tests/api/population.test.ts @@ -0,0 +1,151 @@ +import { POST as savePopulations } from "@/app/api/v0/city/[city]/population/route"; +import { db } from "@/models"; +import assert from "node:assert"; +import { after, before, describe, it } from "node:test"; +import { mockRequest, setupTests, testUserID } from "../helpers"; +import { CreatePopulationRequest } from "@/util/validation"; +import { Op } from "sequelize"; +import { keyBy } from "@/util/helpers"; + +const cityId = "76bb1ab7-5177-45a1-a61f-cfdee9c448e8"; + +const validPopulationUpdate: CreatePopulationRequest = { + cityId, + cityPopulation: 1, + cityPopulationYear: 1337, + regionPopulation: 2, + regionPopulationYear: 1338, + countryPopulation: 3, + countryPopulationYear: 1339, +}; + +const overlappingPopulationUpdate: CreatePopulationRequest = { + cityId, + cityPopulation: 4, + cityPopulationYear: 1340, + regionPopulation: 5, + regionPopulationYear: 1340, + countryPopulation: 6, + countryPopulationYear: 1340, +}; + +const invalidPopulationUpdate: CreatePopulationRequest = { + cityId, + cityPopulation: -4, + cityPopulationYear: -1340, + regionPopulation: -5, + regionPopulationYear: -1340, + countryPopulation: -6, + countryPopulationYear: -1340, +}; + +describe("Population API", () => { + before(async () => { + setupTests(); + await db.initialize(); + await db.models.Population.destroy({ where: { cityId } }); + await db.models.City.destroy({ where: { cityId } }); + const city = await db.models.City.create({ + cityId, + name: "Population Test City", + }); + await db.models.User.upsert({ userId: testUserID, name: "TEST_USER" }); + await city.addUser(testUserID); + }); + + after(async () => { + if (db.sequelize) await db.sequelize.close(); + }); + + it("should save correct population information", async () => { + const req = mockRequest(validPopulationUpdate); + const res = await savePopulations(req, { params: { city: cityId } }); + assert.equal(res.status, 200); + const data = await res.json(); + + assert.equal( + data.data.cityPopulation.population, + validPopulationUpdate.cityPopulation, + ); + assert.equal( + data.data.cityPopulation.year, + validPopulationUpdate.cityPopulationYear, + ); + assert.equal( + data.data.regionPopulation.regionPopulation, + validPopulationUpdate.regionPopulation, + ); + assert.equal( + data.data.regionPopulation.year, + validPopulationUpdate.regionPopulationYear, + ); + assert.equal( + data.data.countryPopulation.countryPopulation, + validPopulationUpdate.countryPopulation, + ); + assert.equal( + data.data.countryPopulation.year, + validPopulationUpdate.countryPopulationYear, + ); + + const populations = await db.models.Population.findAll({ + where: { cityId, year: { [Op.in]: [1337, 1338, 1339] } }, + }); + assert.equal(populations.length, 3); + const populationByYear = keyBy(populations, (p) => p.year.toString()); + assert.equal(populationByYear["1337"].population, 1); + assert.equal(populationByYear["1338"].regionPopulation, 2); + assert.equal(populationByYear["1339"].countryPopulation, 3); + }); + + it("should correctly save population information for the same year", async () => { + const req = mockRequest(overlappingPopulationUpdate); + const res = await savePopulations(req, { params: { city: cityId } }); + assert.equal(res.status, 200); + const data = await res.json(); + + assert.equal( + data.data.cityPopulation.population, + overlappingPopulationUpdate.cityPopulation, + ); + assert.equal( + data.data.cityPopulation.year, + overlappingPopulationUpdate.cityPopulationYear, + ); + assert.equal( + data.data.regionPopulation.regionPopulation, + overlappingPopulationUpdate.regionPopulation, + ); + assert.equal( + data.data.regionPopulation.year, + overlappingPopulationUpdate.regionPopulationYear, + ); + assert.equal( + data.data.countryPopulation.countryPopulation, + overlappingPopulationUpdate.countryPopulation, + ); + assert.equal( + data.data.countryPopulation.year, + overlappingPopulationUpdate.countryPopulationYear, + ); + + const populations = await db.models.Population.findAll({ + where: { cityId, year: 1340 }, + }); + assert.equal(populations.length, 1); + console.dir(populations[0].dataValues); + assert.equal(populations[0].population, 4); + assert.equal(populations[0].regionPopulation, 5); + assert.equal(populations[0].countryPopulation, 6); + }); + + it("should not save invalid population information", async () => { + const req = mockRequest(invalidPopulationUpdate); + const res = await savePopulations(req, { params: { city: cityId } }); + assert.equal(res.status, 400); + const populations = await db.models.Population.findAll({ + where: { cityId, year: -1340 }, + }); + assert.equal(populations.length, 0); + }); +}); diff --git a/app/tests/helpers.ts b/app/tests/helpers.ts index 5dafdc6a7..dcc173852 100644 --- a/app/tests/helpers.ts +++ b/app/tests/helpers.ts @@ -17,7 +17,10 @@ export function createRequest(url: string, body?: any) { return request; } -export function mockRequest(body?: any, searchParams?: Record): NextRequest { +export function mockRequest( + body?: any, + searchParams?: Record, +): NextRequest { const request = new NextRequest(new URL(mockUrl)); request.json = mock.fn(() => Promise.resolve(body)); for (const param in searchParams) { diff --git a/k8s/cc-web-deploy.yml b/k8s/cc-web-deploy.yml index 20c5fdb37..e4e08a968 100644 --- a/k8s/cc-web-deploy.yml +++ b/k8s/cc-web-deploy.yml @@ -46,7 +46,7 @@ spec: value: "587" - name: GLOBAL_API_URL value: "https://ccglobal.openearth.dev" - - name: OPENCLIMATE_API_URL + - name: NEXT_PUBLIC_OPENCLIMATE_API_URL value: "https://openclimate.openearth.dev" resources: limits: