diff --git a/app/api/route.ts b/app/api/route.ts index 48affcde..57bf8810 100644 --- a/app/api/route.ts +++ b/app/api/route.ts @@ -1,218 +1,18 @@ import { NextResponse } from "next/server"; -import { PrismaClient } from "@prisma/client"; import { Calculation, calculationSchema } from "../schemas/calculationSchema"; +import * as calculationService from "../services/calculationService"; -const prisma = new PrismaClient(); - -// define and export the GET handler function export async function POST(req: Request) { try { // Parse and validate user input const data = await req.json(); const input: Calculation = calculationSchema.parse(data); - // data are going to be queried at different levels of granularity based on the postcode - const postcode = input.housePostcode; - const postcodeArea = postcode.area; // extract only the characters for the area, e.g SE - const postcodeDistrict = postcode.district; // extract only characters for the district, SE17 - const postcodeSector = postcode.sector; // extract only the characters for the sector, SE17 1 - - // create the progressive queries - const minimumNumberPostcodes = 30; // minimum number of entries to create the average - let pricesPaid; // declare the variable for prices paid - let numberOfTransactions; // declare the variable for numbers of transactions retrieved - let granularityPostcode; // declare the granularity of the postcode - let averagePrice; - - const pricesPaidSector = await prisma.pricesPaid.aggregate({ - where: { - propertyType: { - equals: input.houseType, - }, - postcode: { - startsWith: postcodeSector, - }, - }, - _count: { - id: true, - }, - _avg: { - price: true, - }, - }); - - const numberPerSector = pricesPaidSector._count.id; - const isMinMetBySector = numberPerSector >= minimumNumberPostcodes; - - if (!isMinMetBySector) { - const pricesPaidDistrict = await prisma.pricesPaid.aggregate({ - where: { - propertyType: { - equals: input.houseType, - }, - postcode: { - startsWith: postcodeDistrict, - }, - }, - _count: { - id: true, - }, - _avg: { - price: true, - }, - }); - - const numberPerDistrict = pricesPaidDistrict._count.id; - const isMinMetByDistrict = numberPerDistrict >= minimumNumberPostcodes; - - if (!isMinMetByDistrict) { - const pricesPaidArea = await prisma.pricesPaid.aggregate({ - where: { - propertyType: { - equals: input.houseType, - }, - postcode: { - startsWith: postcodeArea, - }, - }, - _count: { - id: true, - }, - _avg: { - price: true, - }, - }); - const numberPerArea = pricesPaidArea._count.id; - - pricesPaid = pricesPaidArea; // if condition is met, the granularity is appropriate - numberOfTransactions = numberPerArea; // check the granularity - granularityPostcode = postcodeArea; // granularity of the postcode when performing the average price search - averagePrice = pricesPaidArea._avg.price; - } else { - pricesPaid = pricesPaidDistrict; // if condition is met, the granularity is appropriate - numberOfTransactions = numberPerDistrict; // check the granularity - granularityPostcode = postcodeDistrict; // granularity of the postcode - averagePrice = pricesPaidDistrict._avg.price; - } - } else { - pricesPaid = pricesPaidSector; // if condition is met, the granularity is appropriate - numberOfTransactions = numberPerSector; // check the granularity - granularityPostcode = postcodeSector; // granularity of the postcode - averagePrice = pricesPaidSector._avg.price; - } - - if (averagePrice === null) { - throw new Error("Unable to calculate average price"); - } - - const { priceMid: buildPrice } = await prisma.buildPrices.findFirstOrThrow({ - where: { - houseType: { equals: input.houseType }, - }, - select: { priceMid: true }, - }); - // TODO: Make columns non-nullable - if (!buildPrice) throw Error("Missing buildPrice"); - - const { itl3 } = await prisma.itlLookup.findFirstOrThrow({ - where: { - postcode: postcodeDistrict, - itl3: { - not: null, - }, - }, - select: { - itl3: true, - }, - }); - if (!itl3) throw Error("Missing itl3"); - - const { gdhi2020: gdhi } = await prisma.gDHI.findFirstOrThrow({ - where: { - itl3: { equals: itl3 }, - }, - select: { gdhi2020: true }, - }); - if (!gdhi) throw Error("Missing gdhi"); - - const { - _avg: { monthlyMeanRent: averageRentMonthly }, - } = await prisma.rent.aggregate({ - where: { itl3 }, - _avg: { - monthlyMeanRent: true, - }, - }); - if (!averageRentMonthly) throw Error("Missing averageRentMonthly"); - - const socialRentAdjustments = await prisma.socialRentAdjustments.findMany(); - const itl3Prefix = itl3.substring(0, 4); - - const { - _avg: { earningsPerWeek: socialRentAveEarning }, - } = await prisma.socialRent.aggregate({ - where: { - itl3: { - startsWith: itl3Prefix, - }, - }, - _avg: { - earningsPerWeek: true, - }, - }); - - if (!socialRentAveEarning) throw Error("Missing socialRentAveEarning"); - - const { - _avg: { hpi2020: averageHpi }, - } = await prisma.hPI.aggregate({ - where: { - itl3: { - endsWith: itl3, - }, - }, - _avg: { - hpi2020: true, - }, - }); - if (!averageHpi) throw Error("Missing averageHpi"); - - const { bill: gasBillYearly } = await prisma.gasBills.findFirstOrThrow({ - where: { - itl: { - startsWith: itl3.substring(0, 3), - }, - }, - select: { - bill: true, - }, - }); - if (!gasBillYearly) throw Error("Missing gasBillYearly"); - - return NextResponse.json({ - postcode: input.housePostcode, - houseType: input.houseType, - houseAge: input.houseAge, - houseBedrooms: input.houseBedrooms, - houseSize: input.houseSize, - averagePrice: parseFloat(averagePrice.toFixed(2)), - itl3, - gdhi, - hpi: averageHpi, - buildPrice, - averageRentMonthly, - socialRentAdjustments, - socialRentAveEarning, - numberOfTransactions, - granularityPostcode, - pricesPaid, - gasBillYearly, - }); + const householdData = await calculationService.getHouseholdData(input); + return NextResponse.json(householdData); } catch (err) { console.log("ERROR: API - ", (err as Error).message); const response = { error: (err as Error).message }; return NextResponse.json(response, { status: 500 }); - } finally { - await prisma.$disconnect(); } } diff --git a/app/data/db.ts b/app/data/db.ts new file mode 100644 index 00000000..e5d74822 --- /dev/null +++ b/app/data/db.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from "@prisma/client"; + +const prismaClientSingleton = () => { + return new PrismaClient(); +}; + +declare const globalThis: { + prismaGlobal: ReturnType; +} & typeof global; + +const prisma = globalThis.prismaGlobal ?? prismaClientSingleton(); + +export default prisma; + +if (process.env.NODE_ENV !== "production") globalThis.prismaGlobal = prisma; diff --git a/app/data/gdhiRepo.ts b/app/data/gdhiRepo.ts new file mode 100644 index 00000000..69ed3b82 --- /dev/null +++ b/app/data/gdhiRepo.ts @@ -0,0 +1,26 @@ +import prisma from "./db"; + +const getGDHI2020ByITL3 = async ( + itl3: string +): Promise => { + try { + const { gdhi2020 } = await prisma.gDHI.findFirstOrThrow({ + where: { + AND: { + itl3: { equals: itl3 }, + // TODO: Add `NOT NULL` constraint to column + gdhi2020: { not: null } + }, + }, + select: { gdhi2020: true }, + }); + + return gdhi2020 as number; + } catch (error) { + throw Error(`Data error: Unable to find gdhi2020 for itl3 ${itl3}`); + } +}; + +export const gdhiRepo = { + getGDHI2020ByITL3, +} diff --git a/app/data/itlRepo.ts b/app/data/itlRepo.ts new file mode 100644 index 00000000..8bc92b4d --- /dev/null +++ b/app/data/itlRepo.ts @@ -0,0 +1,28 @@ +import prisma from "./db"; + +const getItl3ByPostcodeDistrict = async ( + postcodeDistrict: string +): Promise => { + try { + const { itl3 } = await prisma.itlLookup.findFirstOrThrow({ + where: { + postcode: postcodeDistrict, + itl3: { + not: null, + }, + }, + select: { + itl3: true, + }, + }); + + // Cast to string as 'not: null' clause in Prisma query does not type narrow + return itl3 as string; + } catch (error) { + throw new Error(`Data error: Unable get get itl3 for postcode district ${postcodeDistrict}`); + } +}; + +export const itlRepo = { + getItl3ByPostcodeDistrict, +} diff --git a/app/services/calculationService.ts b/app/services/calculationService.ts new file mode 100644 index 00000000..50d30de8 --- /dev/null +++ b/app/services/calculationService.ts @@ -0,0 +1,198 @@ +import { PrismaClient } from "@prisma/client"; +import { itlService } from "./itlService"; +import { gdhiService } from "./gdhiService"; +import { Calculation } from "../schemas/calculationSchema"; + +const prisma = new PrismaClient(); + +export const getHouseholdData = async ( + input: Calculation +) => { + try { + // data are going to be queried at different levels of granularity based on the postcode + const postcode = input.housePostcode; + const postcodeArea = postcode.area; // extract only the characters for the area, e.g SE + const postcodeDistrict = postcode.district; // extract only characters for the district, SE17 + const postcodeSector = postcode.sector; // extract only the characters for the sector, SE17 1 + + // create the progressive queries + const minimumNumberPostcodes = 30; // minimum number of entries to create the average + let pricesPaid; // declare the variable for prices paid + let numberOfTransactions; // declare the variable for numbers of transactions retrieved + let granularityPostcode; // declare the granularity of the postcode + let averagePrice; + + const pricesPaidSector = await prisma.pricesPaid.aggregate({ + where: { + propertyType: { + equals: input.houseType, + }, + postcode: { + startsWith: postcodeSector, + }, + }, + _count: { + id: true, + }, + _avg: { + price: true, + }, + }); + + const numberPerSector = pricesPaidSector._count.id; + const isMinMetBySector = numberPerSector >= minimumNumberPostcodes; + + if (!isMinMetBySector) { + const pricesPaidDistrict = await prisma.pricesPaid.aggregate({ + where: { + propertyType: { + equals: input.houseType, + }, + postcode: { + startsWith: postcodeDistrict, + }, + }, + _count: { + id: true, + }, + _avg: { + price: true, + }, + }); + + const numberPerDistrict = pricesPaidDistrict._count.id; + const isMinMetByDistrict = numberPerDistrict >= minimumNumberPostcodes; + + if (!isMinMetByDistrict) { + const pricesPaidArea = await prisma.pricesPaid.aggregate({ + where: { + propertyType: { + equals: input.houseType, + }, + postcode: { + startsWith: postcodeArea, + }, + }, + _count: { + id: true, + }, + _avg: { + price: true, + }, + }); + const numberPerArea = pricesPaidArea._count.id; + + pricesPaid = pricesPaidArea; // if condition is met, the granularity is appropriate + numberOfTransactions = numberPerArea; // check the granularity + granularityPostcode = postcodeArea; // granularity of the postcode when performing the average price search + averagePrice = pricesPaidArea._avg.price; + } else { + pricesPaid = pricesPaidDistrict; // if condition is met, the granularity is appropriate + numberOfTransactions = numberPerDistrict; // check the granularity + granularityPostcode = postcodeDistrict; // granularity of the postcode + averagePrice = pricesPaidDistrict._avg.price; + } + } else { + pricesPaid = pricesPaidSector; // if condition is met, the granularity is appropriate + numberOfTransactions = numberPerSector; // check the granularity + granularityPostcode = postcodeSector; // granularity of the postcode + averagePrice = pricesPaidSector._avg.price; + } + + if (averagePrice === null) { + throw new Error("Unable to calculate average price"); + } + + const { priceMid: buildPrice } = await prisma.buildPrices.findFirstOrThrow({ + where: { + houseType: { equals: input.houseType }, + }, + select: { priceMid: true }, + }); + // TODO: Make columns non-nullable + if (!buildPrice) throw Error("Missing buildPrice"); + + const itl3 = await itlService.getByPostcodeDistrict(postcodeDistrict); + const gdhi = await gdhiService.getByITL3(itl3); + + const { + _avg: { monthlyMeanRent: averageRentMonthly }, + } = await prisma.rent.aggregate({ + where: { itl3 }, + _avg: { + monthlyMeanRent: true, + }, + }); + if (!averageRentMonthly) throw Error("Missing averageRentMonthly"); + + const socialRentAdjustments = await prisma.socialRentAdjustments.findMany(); + const itl3Prefix = itl3.substring(0, 4); + + const { + _avg: { earningsPerWeek: socialRentAveEarning }, + } = await prisma.socialRent.aggregate({ + where: { + itl3: { + startsWith: itl3Prefix, + }, + }, + _avg: { + earningsPerWeek: true, + }, + }); + + if (!socialRentAveEarning) throw Error("Missing socialRentAveEarning"); + + const { + _avg: { hpi2020: averageHpi }, + } = await prisma.hPI.aggregate({ + where: { + itl3: { + endsWith: itl3, + }, + }, + _avg: { + hpi2020: true, + }, + }); + if (!averageHpi) throw Error("Missing averageHpi"); + + const { bill: gasBillYearly } = await prisma.gasBills.findFirstOrThrow({ + where: { + itl: { + startsWith: itl3.substring(0, 3), + }, + }, + select: { + bill: true, + }, + }); + if (!gasBillYearly) throw Error("Missing gasBillYearly"); + + return { + postcode: input.housePostcode, + houseType: input.houseType, + houseAge: input.houseAge, + houseBedrooms: input.houseBedrooms, + houseSize: input.houseSize, + averagePrice: parseFloat(averagePrice.toFixed(2)), + itl3, + gdhi, + hpi: averageHpi, + buildPrice, + averageRentMonthly, + socialRentAdjustments, + socialRentAveEarning, + numberOfTransactions, + granularityPostcode, + pricesPaid, + gasBillYearly, + } + } catch (err) { + throw Error( + `Service error: Unable to generate household. Message: ${(err as Error).message}` + ); + } finally { + await prisma.$disconnect(); + } +}; diff --git a/app/services/gdhiService.ts b/app/services/gdhiService.ts new file mode 100644 index 00000000..3dbb7529 --- /dev/null +++ b/app/services/gdhiService.ts @@ -0,0 +1,9 @@ +import { gdhiRepo } from "../data/gdhiRepo"; + +const getByITL3 = async (itl3: string) => { + return await gdhiRepo.getGDHI2020ByITL3(itl3); +}; + +export const gdhiService = { + getByITL3, +}; diff --git a/app/services/itlService.ts b/app/services/itlService.ts new file mode 100644 index 00000000..358af0f9 --- /dev/null +++ b/app/services/itlService.ts @@ -0,0 +1,9 @@ +import { itlRepo } from "../data/itlRepo"; + +const getByPostcodeDistrict = async (postcodeDistrict: string) => { + return await itlRepo.getItl3ByPostcodeDistrict(postcodeDistrict); +} + +export const itlService = { + getByPostcodeDistrict, +}