diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..fa29cdff --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +** \ No newline at end of file diff --git a/apps/server/src/env.mjs b/apps/server/src/env.mjs index b58bbce1..8d4493cd 100644 --- a/apps/server/src/env.mjs +++ b/apps/server/src/env.mjs @@ -38,7 +38,12 @@ const server = z.object({ CRON_KEY: z.string().optional(), NEXT_PUBLIC_LOGTAIL_SOURCE_TOKEN: z.string().optional(), WHATSAPP_ENDPOINT_URL: z.string(), - WHATSAPP_ENDPOINT_AUTH_TOKEN: z.string() + WHATSAPP_ENDPOINT_AUTH_TOKEN: z.string(), + PUBLIC_API_CACHING: z.union([z.literal("true"), z.literal("false")]).optional() + .transform((val) => { + if (val === undefined) return true; // since it is optional by default caching kept true + return val === "true"; + }), }); /** @@ -58,7 +63,7 @@ const client = z.object({ const processEnv = { DATABASE_PRISMA_URL: process.env.DATABASE_PRISMA_URL, DATABASE_URL: process.env.DATABASE_URL ? process.env.DATABASE_URL : process.env.DATABASE_PRISMA_URL, - DATABASE_URL_NON_POOLING: process.env.DATABASE_URL_NON_POOLING ? process.env.DATABASE_URL_NON_POOLING : process.env.DATABASE_URL, + DATABASE_URL_NON_POOLING: process.env.DATABASE_URL_NON_POOLING ? process.env.DATABASE_URL_NON_POOLING : process.env.DATABASE_URL, // DATABASE_PRISMA_URL is set by VERCEL POSTGRES and had pooling built in. NODE_ENV: process.env.NODE_ENV, NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, @@ -79,7 +84,8 @@ const processEnv = { CRON_KEY: process.env.CRON_KEY, NEXT_PUBLIC_LOGTAIL_SOURCE_TOKEN: process.env.NEXT_PUBLIC_LOGTAIL_SOURCE_TOKEN, WHATSAPP_ENDPOINT_URL: process.env.WHATSAPP_ENDPOINT_URL, - WHATSAPP_ENDPOINT_AUTH_TOKEN: process.env.WHATSAPP_ENDPOINT_AUTH_TOKEN + WHATSAPP_ENDPOINT_AUTH_TOKEN: process.env.WHATSAPP_ENDPOINT_AUTH_TOKEN, + PUBLIC_API_CACHING: process.env.PUBLIC_API_CACHING }; diff --git a/apps/server/src/pages/api/v1/fires/index.ts b/apps/server/src/pages/api/v1/fires/index.ts new file mode 100644 index 00000000..c0d91988 --- /dev/null +++ b/apps/server/src/pages/api/v1/fires/index.ts @@ -0,0 +1,168 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { prisma } from "../../../../server/db"; +import { logger } from "../../../../server/logger"; +import { env } from "../../../../env.mjs" + + +type ResponseData = + | GeoJSON.GeoJSON + | { + message?: string; + error?: object | unknown; + }; + +const CACHING = env.PUBLIC_API_CACHING ?? true; + +export default async function firesBySiteHandler( + req: NextApiRequest, + res: NextApiResponse +) { + try { + checkCORS(req, res); + checkMethods(req, res, ["GET"]); + + let siteId = req.query.siteId as string; + const remoteId = req.query.remoteId as string; + + if (!siteId && !remoteId) + return res.status(400).json({ message: "No site provided." }); + + const foundSite = await prisma.site.findFirst({ where: { OR: [{ id: siteId }, { remoteId: remoteId }] } }); + if (!foundSite) return res.status(404).json({ message: "Site not found." }); + siteId = foundSite.id + + const span = handleParameter_span(req.query.span?.toString()); + + const alertsForSite = await prisma.siteAlert.findMany({ + where: { + siteId: siteId, + eventDate: { + gte: span, + }, + }, + select: { + id: true, + eventDate: true, + type: true, + latitude: true, + longitude: true, + detectedBy: true, + confidence: true, + distance: true, + }, + }); + + const fires = generateGeoJson( + alertsForSite.map((alert) => ({ + id: alert.id, + eventDate: alert.eventDate, + type: alert.type, + detectedBy: alert.detectedBy, + confidence: alert.confidence, + distance: alert.distance, + })), + alertsForSite.map((alert) => ({ + type: "Point", + coordinates: [alert.longitude, alert.latitude], + })) + ); + + if(CACHING) { + res.setHeader( + "Cache-Control", + "public, max-age=7200 s-maxage=3600, stale-while-revalidate=7200" + ); + res.setHeader("CDN-Cache-Control", "max-age=7200"); + res.setHeader("Cloudflare-CDN-Cache-Control", "max-age=7200"); + } + + res.status(200).json(fires); + } catch (error) { + console.log(error); + res.status(400).json({ message: "Failed!", error }); + } +} + +// Suggesting these type of utils - We might later need to organise them +function checkMethods( + req: NextApiRequest, + res: NextApiResponse, + allowedMethods: string[] +) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (!allowedMethods.includes(req.method!)) { + return res.status(405).json({ message: "Method Not Allowed" }); + } +} + +function checkCORS(req: NextApiRequest, res: NextApiResponse) { + res.setHeader('Access-Control-Allow-Credentials', 'true') + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE') + res.setHeader('Access-Control-Allow-Headers', '*') + if (req.method === 'OPTIONS') { + res.status(200).end() + return + } +} + +function checkAuthorization(req: NextApiRequest, res: NextApiResponse) { + const authorization = req.headers.authorization; + const accessToken = authorization?.split(" ")[1]; + if (!accessToken) { + return res.status(401).json({ message: "Unauthorized" }); + } + + // Some token verification mechanism... +} + +export function handleParameter_span(span?: string) { + let spanToDate = new Date(); + switch (span?.toLowerCase()) { + case "24h": + spanToDate = new Date(spanToDate.getTime() - 1000 * 60 * 60 * 24); + break; + case "7d": + spanToDate = new Date(spanToDate.getTime() - 1000 * 60 * 60 * 24 * 7); + break; + case "30d": + spanToDate = new Date(spanToDate.getTime() - 1000 * 60 * 60 * 24 * 30); + break; + case "1y": + spanToDate = new Date(spanToDate.getTime() - 1000 * 60 * 60 * 24 * 365); + break; + default: + logger("Does not match any possible values, using default 7D", "Log"); + spanToDate = new Date(spanToDate.getTime() - 1000 * 60 * 60 * 24 * 7); + } + + return spanToDate; +} + +export function generateGeoJson( + properties: GeoJSON.GeoJsonProperties[] = [], + points: GeoJSON.Point[] = [] +) { + const geoJson: GeoJSON.GeoJSON = { + type: "FeatureCollection", + features: [], + }; + + if (!properties.length || !points.length) { + // throw new Error("Properties and geometries should have valid values.") + } + + if (properties.length !== points.length) { + // throw new Error("Properties and geometries length should be equal.") + } + + for (let i = 0; i < properties.length; i++) { + geoJson.features.push({ + type: "Feature", + properties: properties[i], + geometry: points[i], + }); + } + + return geoJson; +}