From c34008cea9835b99a71582661c73ea7ebdb135a2 Mon Sep 17 00:00:00 2001 From: Jonas Jaszkowic Date: Thu, 4 Jul 2024 17:19:39 +0200 Subject: [PATCH] feat: gdk stats (#269) * feat: first gdk stats * feat: monthly waterings * feat: mostFrequentTreeSpecies * fix: typo * chore: refactoring * fix: error handling and env * chore: comments * feat: more database functions * feat: env var existence check * feat: more distinguishable errors * fix: console.error instead of console.log for errors * chore: cleanup * fix: env variables * fix: more restrictive cors, chore: cleanup --- README.md | 13 +- package-lock.json | 52 ++-- package.json | 2 +- src/database.ts | 95 +++++++ supabase/.env.sample | 3 - supabase/functions/_shared/check-env.ts | 7 + supabase/functions/_shared/common.ts | 43 ++++ .../{checks.ts => contact-request-checks.ts} | 9 - supabase/functions/_shared/cors.ts | 2 +- supabase/functions/_shared/errors.ts | 14 ++ .../functions/check_contact_request/index.ts | 12 +- supabase/functions/gdk_stats/index.ts | 232 ++++++++++++++++++ .../functions/submit_contact_request/index.ts | 56 +++-- .../tests/submit-contact-request-tests.ts | 3 +- .../20240620143046_db_stats_functions.sql | 83 +++++++ 15 files changed, 559 insertions(+), 67 deletions(-) create mode 100644 supabase/functions/_shared/check-env.ts create mode 100644 supabase/functions/_shared/common.ts rename supabase/functions/_shared/{checks.ts => contact-request-checks.ts} (92%) create mode 100644 supabase/functions/_shared/errors.ts create mode 100644 supabase/functions/gdk_stats/index.ts create mode 100644 supabase/migrations/20240620143046_db_stats_functions.sql diff --git a/README.md b/README.md index 39c04694..34be28d6 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ deno test --allow-all supabase/functions/tests/submit-contact-request-tests.ts - - **(Not recommended but possible)** Link your local project directly to the remote `supabase link --project-ref ` (will ask you for your database password from the creation process) - **(Not recommended but possible)** Push your local state directly to your remote project `supabase db push` (will ask you for your database password from the creation process) -#### Supabase +#### Supabase Auth Some of the requests need a authorized user. You can create a new user using email password via the Supabase API. @@ -142,6 +142,17 @@ curl --request POST \ See the [docs/api.http](./docs/api.http) file for more examples or take a look into the API documentation in your local supabase instance under http://localhost:54323/project/default/api?page=users +#### Supabase Edge Functions +To run the Supabase Edge Functions locally: + +- Setup the .env file in [supabase/.env](supabase/.env) according to [supabase/.env.sample](supabase/.env.sample) +- Note: The env variables `SUPABASE_SERVICE_ROLE_KEY` and `SUPABASE_URL` are injected automatically and can't be set the in the [supabase/.env](supabase/.env) file. If you want to overwrite them, you have to rename the environment variables to not start with `SUPABASE_`. For reference, see: https://supabase.com/docs/guides/functions/secrets +- With the environment variables setup correctly, execute `supabase functions serve --no-verify-jwt --env-file supabase/.env` + +To deploy the Edge Functions in your linked remote Supabase project, execute: +- `supabase functions deploy` +- Make sure that you set the proper environment variables in the remote Supabase project too + ## Tests Locally you will need supabase running and a `.env` file with the right values in it. diff --git a/package-lock.json b/package-lock.json index bfb4df8e..51c75316 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.0.0", "license": "MIT", "dependencies": { - "@supabase/supabase-js": "2.43.2" + "@supabase/supabase-js": "2.43.5" }, "devDependencies": { "@saithodev/semantic-release-backmerge": "4.0.1", @@ -2469,9 +2469,9 @@ } }, "node_modules/@supabase/functions-js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.3.1.tgz", - "integrity": "sha512-QyzNle/rVzlOi4BbVqxLSH828VdGY1RElqGFAj+XeVypj6+PVtMlD21G8SDnsPQDtlqqTtoGRgdMlQZih5hTuw==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.1.tgz", + "integrity": "sha512-8sZ2ibwHlf+WkHDUZJUXqqmPvWQ3UHN0W30behOJngVh/qHHekhJLCFbh0AjkE9/FqqXtf9eoVvmYgfCLk5tNA==", "dependencies": { "@supabase/node-fetch": "^2.6.14" } @@ -2488,9 +2488,9 @@ } }, "node_modules/@supabase/postgrest-js": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.15.2.tgz", - "integrity": "sha512-9/7pUmXExvGuEK1yZhVYXPZnLEkDTwxgMQHXLrN5BwPZZm4iUCL1YEyep/Z2lIZah8d8M433mVAUEGsihUj5KQ==", + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.15.5.tgz", + "integrity": "sha512-YR4TiitTE2hizT7mB99Cl3V9i00RAY5sUxS2/NuWWzkreM7OeYlP2OqnqVwwb4z6ILn+j8x9e/igJDepFhjswQ==", "dependencies": { "@supabase/node-fetch": "^2.6.14" } @@ -2507,24 +2507,24 @@ } }, "node_modules/@supabase/storage-js": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.5.5.tgz", - "integrity": "sha512-OpLoDRjFwClwc2cjTJZG8XviTiQH4Ik8sCiMK5v7et0MDu2QlXjCAW3ljxJB5+z/KazdMOTnySi+hysxWUPu3w==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.6.0.tgz", + "integrity": "sha512-REAxr7myf+3utMkI2oOmZ6sdplMZZ71/2NEIEMBZHL9Fkmm3/JnaOZVSRqvG4LStYj2v5WhCruCzuMn6oD/Drw==", "dependencies": { "@supabase/node-fetch": "^2.6.14" } }, "node_modules/@supabase/supabase-js": { - "version": "2.43.2", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.43.2.tgz", - "integrity": "sha512-F9CljeJBo5aPucNhrLoMnpEHi5yqNZ0vH0/CL4mGy+/Ggr7FUrYErVJisa1NptViqyhs1HGNzzwjOYG6626h8g==", + "version": "2.43.5", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.43.5.tgz", + "integrity": "sha512-Y4GukjZWW6ouohMaPlYz8tSz9ykf9jY7w9/RhqKuScmla3Xiklce8eLr8TYAtA+oQYCWxo3RgS3B6O4rd/72FA==", "dependencies": { "@supabase/auth-js": "2.64.2", - "@supabase/functions-js": "2.3.1", + "@supabase/functions-js": "2.4.1", "@supabase/node-fetch": "2.6.15", - "@supabase/postgrest-js": "1.15.2", + "@supabase/postgrest-js": "1.15.5", "@supabase/realtime-js": "2.9.5", - "@supabase/storage-js": "2.5.5" + "@supabase/storage-js": "2.6.0" } }, "node_modules/@technologiestiftung/semantic-release-config": { @@ -3266,12 +3266,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -4695,9 +4695,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -12707,9 +12707,9 @@ } }, "node_modules/ws": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz", - "integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "engines": { "node": ">=10.0.0" }, diff --git a/package.json b/package.json index a22b9c42..b67b4f90 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "node": ">=18" }, "dependencies": { - "@supabase/supabase-js": "2.43.2" + "@supabase/supabase-js": "2.43.5" }, "devDependencies": { "@saithodev/semantic-release-backmerge": "4.0.1", diff --git a/src/database.ts b/src/database.ts index 994acd24..34bcec46 100644 --- a/src/database.ts +++ b/src/database.ts @@ -51,6 +51,66 @@ export type Database = { }, ] } + daily_weather_data: { + Row: { + avg_cloud_cover_percentage: number | null + avg_dew_point_celcius: number | null + avg_pressure_msl: number | null + avg_relative_humidity_percentage: number | null + avg_temperature_celsius: number | null + avg_visibility_m: number | null + avg_wind_direction_deg: number | null + avg_wind_gust_direction_deg: number | null + avg_wind_gust_speed_kmh: number | null + avg_wind_speed_kmh: number | null + created_at: string + day_finished: boolean + id: number + measure_day: string + source_dwd_station_ids: string[] | null + sum_precipitation_mm_per_sqm: number | null + sum_sunshine_minutes: number | null + } + Insert: { + avg_cloud_cover_percentage?: number | null + avg_dew_point_celcius?: number | null + avg_pressure_msl?: number | null + avg_relative_humidity_percentage?: number | null + avg_temperature_celsius?: number | null + avg_visibility_m?: number | null + avg_wind_direction_deg?: number | null + avg_wind_gust_direction_deg?: number | null + avg_wind_gust_speed_kmh?: number | null + avg_wind_speed_kmh?: number | null + created_at?: string + day_finished?: boolean + id?: number + measure_day: string + source_dwd_station_ids?: string[] | null + sum_precipitation_mm_per_sqm?: number | null + sum_sunshine_minutes?: number | null + } + Update: { + avg_cloud_cover_percentage?: number | null + avg_dew_point_celcius?: number | null + avg_pressure_msl?: number | null + avg_relative_humidity_percentage?: number | null + avg_temperature_celsius?: number | null + avg_visibility_m?: number | null + avg_wind_direction_deg?: number | null + avg_wind_gust_direction_deg?: number | null + avg_wind_gust_speed_kmh?: number | null + avg_wind_speed_kmh?: number | null + created_at?: string + day_finished?: boolean + id?: number + measure_day?: string + source_dwd_station_ids?: string[] | null + sum_precipitation_mm_per_sqm?: number | null + sum_sunshine_minutes?: number | null + } + Relationships: [] + } profiles: { Row: { id: string @@ -311,6 +371,41 @@ export type Database = { [_ in never]: never } Functions: { + accumulated_weather_per_month: { + Args: { + limit_monts: number + } + Returns: { + measure_day: string + sum_precipitation_mm_per_sqm: number + avg_temperature_celsius: number + avg_pressure_msl: number + sum_sunshine_minutes: number + avg_wind_direction_deg: number + avg_wind_speed_kmh: number + avg_cloud_cover_percentage: number + avg_dew_point_celcius: number + avg_relative_humidity_percentage: number + avg_visibility_m: number + avg_wind_gust_direction_deg: number + avg_wind_gust_speed_kmh: number + }[] + } + calculate_avg_waterings_per_month: { + Args: Record + Returns: { + month: string + watering_count: number + avg_amount_per_watering: number + }[] + } + calculate_top_tree_species: { + Args: Record + Returns: { + gattung_deutsch: string + percentage: number + }[] + } count_by_age: { Args: { start_year: number diff --git a/supabase/.env.sample b/supabase/.env.sample index 3b66df06..bc6870a3 100644 --- a/supabase/.env.sample +++ b/supabase/.env.sample @@ -1,6 +1,3 @@ -URL=http://host.docker.internal:54321 -ANON_KEY=ey.. -SERVICE_ROLE_KEY=ey... ALLOWED_ORIGIN=http://localhost:5173 SMTP_HOST=... SMTP_USER=... diff --git a/supabase/functions/_shared/check-env.ts b/supabase/functions/_shared/check-env.ts new file mode 100644 index 00000000..fab3e20b --- /dev/null +++ b/supabase/functions/_shared/check-env.ts @@ -0,0 +1,7 @@ +export const loadEnvVars = (vars: string[]) => { + const missingVars = vars.filter((v) => !Deno.env.get(v)); + if (missingVars.length > 0) { + throw new Error(`Missing environment variables: ${missingVars.join(", ")}`); + } + return vars.map((v) => Deno.env.get(v)); +}; diff --git a/supabase/functions/_shared/common.ts b/supabase/functions/_shared/common.ts new file mode 100644 index 00000000..ccc435ed --- /dev/null +++ b/supabase/functions/_shared/common.ts @@ -0,0 +1,43 @@ +export interface TreeSpecies { + speciesName?: string; + percentage: number; +} + +export interface Monthly { + month: string; + wateringCount: number; + averageAmountPerWatering: number; + totalSum: number; +} + +export interface Watering { + id: string; + lat: number; + lng: number; + amount: number; + timestamp: string; +} + +export interface TreeAdoptions { + count: number; + veryThirstyCount: number; +} + +export interface GdkStats { + numTrees: number; + numPumps: number; + numActiveUsers: number; + numWateringsThisYear: number; + monthlyWaterings: Monthly[]; + treeAdoptions: TreeAdoptions; + mostFrequentTreeSpecies: TreeSpecies[]; + totalTreeSpeciesCount: number; + waterings: Watering[]; + monthlyWeather: MonthlyWeather[]; +} + +export interface MonthlyWeather { + month: string; + averageTemperatureCelsius: number; + totalRainfallLiters: number; +} diff --git a/supabase/functions/_shared/checks.ts b/supabase/functions/_shared/contact-request-checks.ts similarity index 92% rename from supabase/functions/_shared/checks.ts rename to supabase/functions/_shared/contact-request-checks.ts index f1628fd7..fb0bc788 100644 --- a/supabase/functions/_shared/checks.ts +++ b/supabase/functions/_shared/contact-request-checks.ts @@ -24,10 +24,7 @@ export async function checkIfContactRequestIsAllowed( const { data: senderData, error: senderDataError } = await supabaseClient.auth.getUser(token); - console.log(senderData); - if (senderDataError) { - console.log(senderDataError); return { isAllowed: false, reason: "unauthorized", lookupData: undefined }; } @@ -39,10 +36,7 @@ export async function checkIfContactRequestIsAllowed( .eq("id", senderData.user.id) .single(); - console.log(senderLookupData); - if (senderLookupDataError) { - console.log(senderLookupDataError); return { isAllowed: false, reason: "not_found", lookupData: undefined }; } @@ -55,7 +49,6 @@ export async function checkIfContactRequestIsAllowed( .single(); if (recipientDataError) { - console.log(recipientDataError); return { isAllowed: false, reason: "not_found", lookupData: undefined }; } @@ -69,7 +62,6 @@ export async function checkIfContactRequestIsAllowed( .not("contact_mail_id", "is", null); // only count sent emails if (requestsToRecipientError) { - console.log(requestsToRecipientError); return { isAllowed: false, reason: "internal_server_error", @@ -95,7 +87,6 @@ export async function checkIfContactRequestIsAllowed( .gt("created_at", sub(new Date(), { days: 1 }).toISOString()); if (requestsOfLast24hError) { - console.log(requestsOfLast24hError); return { isAllowed: false, reason: "internal_server_error", diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts index ce27bf50..e9d9d09d 100644 --- a/supabase/functions/_shared/cors.ts +++ b/supabase/functions/_shared/cors.ts @@ -2,7 +2,7 @@ const ALLOWED_ORIGIN = Deno.env.get("ALLOWED_ORIGIN"); export const corsHeaders = { "Access-Control-Allow-Origin": ALLOWED_ORIGIN, - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type,Authorization,x-client-info,apikey", }; diff --git a/supabase/functions/_shared/errors.ts b/supabase/functions/_shared/errors.ts new file mode 100644 index 00000000..732aa005 --- /dev/null +++ b/supabase/functions/_shared/errors.ts @@ -0,0 +1,14 @@ +export enum ErrorTypes { + GdkStatsPump = "gdk_stats_pumps", + GdkStatsUser = "gdk_stats_users", + GdkStatsWatering = "gdk_stats_waterings", + GdkStatsAdoption = "gdk_stats_adoptions", + GdkStatsTreeSpecie = "gdk_stats_tree_species", + GdkStatsWeather = "gdk_stats_weather", +} + +export class GdkError extends Error { + constructor(message: string, public errorType: ErrorTypes) { + super(message); + } +} diff --git a/supabase/functions/check_contact_request/index.ts b/supabase/functions/check_contact_request/index.ts index 727604ab..85a8d3b4 100644 --- a/supabase/functions/check_contact_request/index.ts +++ b/supabase/functions/check_contact_request/index.ts @@ -1,10 +1,16 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import { checkIfContactRequestIsAllowed } from "../_shared/checks.ts"; import { corsHeaders } from "../_shared/cors.ts"; +import { loadEnvVars } from "../_shared/check-env.ts"; -const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); -const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY"); -const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); +const ENV_VARS = [ + "SUPABASE_URL", + "SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY", +]; + +const [SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY] = + loadEnvVars(ENV_VARS); const handler = async (_request: Request): Promise => { if (_request.method === "OPTIONS") { diff --git a/supabase/functions/gdk_stats/index.ts b/supabase/functions/gdk_stats/index.ts new file mode 100644 index 00000000..64eaee2f --- /dev/null +++ b/supabase/functions/gdk_stats/index.ts @@ -0,0 +1,232 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { corsHeaders } from "../_shared/cors.ts"; +import { loadEnvVars } from "../_shared/check-env.ts"; +import { + GdkStats, + Monthly, + MonthlyWeather, + TreeAdoptions, + TreeSpecies, + Watering, +} from "../_shared/common.ts"; +import { GdkError, ErrorTypes } from "../_shared/errors.ts"; + +const ENV_VARS = ["SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY", "PUMPS_URL"]; +const [SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, PUMPS_URL] = + loadEnvVars(ENV_VARS); + +// As trees table barely changes, we can hardcode the values +// It would be too expensive to calculate on each request + +// SELECT COUNT(1) FROM trees; +const TREE_COUNT = 885825; + +// SELECT trees.gattung_deutsch, (COUNT(1) * 100.0) / (SELECT COUNT(1) FROM trees) AS percentage +// FROM trees +// GROUP BY trees.gattung_deutsch +// ORDER BY COUNT(1) DESC +// LIMIT 20; +const MOST_FREQUENT_TREE_SPECIES: TreeSpecies[] = [ + { speciesName: "AHORN", percentage: 22.8128580701605848 }, + { speciesName: "LINDE", percentage: 21.5930911861823724 }, + { speciesName: "EICHE", percentage: 10.5370699630288149 }, + { speciesName: undefined, percentage: 4.1923630513927695 }, + { speciesName: "ROBINIE", percentage: 3.9515705698078063 }, + { speciesName: "ROSSKASTANIE", percentage: 3.6574944260999633 }, + { speciesName: "BIRKE", percentage: 3.610419665283775 }, + { speciesName: "HAINBUCHE", percentage: 3.4514717918324726 }, + { speciesName: "PLATANE", percentage: 3.3499844777467333 }, + { speciesName: "PAPPEL", percentage: 2.8882679987582197 }, + { speciesName: "ESCHE", percentage: 2.7732339909124263 }, + { speciesName: "KIEFER", percentage: 2.4801738492365874 }, + { speciesName: "ULME", percentage: 1.946998560663788 }, + { speciesName: "BUCHE", percentage: 1.7521519487483419 }, + { speciesName: "HASEL", percentage: 1.1728050122766912 }, + { speciesName: "WEIßDORN", percentage: 1.1243755820844975 }, + { speciesName: "WEIDE", percentage: 1.0893799565376909 }, + { speciesName: "MEHLBEERE", percentage: 0.90469336494228544013 }, + { speciesName: "ERLE", percentage: 0.80907628481923630514 }, + { speciesName: "APFEL", percentage: 0.70092851296813704739 }, +]; + +// SELECT COUNT(gattung_deutsch) FROM trees GROUP BY gattung_deutsch; +const TOTAL_TREE_SPECIES_COUNT = 97; + +const supabaseServiceRoleClient = createClient( + SUPABASE_URL, + SUPABASE_SERVICE_ROLE_KEY +); + +const getUserProfilesCount = async (): Promise => { + const { count } = await supabaseServiceRoleClient + .from("profiles") + .select("*", { count: "exact", head: true }); + + if (count === null) { + throw new GdkError( + "Could not fetch count of profiles table", + ErrorTypes.GdkStatsUser + ); + } + + return count || 0; +}; + +const getWateringsCount = async (): Promise => { + const beginningOfYear = new Date(`${new Date().getFullYear()}-01-01`); + const { count } = await supabaseServiceRoleClient + .from("trees_watered") + .select("*", { count: "exact", head: true }) + .gt("timestamp", beginningOfYear.toISOString()); + + if (count === null) { + throw new GdkError( + "Could not fetch count of trees_watered table", + ErrorTypes.GdkStatsWatering + ); + } + + return count || 0; +}; + +const getPumpsCount = async (): Promise => { + const response = await fetch(PUMPS_URL); + if (response.status !== 200) { + throw new GdkError(response.statusText, ErrorTypes.GdkStatsPump); + } + const geojson = await response.json(); + return geojson.features.length; +}; + +const getAdoptedTreesCount = async (): Promise => { + const { data, error } = await supabaseServiceRoleClient + .rpc("calculate_adoptions") + .select("*"); + + if (error) { + throw new GdkError(error.message, ErrorTypes.GdkStatsAdoption); + } + + return { + count: data[0].total_adoptions, + veryThirstyCount: data[0].very_thirsty_adoptions, + } as TreeAdoptions; +}; + +const getMonthlyWaterings = async (): Promise => { + const { data, error } = await supabaseServiceRoleClient + .rpc("calculate_avg_waterings_per_month") + .select("*"); + + if (error) { + throw new GdkError(error.message, ErrorTypes.GdkStatsWatering); + } + + return data.map((month: any) => ({ + month: month.month, + wateringCount: month.watering_count, + totalSum: month.total_sum, + averageAmountPerWatering: month.avg_amount_per_watering, + })); +}; + +const getMonthlyWeather = async (): Promise => { + const { data, error } = await supabaseServiceRoleClient + .rpc("get_monthly_weather") + .select("*"); + + if (error) { + throw new GdkError(error.message, ErrorTypes.GdkStatsWeather); + } + + return data.map((month: any) => ({ + month: month.month, + averageTemperatureCelsius: month.avg_temperature_celsius, + totalRainfallLiters: month.total_rainfall_liters, + })); +}; + +const getWaterings = async (): Promise => { + const { data, error } = await supabaseServiceRoleClient + .rpc("get_waterings_with_location") + .select("*"); + + if (error) { + throw new GdkError(error.message, ErrorTypes.GdkStatsWatering); + } + + return data.map((watering: any) => { + return { + id: watering.id, + lat: watering.lat, + lng: watering.lng, + amount: watering.amount, + timestamp: watering.timestamp, + }; + }); +}; + +const handler = async (request: Request): Promise => { + if (request.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders, status: 204 }); + } + + try { + const [ + usersCount, + wateringsCount, + treeAdoptions, + numPumps, + monthlyWaterings, + waterings, + monthlyWeather, + ] = await Promise.all([ + getUserProfilesCount(), + getWateringsCount(), + getAdoptedTreesCount(), + getPumpsCount(), + getMonthlyWaterings(), + getWaterings(), + getMonthlyWeather(), + ]); + + const stats: GdkStats = { + numTrees: TREE_COUNT, + numPumps: numPumps, + numActiveUsers: usersCount, + numWateringsThisYear: wateringsCount, + monthlyWaterings: monthlyWaterings, + treeAdoptions: treeAdoptions, + mostFrequentTreeSpecies: MOST_FREQUENT_TREE_SPECIES, + totalTreeSpeciesCount: TOTAL_TREE_SPECIES_COUNT, + waterings: waterings, + monthlyWeather: monthlyWeather, + }; + + return new Response(JSON.stringify(stats), { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } catch (error) { + if (error instanceof GdkError) { + console.error( + `Error of type ${error.errorType} in gdk_stats function invocation: ${error.message}` + ); + } else { + console.error(JSON.stringify(error)); + } + + return new Response(JSON.stringify(error), { + status: 500, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); + } +}; + +Deno.serve(handler); diff --git a/supabase/functions/submit_contact_request/index.ts b/supabase/functions/submit_contact_request/index.ts index a46b0f4c..64bd201b 100644 --- a/supabase/functions/submit_contact_request/index.ts +++ b/supabase/functions/submit_contact_request/index.ts @@ -1,28 +1,42 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import nodemailer from "npm:nodemailer"; -import { checkIfContactRequestIsAllowed } from "../_shared/checks.ts"; +import { checkIfContactRequestIsAllowed } from "../_shared/contact-request-checks.ts"; import { corsHeaders } from "../_shared/cors.ts"; import { mailTemplate } from "./mail-template.ts"; - -const SMTP_HOST = Deno.env.get("SMTP_HOST"); -const SMTP_USER = Deno.env.get("SMTP_USER"); -const SMTP_PASSWORD = Deno.env.get("SMTP_PASSWORD"); -const SMTP_FROM = Deno.env.get("SMTP_FROM"); -const SMTP_PORT = parseInt(Deno.env.get("SMTP_PORT")); -const SMTP_SECURE = Deno.env.get("SMTP_SECURE") === "true"; - -const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); -const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY"); -const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); - -const handler = async (_request: Request): Promise => { - if (_request.method === "OPTIONS") { +import { loadEnvVars } from "../_shared/check-env.ts"; + +const ENV_VARS = [ + "SMTP_HOST", + "SMTP_USER", + "SMTP_PASSWORD", + "SMTP_FROM", + "SMTP_PORT", + "SMTP_SECURE", + "SUPABASE_URL", + "SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY", +]; + +const [ + SMTP_HOST, + SMTP_USER, + SMTP_PASSWORD, + SMTP_FROM, + SMTP_PORT, + SMTP_SECURE, + SUPABASE_URL, + SUPABASE_ANON_KEY, + SUPABASE_SERVICE_ROLE_KEY, +] = loadEnvVars(ENV_VARS); + +const handler = async (request: Request): Promise => { + if (request.method === "OPTIONS") { return new Response(null, { headers: corsHeaders, status: 204 }); } - const { recipientContactName, message } = await _request.json(); + const { recipientContactName, message } = await request.json(); - const authHeader = _request.headers.get("Authorization")!; + const authHeader = request.headers.get("Authorization")!; const supabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { global: { headers: { Authorization: authHeader } }, @@ -68,7 +82,7 @@ const handler = async (_request: Request): Promise => { .single(); if (fullRecipientDataError) { - console.log(fullRecipientDataError); + console.error(fullRecipientDataError); return new Response(JSON.stringify(fullRecipientDataError), { status: 404, headers: corsHeaders, @@ -88,7 +102,7 @@ const handler = async (_request: Request): Promise => { .single(); if (insertedRequestError) { - console.log(insertedRequestError); + console.error(insertedRequestError); return new Response(JSON.stringify(insertedRequestError), { status: 500, headers: corsHeaders, @@ -136,14 +150,14 @@ const handler = async (_request: Request): Promise => { .eq("id", insertedRequest.id); if (updateRequestError) { - console.log(updateRequestError); + console.error(updateRequestError); return new Response(JSON.stringify(updateRequestError), { status: 500, headers: corsHeaders, }); } } catch (e) { - console.log(e); + console.error(e); return new Response(JSON.stringify(e), { status: 500, headers: corsHeaders, diff --git a/supabase/functions/tests/submit-contact-request-tests.ts b/supabase/functions/tests/submit-contact-request-tests.ts index 717e67b8..3af31e15 100644 --- a/supabase/functions/tests/submit-contact-request-tests.ts +++ b/supabase/functions/tests/submit-contact-request-tests.ts @@ -97,14 +97,13 @@ const testContactRequestBlockReasons = async () => { assertEquals(userLoginDataError, null); // First contact request to user2 should be possible -> used 1/3 requests - const { data: firstContactRequestData, error: firstContactRequestDataError } = + const { data: firstContactRequestData } = await supabaseAnonClient.functions.invoke("submit_contact_request", { body: { recipientContactName: "user2", message: "Hello, world!", }, }); - console.log(firstContactRequestData, firstContactRequestDataError); assertEquals(firstContactRequestData.code, "contact_request_sent"); diff --git a/supabase/migrations/20240620143046_db_stats_functions.sql b/supabase/migrations/20240620143046_db_stats_functions.sql new file mode 100644 index 00000000..51a2ae98 --- /dev/null +++ b/supabase/migrations/20240620143046_db_stats_functions.sql @@ -0,0 +1,83 @@ +CREATE OR REPLACE FUNCTION public.calculate_avg_waterings_per_month() + RETURNS TABLE(month text, watering_count bigint, avg_amount_per_watering numeric, total_sum numeric) + LANGUAGE plpgsql +AS $function$ +BEGIN + RETURN QUERY + SELECT to_char(trees_watered.timestamp, 'yyyy-mm') AS month, COUNT(1) AS watering_count, SUM(trees_watered.amount) / COUNT(1) as avg_amount_per_watering, SUM(trees_watered.amount) as total_sum + FROM trees_watered + GROUP BY to_char(trees_watered.timestamp, 'yyyy-mm') + ORDER BY to_char(trees_watered.timestamp, 'yyyy-mm') DESC; +END; +$function$; + +CREATE OR REPLACE FUNCTION public.calculate_top_tree_species() + RETURNS TABLE(gattung_deutsch text, percentage numeric) + LANGUAGE plpgsql +AS $function$ +BEGIN + RETURN QUERY + SELECT trees.gattung_deutsch, (COUNT(1) * 100.0) / (SELECT COUNT(1) FROM trees) AS percentage + FROM trees + GROUP BY trees.gattung_deutsch + ORDER BY COUNT(1) DESC + LIMIT 20; +END; +$function$; + +CREATE OR REPLACE FUNCTION public.get_waterings_with_location() + RETURNS TABLE(id text, lat double precision, lng double precision, amount numeric, "timestamp" timestamp with time zone) + LANGUAGE plpgsql +AS $function$ +BEGIN + RETURN QUERY + SELECT t.id, ST_Y(t.geom) AS lat, ST_X(t.geom) AS lng, tw.amount, tw."timestamp" + from trees_watered tw, trees t + where tw.tree_id = t.id + and tw."timestamp" > DATE_TRUNC('year', CURRENT_DATE)::date; +END; +$function$; + +CREATE OR REPLACE FUNCTION public.calculate_adoptions() + RETURNS TABLE(total_adoptions bigint, very_thirsty_adoptions bigint) + LANGUAGE plpgsql +AS $function$ +BEGIN +RETURN QUERY + WITH adoptions AS ( + SELECT + ta.id AS adoption_id, + t.id AS tree_id, + t.pflanzjahr, + date_part('year', + now()) - t.pflanzjahr AS age, + (date_part('year', + now()) - t.pflanzjahr >= 5 + AND date_part('year', + now()) - t.pflanzjahr <= 10) AS very_thirsty + FROM + trees_adopted ta, + trees t + WHERE + ta.tree_id = t.id +) +SELECT + count(1) total_adoptions, + count(1) FILTER (WHERE adoptions.very_thirsty) AS very_thirsty_adoptions +FROM + adoptions; +END; +$function$; + +CREATE OR REPLACE FUNCTION public.get_monthly_weather() + RETURNS TABLE(month text, avg_temperature_celsius double precision, total_rainfall_liters double precision) + LANGUAGE plpgsql +AS $function$ +BEGIN + RETURN QUERY + SELECT to_char(daily_weather_data.measure_day, 'yyyy-mm') AS month, AVG(daily_weather_data.avg_temperature_celsius) as avg_temperature_celsius, SUM(daily_weather_data.sum_precipitation_mm_per_sqm) as total_rainfall_liters + FROM daily_weather_data + GROUP BY to_char(daily_weather_data.measure_day, 'yyyy-mm') + ORDER BY to_char(daily_weather_data.measure_day, 'yyyy-mm') DESC; +END; +$function$;