diff --git a/.github/workflows/deploy-to-supabase-production.yml b/.github/workflows/deploy-to-supabase-production.yml index 6469099a..41c06021 100644 --- a/.github/workflows/deploy-to-supabase-production.yml +++ b/.github/workflows/deploy-to-supabase-production.yml @@ -27,3 +27,4 @@ jobs: - run: | supabase link --project-ref $PRODUCTION_PROJECT_ID supabase db push + supabase functions deploy diff --git a/.github/workflows/deploy-to-supabase-staging.yml b/.github/workflows/deploy-to-supabase-staging.yml index 19dca6e8..4e2cc1e5 100644 --- a/.github/workflows/deploy-to-supabase-staging.yml +++ b/.github/workflows/deploy-to-supabase-staging.yml @@ -27,3 +27,4 @@ jobs: - run: | supabase link --project-ref $STAGING_PROJECT_ID supabase db push + supabase functions deploy diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6127c929..405077b3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -44,11 +44,10 @@ jobs: - run: supabase start - run: npm ci - run: npm run build --if-present - # Make sure to run tests in band and force exit to avoid hanging tests - # until we know where the open handles are + - run: npm run lint - run: npm test -- --runInBand --forceExit - run: supabase stop - - run: npm run lint + release: name: semantic-release needs: [test] diff --git a/.gitignore b/.gitignore index cd30ab5e..eb4433ef 100644 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,6 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ -.env.test # parcel-bundler cache (https://parceljs.org/) .cache # Next.js build output diff --git a/README.md b/README.md index 360dd48c..34be28d6 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,16 @@ npm test On CI the Supabase is started automagically. See [.github/workflows/tests.yml](.github/workflows/tests.yml) +To run the tests for the Supabase Edge Functions, execute locally: + +```bash +cd giessdenkiez-de-postgres-api +docker run -p 1025:1025 mailhog/mailhog +supabase start +supabase functions serve --no-verify-jwt --env-file supabase/.env.test +deno test --allow-all supabase/functions/tests/submit-contact-request-tests.ts --env=supabase/.env.test +``` + ## Supabase ### Migrations and Types @@ -106,7 +116,7 @@ On CI the Supabase is started automagically. See [.github/workflows/tests.yml](. - **(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. @@ -132,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 d49e00a5..34bcec46 100644 --- a/src/database.ts +++ b/src/database.ts @@ -9,6 +9,108 @@ export type Json = export type Database = { public: { Tables: { + contact_requests: { + Row: { + contact_id: string + contact_mail_id: string | null + contact_message: string | null + created_at: string + id: string + user_id: string + } + Insert: { + contact_id: string + contact_mail_id?: string | null + contact_message?: string | null + created_at?: string + id?: string + user_id: string + } + Update: { + contact_id?: string + contact_mail_id?: string | null + contact_message?: string | null + created_at?: string + id?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: "contact_requests_contact_id_fkey" + columns: ["contact_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + { + foreignKeyName: "contact_requests_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } + 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 @@ -269,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 @@ -276,6 +413,15 @@ export type Database = { } Returns: number } + get_user_data_for_id: { + Args: { + u_id: string + } + Returns: { + id: string + email: string + }[] + } get_watered_and_adopted: { Args: Record Returns: { diff --git a/supabase/.env.sample b/supabase/.env.sample new file mode 100644 index 00000000..bc6870a3 --- /dev/null +++ b/supabase/.env.sample @@ -0,0 +1,6 @@ +ALLOWED_ORIGIN=http://localhost:5173 +SMTP_HOST=... +SMTP_USER=... +SMTP_PASSWORD=... +SMTP_FROM=... +SMTP_PORT=... \ No newline at end of file diff --git a/supabase/.env.test b/supabase/.env.test new file mode 100644 index 00000000..78630fe1 --- /dev/null +++ b/supabase/.env.test @@ -0,0 +1,7 @@ +ALLOWED_ORIGIN=http://localhost:5173 +SMTP_HOST=host.docker.internal +SMTP_USER="" +SMTP_PASSWORD="" +SMTP_FROM=giessdenkiez@citylab-berlin.org +SMTP_PORT=1025 +SMTP_SECURE=false \ No newline at end of file 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/contact-request-checks.ts b/supabase/functions/_shared/contact-request-checks.ts new file mode 100644 index 00000000..fb0bc788 --- /dev/null +++ b/supabase/functions/_shared/contact-request-checks.ts @@ -0,0 +1,115 @@ +import { SupabaseClient } from "npm:@supabase/supabase-js"; +import { sub } from "npm:date-fns"; + +export interface CheckResult { + isAllowed: boolean; + reason: string | undefined; + lookupData: ContactRequestLookupData | undefined; +} + +export interface ContactRequestLookupData { + senderUsername: string; + senderEmail: string; + senderUserId: string; + recipientUserId: string; +} + +export async function checkIfContactRequestIsAllowed( + recipientContactName: string, + token: string, + supabaseClient: SupabaseClient, + supabaseServiceRoleClient: SupabaseClient +): Promise { + // Get the user (= sender) data from the token + const { data: senderData, error: senderDataError } = + await supabaseClient.auth.getUser(token); + + if (senderDataError) { + return { isAllowed: false, reason: "unauthorized", lookupData: undefined }; + } + + // Lookup the sender username + const { data: senderLookupData, error: senderLookupDataError } = + await supabaseServiceRoleClient + .from("profiles") + .select("*") + .eq("id", senderData.user.id) + .single(); + + if (senderLookupDataError) { + return { isAllowed: false, reason: "not_found", lookupData: undefined }; + } + + // Lookup the recipient user id + const { data: recipientData, error: recipientDataError } = + await supabaseServiceRoleClient + .from("profiles") + .select("*") + .eq("username", recipientContactName) + .single(); + + if (recipientDataError) { + return { isAllowed: false, reason: "not_found", lookupData: undefined }; + } + + // Check if the user has already tried to contact the recipient + const { data: requestsToRecipient, error: requestsToRecipientError } = + await supabaseClient + .from("contact_requests") + .select("*") + .eq("user_id", senderData.user.id) + .eq("contact_id", recipientData.id) + .not("contact_mail_id", "is", null); // only count sent emails + + if (requestsToRecipientError) { + return { + isAllowed: false, + reason: "internal_server_error", + lookupData: undefined, + }; + } + + if (requestsToRecipient.length > 0) { + return { + isAllowed: false, + reason: "already_contacted_the_recipient_before", + lookupData: undefined, + }; + } + + // Check if the user has sent 3 contact requests in the last 24 hours + const { data: requestsOfLast24h, error: requestsOfLast24hError } = + await supabaseClient + .from("contact_requests") + .select("*") + .eq("user_id", senderData.user.id) + .not("contact_mail_id", "is", null) // only count sent emails + .gt("created_at", sub(new Date(), { days: 1 }).toISOString()); + + if (requestsOfLast24hError) { + return { + isAllowed: false, + reason: "internal_server_error", + lookupData: undefined, + }; + } + + if (requestsOfLast24h.length >= 3) { + return { + isAllowed: false, + reason: "already_sent_more_than_3_contact_requests", + lookupData: undefined, + }; + } + + return { + isAllowed: true, + reason: undefined, + lookupData: { + senderUsername: senderLookupData.username, + senderEmail: senderData.user.email, + senderUserId: senderData.user.id, + recipientUserId: recipientData.id, + }, + }; +} diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts new file mode 100644 index 00000000..e9d9d09d --- /dev/null +++ b/supabase/functions/_shared/cors.ts @@ -0,0 +1,8 @@ +const ALLOWED_ORIGIN = Deno.env.get("ALLOWED_ORIGIN"); + +export const corsHeaders = { + "Access-Control-Allow-Origin": ALLOWED_ORIGIN, + "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 new file mode 100644 index 00000000..aaae7eb7 --- /dev/null +++ b/supabase/functions/check_contact_request/index.ts @@ -0,0 +1,73 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { corsHeaders } from "../_shared/cors.ts"; +import { loadEnvVars } from "../_shared/check-env.ts"; +import { checkIfContactRequestIsAllowed } from "../_shared/contact-request-checks.ts"; + +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") { + return new Response(null, { headers: corsHeaders, status: 204 }); + } + + const { recipientContactName } = await _request.json(); + + const authHeader = _request.headers.get("Authorization")!; + + const supabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { headers: { Authorization: authHeader } }, + }); + + const supabaseServiceRoleClient = createClient( + SUPABASE_URL, + SUPABASE_SERVICE_ROLE_KEY + ); + + const token = authHeader.replace("Bearer ", ""); + + const { isAllowed, reason } = await checkIfContactRequestIsAllowed( + recipientContactName, + token, + supabaseClient, + supabaseServiceRoleClient + ); + + if (!isAllowed) { + return new Response( + JSON.stringify({ + isContactRequestAllowed: false, + reason, + }), + { + status: 200, // We have to use 200 here to allow the client to read the response body + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + return new Response( + JSON.stringify({ + isContactRequestAllowed: true, + reason: undefined, + }), + { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); +}; + +Deno.serve(handler); diff --git a/supabase/functions/gdk_stats/index.ts b/supabase/functions/gdk_stats/index.ts new file mode 100644 index 00000000..cc156ed7 --- /dev/null +++ b/supabase/functions/gdk_stats/index.ts @@ -0,0 +1,233 @@ +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, + maximumTemperatureCelsius: month.max_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 new file mode 100644 index 00000000..64bd201b --- /dev/null +++ b/supabase/functions/submit_contact_request/index.ts @@ -0,0 +1,176 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import nodemailer from "npm:nodemailer"; +import { checkIfContactRequestIsAllowed } from "../_shared/contact-request-checks.ts"; +import { corsHeaders } from "../_shared/cors.ts"; +import { mailTemplate } from "./mail-template.ts"; +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 authHeader = request.headers.get("Authorization")!; + + const supabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { headers: { Authorization: authHeader } }, + }); + + const supabaseServiceRoleClient = createClient( + SUPABASE_URL, + SUPABASE_SERVICE_ROLE_KEY + ); + + // Get the user (= sender) data from the token + const token = authHeader.replace("Bearer ", ""); + + const { isAllowed, reason, lookupData } = + await checkIfContactRequestIsAllowed( + recipientContactName, + token, + supabaseClient, + supabaseServiceRoleClient + ); + + if (!isAllowed || !lookupData) { + return new Response( + JSON.stringify({ + isContactRequestAllowed: false, + reason, + }), + { + status: 403, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + // Lookup the recipient email address via serviceRoleClient + const { data: fullRecipientData, error: fullRecipientDataError } = + await supabaseServiceRoleClient + .rpc("get_user_data_for_id", { u_id: lookupData.recipientUserId }) + .select("email") + .single(); + + if (fullRecipientDataError) { + console.error(fullRecipientDataError); + return new Response(JSON.stringify(fullRecipientDataError), { + status: 404, + headers: corsHeaders, + }); + } + + // Save the contact request + const { data: insertedRequest, error: insertedRequestError } = + await supabaseClient + .from("contact_requests") + .insert({ + user_id: lookupData.senderUserId, + contact_id: lookupData.recipientUserId, + contact_message: message, + }) + .select("*") + .single(); + + if (insertedRequestError) { + console.error(insertedRequestError); + return new Response(JSON.stringify(insertedRequestError), { + status: 500, + headers: corsHeaders, + }); + } + + // Send the email + try { + const transporter = nodemailer.createTransport({ + host: SMTP_HOST, + port: SMTP_PORT, + // Use `true` for port 465, `false` for all other ports, see: https://nodemailer.com/ + secure: SMTP_SECURE, + // auth must be undefined if no SMTP_PASSWORD is set + auth: + SMTP_PASSWORD === "" + ? undefined + : { + user: SMTP_USER, + pass: SMTP_PASSWORD, + }, + }); + + const mailOptions = { + from: SMTP_FROM, + to: fullRecipientData.email, + replyTo: lookupData.senderEmail, + subject: "Kontaktanfrage / Contact request", + html: mailTemplate( + lookupData.senderUsername, + message, + lookupData.senderEmail + ), + }; + + // Send the email + const info = await transporter.sendMail(mailOptions); + + // Update the contact request with the email id + const { error: updateRequestError } = await supabaseClient + .from("contact_requests") + .update({ + contact_mail_id: info.response, + }) + .eq("id", insertedRequest.id); + + if (updateRequestError) { + console.error(updateRequestError); + return new Response(JSON.stringify(updateRequestError), { + status: 500, + headers: corsHeaders, + }); + } + } catch (e) { + console.error(e); + return new Response(JSON.stringify(e), { + status: 500, + headers: corsHeaders, + }); + } + + return new Response(JSON.stringify({ code: "contact_request_sent" }), { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + }); +}; + +Deno.serve(handler); diff --git a/supabase/functions/submit_contact_request/mail-template.ts b/supabase/functions/submit_contact_request/mail-template.ts new file mode 100644 index 00000000..385e6044 --- /dev/null +++ b/supabase/functions/submit_contact_request/mail-template.ts @@ -0,0 +1,390 @@ +export const mailTemplate = ( + username: string, + message: string, + email: string +) => ` + + + + + + + + + + + + + + + +`; diff --git a/supabase/functions/tests/submit-contact-request-tests.ts b/supabase/functions/tests/submit-contact-request-tests.ts new file mode 100644 index 00000000..3af31e15 --- /dev/null +++ b/supabase/functions/tests/submit-contact-request-tests.ts @@ -0,0 +1,214 @@ +import { assertEquals } from "https://deno.land/std@0.192.0/testing/asserts.ts"; + +import { + SupabaseClient, + createClient, +} from "https://esm.sh/@supabase/supabase-js@2.23.0"; + +const supabaseUrl = Deno.env.get("SUPABASE_URL") ?? ""; +const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY") ?? ""; +const supabaseServiceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; + +const options = { + auth: { + autoRefreshToken: false, + persistSession: false, + detectSessionInUrl: false, + }, +}; + +const supabaseAnonClient: SupabaseClient = createClient( + supabaseUrl, + supabaseAnonKey, + options +); +const supabaseServiceRoleClient: SupabaseClient = createClient( + supabaseUrl, + supabaseServiceRoleKey, + options +); + +const create5Users = async () => { + const { data: user1CreationData, error: user1CreationDataError } = + await supabaseServiceRoleClient.auth.admin.createUser({ + email: "user1@test.com", + password: "password1", + email_confirm: true, + }); + assertEquals(user1CreationDataError, null); + + const { data: user2CreationData, error: user2CreationDataError } = + await supabaseServiceRoleClient.auth.admin.createUser({ + email: "user2@test.com", + password: "password2", + email_confirm: true, + }); + assertEquals(user2CreationDataError, null); + + const { data: user3CreationData, error: user3CreationDataError } = + await supabaseServiceRoleClient.auth.admin.createUser({ + email: "user3@test.com", + password: "password3", + email_confirm: true, + }); + assertEquals(user3CreationDataError, null); + + const { data: user4CreationData, error: user4CreationDataError } = + await supabaseServiceRoleClient.auth.admin.createUser({ + email: "user4@test.com", + password: "password4", + email_confirm: true, + }); + assertEquals(user4CreationDataError, null); + + const { data: user5CreationData, error: user5CreationDataError } = + await supabaseServiceRoleClient.auth.admin.createUser({ + email: "user5@test.com", + password: "password5", + email_confirm: true, + }); + assertEquals(user5CreationDataError, null); + + return [ + user1CreationData, + user2CreationData, + user3CreationData, + user4CreationData, + user5CreationData, + ]; +}; + +const deleteAllUsers = async (users: any[]) => { + for (const user of users) { + await supabaseServiceRoleClient.auth.admin.deleteUser(user.user.id); + } +}; + +const testContactRequestBlockReasons = async () => { + // Create 5 users + const users = await create5Users(); + + // Login as user1 + const { error: userLoginDataError } = + await supabaseAnonClient.auth.signInWithPassword({ + email: "user1@test.com", + password: "password1", + }); + assertEquals(userLoginDataError, null); + + // First contact request to user2 should be possible -> used 1/3 requests + const { data: firstContactRequestData } = + await supabaseAnonClient.functions.invoke("submit_contact_request", { + body: { + recipientContactName: "user2", + message: "Hello, world!", + }, + }); + + assertEquals(firstContactRequestData.code, "contact_request_sent"); + + // Second contact request to same user2 should be blocked -> still used 1/3 requests + const { data: blockedContactRequestData } = + await supabaseAnonClient.functions.invoke("submit_contact_request", { + body: { + recipientContactName: "user2", + message: "Hello, world!", + }, + }); + + assertEquals( + blockedContactRequestData.code, + "already_contacted_the_recipient_before" + ); + + // Second contact request to different user3 should be possible, used 2/3 requests + const { data: secondContactRequestData } = + await supabaseAnonClient.functions.invoke("submit_contact_request", { + body: { + recipientContactName: "user3", + message: "Hello, world!", + }, + }); + + assertEquals(secondContactRequestData.code, "contact_request_sent"); + + // Third contact request to different user4 should be possible, used 3/3 requests + const { data: thirdContactRequestData } = + await supabaseAnonClient.functions.invoke("submit_contact_request", { + body: { + recipientContactName: "user4", + message: "Hello, world!", + }, + }); + + assertEquals(thirdContactRequestData.code, "contact_request_sent"); + + // Fourth contact request to different user4 should be blocked -> already used 3/3 request + const { data: dailyLimitContactRequestData } = + await supabaseAnonClient.functions.invoke("submit_contact_request", { + body: { + recipientContactName: "user5", + message: "Hello, world!", + }, + }); + + assertEquals( + dailyLimitContactRequestData.code, + "already_sent_more_than_3_contact_requests" + ); + + await deleteAllUsers(users); +}; + +const testUnauthorizedFunctionInvocation = async () => { + const { error } = await supabaseAnonClient.functions.invoke( + "submit_contact_request", + { + body: { + recipientContactName: "user2", + message: "Hello, world!", + }, + } + ); + // Workaround: https://github.com/supabase/functions-js/issues/65 + await error.context.json(); + assertEquals(error.context.status, 401); +}; + +const testRecipientNotFound = async () => { + // Create 5 users + const users = await create5Users(); + + // Login as user1 + const { error: userLoginDataError } = + await supabaseAnonClient.auth.signInWithPassword({ + email: "user1@test.com", + password: "password1", + }); + assertEquals(userLoginDataError, null); + + // Try to contact a non-existing user + const { error } = await supabaseAnonClient.functions.invoke( + "submit_contact_request", + { + body: { + recipientContactName: "user871", + message: "Hello, world!", + }, + } + ); + + // Workaround: https://github.com/supabase/functions-js/issues/65 + await error.context.json(); + assertEquals(error.context.status, 404); + + await deleteAllUsers(users); +}; + +// Register and run tests +Deno.test( + "testUnauthorizedFunctionInvocation", + testUnauthorizedFunctionInvocation +); +Deno.test("testRecipientNotFound", testRecipientNotFound); +Deno.test("testContactRequestBlockReasons", testContactRequestBlockReasons); diff --git a/supabase/migrations/20240604135436_contact_requests.sql b/supabase/migrations/20240604135436_contact_requests.sql new file mode 100644 index 00000000..0b0ed223 --- /dev/null +++ b/supabase/migrations/20240604135436_contact_requests.sql @@ -0,0 +1,49 @@ +create table contact_requests ( + id uuid primary key default gen_random_uuid(), + user_id uuid references auth.users(id) not null, + contact_id uuid references auth.users(id) not null, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + contact_message text, + contact_mail_id text default null -- the resend.io ID of the sent contact mail +); + +alter table "public"."contact_requests" enable row level security; + +create policy "Authenticated users can insert their own contact requests" +on contact_requests +for insert to authenticated +with check (auth.uid() = user_id); + +create policy "Authenticated users can select their own contact requests" +on contact_requests +for select to authenticated +using (auth.uid() = user_id); + +create policy "Authenticated users can delete their own contact requests" +on contact_requests +for delete to authenticated +using (auth.uid() = user_id); + +create policy "Authenticated users can update their own contact requests" +on contact_requests +for update to authenticated +using (auth.uid() = user_id); + +grant select on table auth.users to service_role; + +CREATE OR REPLACE FUNCTION public.get_user_data_for_id(u_id uuid) + RETURNS TABLE(id uuid, email character varying) + LANGUAGE plpgsql +AS $function$ +BEGIN + RETURN query + + SELECT + au.id, au.email + FROM + auth.users au + WHERE + au.id = u_id; + +END; +$function$; \ No newline at end of file diff --git a/supabase/migrations/20240612143035_contact_requests_on_delete_cascade.sql b/supabase/migrations/20240612143035_contact_requests_on_delete_cascade.sql new file mode 100644 index 00000000..f762ed9c --- /dev/null +++ b/supabase/migrations/20240612143035_contact_requests_on_delete_cascade.sql @@ -0,0 +1,11 @@ +alter table contact_requests drop constraint contact_requests_user_id_fkey; +alter table contact_requests add constraint contact_requests_user_id_fkey + foreign key (user_id) + references auth.users(id) + on delete cascade; + +alter table contact_requests drop constraint contact_requests_contact_id_fkey; +alter table contact_requests add constraint contact_requests_contact_id_fkey + foreign key (contact_id) + references auth.users(id) + on delete cascade; \ No newline at end of file diff --git a/supabase/migrations/20240619130511_montly_rain.sql b/supabase/migrations/20240619130511_montly_rain.sql new file mode 100644 index 00000000..2e05b6e1 --- /dev/null +++ b/supabase/migrations/20240619130511_montly_rain.sql @@ -0,0 +1,25 @@ +create table if not exists daily_weather_data ( + id serial primary key, + created_at timestamp not null default now(), + measure_day timestamp not null, + day_finished boolean not null default false, + sum_precipitation_mm_per_sqm float, + avg_temperature_celsius float, + avg_pressure_msl float, + sum_sunshine_minutes float, + avg_wind_direction_deg float, + avg_wind_speed_kmh float, + avg_cloud_cover_percentage float, + avg_dew_point_celcius float, + avg_relative_humidity_percentage float, + avg_visibility_m float, + avg_wind_gust_direction_deg float, + avg_wind_gust_speed_kmh float, + source_dwd_station_ids text[] +); + +alter table "public"."daily_weather_data" enable row level security; +create policy "Allow anonymous select on daily_weather_data" + on "public"."daily_weather_data" + for select + using (true); \ No newline at end of file diff --git a/supabase/migrations/20240620113031_rain_functions.sql b/supabase/migrations/20240620113031_rain_functions.sql new file mode 100644 index 00000000..66e68ca8 --- /dev/null +++ b/supabase/migrations/20240620113031_rain_functions.sql @@ -0,0 +1,32 @@ +CREATE OR REPLACE FUNCTION public.accumulated_weather_per_month (limit_monts int) + RETURNS TABLE ( + measure_day text, sum_precipitation_mm_per_sqm float, avg_temperature_celsius float, avg_pressure_msl float, sum_sunshine_minutes float, avg_wind_direction_deg float, avg_wind_speed_kmh float, avg_cloud_cover_percentage float, avg_dew_point_celcius float, avg_relative_humidity_percentage float, avg_visibility_m float, avg_wind_gust_direction_deg float, avg_wind_gust_speed_kmh float) + LANGUAGE plpgsql + SECURITY INVOKER + AS $function$ +BEGIN + RETURN query + SELECT + to_char(daily_weather_data.measure_day, 'YYYY-MM'), + sum(daily_weather_data.sum_precipitation_mm_per_sqm) AS sum_precipitation_mm_per_sqm, + avg(daily_weather_data.avg_temperature_celsius) AS avg_temperature_celsius, + avg(daily_weather_data.avg_pressure_msl) AS avg_pressure_msl, + sum(daily_weather_data.sum_sunshine_minutes) AS sum_sunshine_minutes, + avg(daily_weather_data.avg_wind_direction_deg) AS avg_wind_direction_deg, + avg(daily_weather_data.avg_wind_speed_kmh) AS avg_wind_speed_kmh, + avg(daily_weather_data.avg_cloud_cover_percentage) AS avg_cloud_cover_percentage, + avg(daily_weather_data.avg_dew_point_celcius) AS avg_dew_point_celcius, + avg(daily_weather_data.avg_relative_humidity_percentage) AS avg_relative_humidity_percentage, + avg(daily_weather_data.avg_visibility_m) AS avg_visibility_m, + avg(daily_weather_data.avg_wind_gust_direction_deg) AS avg_wind_gust_direction_deg, + avg(daily_weather_data.avg_wind_gust_speed_kmh) AS avg_wind_gust_speed_kmh + 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 +LIMIT limit_monts; +END; +$function$; \ No newline at end of file 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$; diff --git a/supabase/migrations/20240708094214_fix_db_stats_function.sql b/supabase/migrations/20240708094214_fix_db_stats_function.sql new file mode 100644 index 00000000..820f38bf --- /dev/null +++ b/supabase/migrations/20240708094214_fix_db_stats_function.sql @@ -0,0 +1,14 @@ +drop function if exists get_monthly_weather(); + +CREATE OR REPLACE FUNCTION public.get_monthly_weather() + RETURNS TABLE(month text, avg_temperature_celsius double precision, max_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, MAX(daily_weather_data.avg_temperature_celsius) as max_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$