From ffc81a86357973601502950668fa6de991178e6e Mon Sep 17 00:00:00 2001 From: Jonas Jaszkowic Date: Wed, 12 Jun 2024 13:18:18 +0200 Subject: [PATCH 01/12] feat: send mail via SMTP (#265) * feat: contact requests first draft * feat: first working version * feat: make sure user can only send one * feat: fetch mail via rpc * feat: cors * feat: cors via env * fix: typo * feat: Send via smtp directly * feat: check for not more than 3 contact requests in 24h * chore: better variable names * feat: adapt mail template --- src/database.ts | 51 +++ supabase/.env.sample | 9 + supabase/functions/_shared/cors.ts | 8 + .../functions/submit_contact_request/index.ts | 196 ++++++++ .../submit_contact_request/mail-template.ts | 430 ++++++++++++++++++ .../20240604135436_contact_requests.sql | 49 ++ 6 files changed, 743 insertions(+) create mode 100644 supabase/.env.sample create mode 100644 supabase/functions/_shared/cors.ts create mode 100644 supabase/functions/submit_contact_request/index.ts create mode 100644 supabase/functions/submit_contact_request/mail-template.ts create mode 100644 supabase/migrations/20240604135436_contact_requests.sql diff --git a/src/database.ts b/src/database.ts index d49e00a5..994acd24 100644 --- a/src/database.ts +++ b/src/database.ts @@ -9,6 +9,48 @@ 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"] + }, + ] + } profiles: { Row: { id: string @@ -276,6 +318,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..c272030a --- /dev/null +++ b/supabase/.env.sample @@ -0,0 +1,9 @@ +RESEND_API_KEY=re_... +URL=http://host.docker.internal:54321 +ANON_KEY=ey.. +SERVICE_ROLE_KEY=ey... +ALLOWED_ORIGIN=http://localhost:5173 +SMTP_HOST=... +SMTP_USER=... +SMTP_PASSWORD=... +SMTP_FROM=... \ No newline at end of file diff --git a/supabase/functions/_shared/cors.ts b/supabase/functions/_shared/cors.ts new file mode 100644 index 00000000..ce27bf50 --- /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, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": + "Content-Type,Authorization,x-client-info,apikey", +}; diff --git a/supabase/functions/submit_contact_request/index.ts b/supabase/functions/submit_contact_request/index.ts new file mode 100644 index 00000000..2081ac31 --- /dev/null +++ b/supabase/functions/submit_contact_request/index.ts @@ -0,0 +1,196 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { sub } from "npm:date-fns"; +import nodemailer from "npm:nodemailer"; +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 SUPABASE_URL = Deno.env.get("URL"); +const SUPABASE_ANON_KEY = Deno.env.get("ANON_KEY"); +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SERVICE_ROLE_KEY"); + +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 { data: senderData, error: senderDataError } = + await supabaseClient.auth.getUser(token); + + if (senderDataError) { + return new Response(undefined, { status: 401 }); + } + + // Lookup the recipient user id + const { data: recipientData, error: recipientDataError } = + await supabaseServiceRoleClient + .from("profiles") + .select("*") + .eq("username", recipientContactName) + .single(); + + if (recipientDataError) { + return new Response(undefined, { status: 404, headers: corsHeaders }); + } + + // 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 new Response(undefined, { status: 500, headers: corsHeaders }); + } + + if (requestsToRecipient.length > 0) { + return new Response( + JSON.stringify({ + code: "already_contacted_the_recipient_before", + message: + "User has already sent a contact request to the recipient, not allowed to send another one.", + }), + { + status: 200, + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + } + ); + } + + // 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) { + console.log(requestsOfLast24hError); + return new Response(undefined, { status: 500, headers: corsHeaders }); + } + + if (requestsOfLast24h.length >= 3) { + return new Response( + JSON.stringify({ + code: "already_sent_more_than_3_contact_requests", + message: + "User has already sent more than 3 contact requests in the last 24 hours, not allowed to send another one.", + }), + { + status: 200, + 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: recipientData.id }) + .select("email") + .single(); + + if (fullRecipientDataError) { + return new Response(undefined, { status: 404, headers: corsHeaders }); + } + + // Save the contact request + const { data: insertedRequest, error: insertedRequestError } = + await supabaseClient + .from("contact_requests") + .insert({ + user_id: senderData.user.id, + contact_id: recipientData.id, + contact_message: message, + }) + .select("*") + .single(); + + if (insertedRequestError) { + console.log(insertedRequestError); + return new Response(undefined, { status: 500, headers: corsHeaders }); + } + + // Send the email + try { + const transporter = nodemailer.createTransport({ + host: SMTP_HOST, + port: 25, + // Use `true` for port 465, `false` for all other ports, see: https://nodemailer.com/ + secure: false, + auth: { + user: SMTP_USER, + pass: SMTP_PASSWORD, + }, + }); + + const mailOptions = { + from: SMTP_FROM, + to: fullRecipientData.email, + subject: "[Gieß den Kiez] Kontaktanfrage / Contact request", + html: mailTemplate( + recipientContactName, + message, + fullRecipientData.email + ), + }; + + // 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) { + return new Response(undefined, { status: 500, headers: corsHeaders }); + } + } catch (e) { + console.log(e); + return new Response(undefined, { 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..604a9726 --- /dev/null +++ b/supabase/functions/submit_contact_request/mail-template.ts @@ -0,0 +1,430 @@ +export const mailTemplate = ( + username: string, + message: string, + email: string +) => ` + + + + + + + + + + + + + + + +`; 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 From 30d9050aaba9bc6dfb855bbc1444226b4ae852b7 Mon Sep 17 00:00:00 2001 From: Jonas Jaszkowic Date: Wed, 12 Jun 2024 13:19:10 +0200 Subject: [PATCH 02/12] feat: deploy functions --- .github/workflows/deploy-to-supabase-production.yml | 1 + .github/workflows/deploy-to-supabase-staging.yml | 1 + 2 files changed, 2 insertions(+) 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 From 6de16b97658847101efe69c11e6bd72e7c79ffc2 Mon Sep 17 00:00:00 2001 From: Jonas Jaszkowic Date: Wed, 12 Jun 2024 14:49:20 +0200 Subject: [PATCH 03/12] fix: use different smtp port --- supabase/.env.sample | 4 ++-- supabase/functions/submit_contact_request/index.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/supabase/.env.sample b/supabase/.env.sample index c272030a..3b66df06 100644 --- a/supabase/.env.sample +++ b/supabase/.env.sample @@ -1,4 +1,3 @@ -RESEND_API_KEY=re_... URL=http://host.docker.internal:54321 ANON_KEY=ey.. SERVICE_ROLE_KEY=ey... @@ -6,4 +5,5 @@ ALLOWED_ORIGIN=http://localhost:5173 SMTP_HOST=... SMTP_USER=... SMTP_PASSWORD=... -SMTP_FROM=... \ No newline at end of file +SMTP_FROM=... +SMTP_PORT=... \ No newline at end of file diff --git a/supabase/functions/submit_contact_request/index.ts b/supabase/functions/submit_contact_request/index.ts index 2081ac31..8360e2b6 100644 --- a/supabase/functions/submit_contact_request/index.ts +++ b/supabase/functions/submit_contact_request/index.ts @@ -8,6 +8,7 @@ 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 SUPABASE_URL = Deno.env.get("URL"); const SUPABASE_ANON_KEY = Deno.env.get("ANON_KEY"); @@ -145,9 +146,9 @@ const handler = async (_request: Request): Promise => { try { const transporter = nodemailer.createTransport({ host: SMTP_HOST, - port: 25, + port: SMTP_PORT, // Use `true` for port 465, `false` for all other ports, see: https://nodemailer.com/ - secure: false, + secure: true, auth: { user: SMTP_USER, pass: SMTP_PASSWORD, From 53a6dcc3364c48fa401c2d1f470cd4c4f94fdf68 Mon Sep 17 00:00:00 2001 From: Jonas Jaszkowic Date: Thu, 13 Jun 2024 12:27:07 +0200 Subject: [PATCH 04/12] feat: tests for contact request function (#266) feat: tests for contact request function (#266) feat: adjust email template --- .github/workflows/tests.yml | 5 +- .gitignore | 1 - README.md | 10 + supabase/.env.test | 7 + .../functions/submit_contact_request/index.ts | 63 +++-- .../submit_contact_request/mail-template.ts | 134 ++++------- .../tests/submit-contact-request-tests.ts | 215 ++++++++++++++++++ ...035_contact_requests_on_delete_cascade.sql | 11 + 8 files changed, 339 insertions(+), 107 deletions(-) create mode 100644 supabase/.env.test create mode 100644 supabase/functions/tests/submit-contact-request-tests.ts create mode 100644 supabase/migrations/20240612143035_contact_requests_on_delete_cascade.sql 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..39c04694 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 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/submit_contact_request/index.ts b/supabase/functions/submit_contact_request/index.ts index 8360e2b6..a12e1b6e 100644 --- a/supabase/functions/submit_contact_request/index.ts +++ b/supabase/functions/submit_contact_request/index.ts @@ -9,10 +9,11 @@ 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("URL"); -const SUPABASE_ANON_KEY = Deno.env.get("ANON_KEY"); -const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SERVICE_ROLE_KEY"); +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") { @@ -38,7 +39,8 @@ const handler = async (_request: Request): Promise => { await supabaseClient.auth.getUser(token); if (senderDataError) { - return new Response(undefined, { status: 401 }); + console.log(senderDataError); + return new Response(JSON.stringify({}), { status: 401 }); } // Lookup the recipient user id @@ -50,7 +52,11 @@ const handler = async (_request: Request): Promise => { .single(); if (recipientDataError) { - return new Response(undefined, { status: 404, headers: corsHeaders }); + console.log(recipientDataError); + return new Response(JSON.stringify(recipientDataError), { + status: 404, + headers: corsHeaders, + }); } // Check if the user has already tried to contact the recipient @@ -63,7 +69,11 @@ const handler = async (_request: Request): Promise => { .not("contact_mail_id", "is", null); // only count sent emails if (requestsToRecipientError) { - return new Response(undefined, { status: 500, headers: corsHeaders }); + console.log(requestsToRecipientError); + return new Response(JSON.stringify(requestsToRecipientError), { + status: 500, + headers: corsHeaders, + }); } if (requestsToRecipient.length > 0) { @@ -94,7 +104,10 @@ const handler = async (_request: Request): Promise => { if (requestsOfLast24hError) { console.log(requestsOfLast24hError); - return new Response(undefined, { status: 500, headers: corsHeaders }); + return new Response(JSON.stringify(requestsOfLast24hError), { + status: 500, + headers: corsHeaders, + }); } if (requestsOfLast24h.length >= 3) { @@ -122,7 +135,11 @@ const handler = async (_request: Request): Promise => { .single(); if (fullRecipientDataError) { - return new Response(undefined, { status: 404, headers: corsHeaders }); + console.log(fullRecipientDataError); + return new Response(JSON.stringify(fullRecipientDataError), { + status: 404, + headers: corsHeaders, + }); } // Save the contact request @@ -139,7 +156,10 @@ const handler = async (_request: Request): Promise => { if (insertedRequestError) { console.log(insertedRequestError); - return new Response(undefined, { status: 500, headers: corsHeaders }); + return new Response(JSON.stringify(insertedRequestError), { + status: 500, + headers: corsHeaders, + }); } // Send the email @@ -148,11 +168,15 @@ const handler = async (_request: Request): Promise => { host: SMTP_HOST, port: SMTP_PORT, // Use `true` for port 465, `false` for all other ports, see: https://nodemailer.com/ - secure: true, - auth: { - user: SMTP_USER, - pass: SMTP_PASSWORD, - }, + 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 = { @@ -178,11 +202,18 @@ const handler = async (_request: Request): Promise => { .eq("id", insertedRequest.id); if (updateRequestError) { - return new Response(undefined, { status: 500, headers: corsHeaders }); + console.log(updateRequestError); + return new Response(JSON.stringify(updateRequestError), { + status: 500, + headers: corsHeaders, + }); } } catch (e) { console.log(e); - return new Response(undefined, { status: 500, headers: corsHeaders }); + return new Response(JSON.stringify(e), { + status: 500, + headers: corsHeaders, + }); } return new Response(JSON.stringify({ code: "contact_request_sent" }), { diff --git a/supabase/functions/submit_contact_request/mail-template.ts b/supabase/functions/submit_contact_request/mail-template.ts index 604a9726..0629d2f5 100644 --- a/supabase/functions/submit_contact_request/mail-template.ts +++ b/supabase/functions/submit_contact_request/mail-template.ts @@ -186,47 +186,18 @@ export const mailTemplate = ( Um dem Nutzer per E-Mail zu antworten, schreibe bitte an: - Sollte die Kontaktanfrage unangemessene Inhalte enthalten, tut uns das sehr leid. Bitte informiere unverzüglich unser Team über info@citylab-berlin.org. -

- -

- - Gieß den Kiez ist eine Anwendung, die hilft, - ehrenamtliches Engagement beim Gießen durstiger - Stadtbäume zu koordinieren. Gieß den Kiez ist - ein Projekt der - Technologiestiftung Berlin - und wird vom - CityLAB Berlin - entwickelt. - + + Sollte die Kontaktanfrage unangemessene Inhalte enthalten, tut uns das sehr leid. Bitte informiere unverzüglich unser Team über info@citylab-berlin.org. +


@@ -273,45 +244,16 @@ export const mailTemplate = ( To reply to the user by e-mail, please write to: - If the contact request contains inappropriate content, we are very sorry. Please inform our team immediately via info@citylab-berlin.org. -

- -

- - Gieß den Kiez is an application that helps to - coordinate volunteer engagement to water thirsty - urban trees. Gieß den Kiez is a project of the - Technology Foundation Berlin - and is being developed by - CityLAB Berlin. - + If the contact request contains inappropriate content, we are very sorry. Please inform our team immediately via info@citylab-berlin.org.

- Du bekommst diese Nachricht, weil Du Dir auf - giessdenkiez.de - mit dieser E-Mail einen Account anlegen wolltest. + + Gieß den Kiez ist eine Anwendung, die hilft, + ehrenamtliches Engagement beim Gießen durstiger + Stadtbäume zu koordinieren. Gieß den Kiez ist + ein Projekt der + Technologiestiftung Berlin + und wird vom + CityLAB Berlin + entwickelt. +

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..717e67b8 --- /dev/null +++ b/supabase/functions/tests/submit-contact-request-tests.ts @@ -0,0 +1,215 @@ +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, error: firstContactRequestDataError } = + await supabaseAnonClient.functions.invoke("submit_contact_request", { + body: { + recipientContactName: "user2", + message: "Hello, world!", + }, + }); + console.log(firstContactRequestData, firstContactRequestDataError); + + 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/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 From 6209690705b0547d2a8d94ff05c3b3ebdbd2d998 Mon Sep 17 00:00:00 2001 From: Jonas Jaszkowic Date: Thu, 13 Jun 2024 12:39:08 +0200 Subject: [PATCH 05/12] fix: sender name --- .../functions/submit_contact_request/index.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/supabase/functions/submit_contact_request/index.ts b/supabase/functions/submit_contact_request/index.ts index a12e1b6e..5efd19f6 100644 --- a/supabase/functions/submit_contact_request/index.ts +++ b/supabase/functions/submit_contact_request/index.ts @@ -43,6 +43,22 @@ const handler = async (_request: Request): Promise => { return new Response(JSON.stringify({}), { status: 401 }); } + // Lookup the sender username + const { data: senderLookupData, error: senderLookupDataError } = + await supabaseServiceRoleClient + .from("profiles") + .select("*") + .eq("id", senderData.user.id) + .single(); + + if (senderLookupDataError) { + console.log(senderLookupDataError); + return new Response(JSON.stringify(senderLookupDataError), { + status: 404, + headers: corsHeaders, + }); + } + // Lookup the recipient user id const { data: recipientData, error: recipientDataError } = await supabaseServiceRoleClient @@ -184,7 +200,7 @@ const handler = async (_request: Request): Promise => { to: fullRecipientData.email, subject: "[Gieß den Kiez] Kontaktanfrage / Contact request", html: mailTemplate( - recipientContactName, + senderLookupData.username, message, fullRecipientData.email ), From 720a7bc704f0cb050577c15a67e9b5d23611c2bb Mon Sep 17 00:00:00 2001 From: Jonas Jaszkowic Date: Thu, 13 Jun 2024 12:50:16 +0200 Subject: [PATCH 06/12] fix: sender email --- supabase/functions/submit_contact_request/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/submit_contact_request/index.ts b/supabase/functions/submit_contact_request/index.ts index 5efd19f6..2084cd68 100644 --- a/supabase/functions/submit_contact_request/index.ts +++ b/supabase/functions/submit_contact_request/index.ts @@ -202,7 +202,7 @@ const handler = async (_request: Request): Promise => { html: mailTemplate( senderLookupData.username, message, - fullRecipientData.email + senderData.user.email ), }; From 70155fd0a64180bfbc490191c48963a9bce45033 Mon Sep 17 00:00:00 2001 From: Jonas Jaszkowic Date: Tue, 18 Jun 2024 13:46:03 +0200 Subject: [PATCH 07/12] Feat/check contact request allowed (#267) * feat: check for contact request allowed * fix: status code * fix: mail template * feat: reply to --- supabase/functions/_shared/checks.ts | 124 ++++++++++++++++++ .../functions/check_contact_request/index.ts | 67 ++++++++++ .../functions/submit_contact_request/index.ts | 116 +++------------- .../submit_contact_request/mail-template.ts | 8 +- 4 files changed, 212 insertions(+), 103 deletions(-) create mode 100644 supabase/functions/_shared/checks.ts create mode 100644 supabase/functions/check_contact_request/index.ts diff --git a/supabase/functions/_shared/checks.ts b/supabase/functions/_shared/checks.ts new file mode 100644 index 00000000..f1628fd7 --- /dev/null +++ b/supabase/functions/_shared/checks.ts @@ -0,0 +1,124 @@ +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); + + console.log(senderData); + + if (senderDataError) { + console.log(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(); + + console.log(senderLookupData); + + if (senderLookupDataError) { + console.log(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) { + console.log(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) { + console.log(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) { + console.log(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/check_contact_request/index.ts b/supabase/functions/check_contact_request/index.ts new file mode 100644 index 00000000..727604ab --- /dev/null +++ b/supabase/functions/check_contact_request/index.ts @@ -0,0 +1,67 @@ +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; +import { checkIfContactRequestIsAllowed } from "../_shared/checks.ts"; +import { corsHeaders } from "../_shared/cors.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 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/submit_contact_request/index.ts b/supabase/functions/submit_contact_request/index.ts index 2084cd68..b18bfb45 100644 --- a/supabase/functions/submit_contact_request/index.ts +++ b/supabase/functions/submit_contact_request/index.ts @@ -1,6 +1,6 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; -import { sub } from "npm:date-fns"; import nodemailer from "npm:nodemailer"; +import { checkIfContactRequestIsAllowed } from "../_shared/checks.ts"; import { corsHeaders } from "../_shared/cors.ts"; import { mailTemplate } from "./mail-template.ts"; @@ -35,106 +35,23 @@ const handler = async (_request: Request): Promise => { // Get the user (= sender) data from the token const token = authHeader.replace("Bearer ", ""); - const { data: senderData, error: senderDataError } = - await supabaseClient.auth.getUser(token); - if (senderDataError) { - console.log(senderDataError); - return new Response(JSON.stringify({}), { status: 401 }); - } - - // Lookup the sender username - const { data: senderLookupData, error: senderLookupDataError } = - await supabaseServiceRoleClient - .from("profiles") - .select("*") - .eq("id", senderData.user.id) - .single(); - - if (senderLookupDataError) { - console.log(senderLookupDataError); - return new Response(JSON.stringify(senderLookupDataError), { - status: 404, - headers: corsHeaders, - }); - } - - // Lookup the recipient user id - const { data: recipientData, error: recipientDataError } = - await supabaseServiceRoleClient - .from("profiles") - .select("*") - .eq("username", recipientContactName) - .single(); - - if (recipientDataError) { - console.log(recipientDataError); - return new Response(JSON.stringify(recipientDataError), { - status: 404, - headers: corsHeaders, - }); - } - - // 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) { - console.log(requestsToRecipientError); - return new Response(JSON.stringify(requestsToRecipientError), { - status: 500, - headers: corsHeaders, - }); - } - - if (requestsToRecipient.length > 0) { - return new Response( - JSON.stringify({ - code: "already_contacted_the_recipient_before", - message: - "User has already sent a contact request to the recipient, not allowed to send another one.", - }), - { - status: 200, - headers: { - ...corsHeaders, - "Content-Type": "application/json", - }, - } + const { isAllowed, reason, lookupData } = + await checkIfContactRequestIsAllowed( + recipientContactName, + token, + supabaseClient, + supabaseServiceRoleClient ); - } - - // 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) { - console.log(requestsOfLast24hError); - return new Response(JSON.stringify(requestsOfLast24hError), { - status: 500, - headers: corsHeaders, - }); - } - if (requestsOfLast24h.length >= 3) { + if (!isAllowed || !lookupData) { return new Response( JSON.stringify({ - code: "already_sent_more_than_3_contact_requests", - message: - "User has already sent more than 3 contact requests in the last 24 hours, not allowed to send another one.", + isContactRequestAllowed: false, + reason, }), { - status: 200, + status: 403, headers: { ...corsHeaders, "Content-Type": "application/json", @@ -146,7 +63,7 @@ const handler = async (_request: Request): Promise => { // Lookup the recipient email address via serviceRoleClient const { data: fullRecipientData, error: fullRecipientDataError } = await supabaseServiceRoleClient - .rpc("get_user_data_for_id", { u_id: recipientData.id }) + .rpc("get_user_data_for_id", { u_id: lookupData.recipientUserId }) .select("email") .single(); @@ -163,8 +80,8 @@ const handler = async (_request: Request): Promise => { await supabaseClient .from("contact_requests") .insert({ - user_id: senderData.user.id, - contact_id: recipientData.id, + user_id: lookupData.senderUserId, + contact_id: lookupData.recipientUserId, contact_message: message, }) .select("*") @@ -198,11 +115,12 @@ const handler = async (_request: Request): Promise => { const mailOptions = { from: SMTP_FROM, to: fullRecipientData.email, + replyTo: lookupData.senderEmail, subject: "[Gieß den Kiez] Kontaktanfrage / Contact request", html: mailTemplate( - senderLookupData.username, + lookupData.senderUsername, message, - senderData.user.email + lookupData.senderEmail ), }; diff --git a/supabase/functions/submit_contact_request/mail-template.ts b/supabase/functions/submit_contact_request/mail-template.ts index 0629d2f5..385e6044 100644 --- a/supabase/functions/submit_contact_request/mail-template.ts +++ b/supabase/functions/submit_contact_request/mail-template.ts @@ -174,7 +174,7 @@ export const mailTemplate = ( box-sizing: border-box; " > - Der Gieß den Kiez User ${username} möchte sich + ${username} möchte sich mit Dir vernetzen und hat Dir folgende Nachricht gesendet:
- The Gieß den Kiez user ${username} would like to connect with you + ${username} would like to connect with you and has sent you the following message:
- To reply to the user by e-mail, please write to: + To reply to the user via e-mail, please write to: If the contact request contains inappropriate content, we are very sorry. Please inform our team immediately via info@citylab-berlin.org. + text-align: center;">We apologize if the contact request contains inappropriate content. Please notify our team immediately via info@citylab-berlin.org.

Date: Thu, 20 Jun 2024 14:15:36 +0200 Subject: [PATCH 08/12] feat: add daily rain tables (#268) * feat: add monthly rain tables * fix: foreign key * fix: daily weather data * fix: add RLS * feat: created_at column, just to be sure * feat: function for fetching aggregated weather data * chore: formatting * feat: add parameter to limit number of returned months * fix: typo --- .../migrations/20240619130511_montly_rain.sql | 25 +++++++++++++++ .../20240620113031_rain_functions.sql | 32 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 supabase/migrations/20240619130511_montly_rain.sql create mode 100644 supabase/migrations/20240620113031_rain_functions.sql 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 From b5e5281e0cfa809406a331b5f599311c605d1cf2 Mon Sep 17 00:00:00 2001 From: Jonas Jaszkowic Date: Mon, 24 Jun 2024 11:28:16 +0200 Subject: [PATCH 09/12] fix: contact email subject --- supabase/functions/submit_contact_request/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/submit_contact_request/index.ts b/supabase/functions/submit_contact_request/index.ts index b18bfb45..a46b0f4c 100644 --- a/supabase/functions/submit_contact_request/index.ts +++ b/supabase/functions/submit_contact_request/index.ts @@ -116,7 +116,7 @@ const handler = async (_request: Request): Promise => { from: SMTP_FROM, to: fullRecipientData.email, replyTo: lookupData.senderEmail, - subject: "[Gieß den Kiez] Kontaktanfrage / Contact request", + subject: "Kontaktanfrage / Contact request", html: mailTemplate( lookupData.senderUsername, message, From c34008cea9835b99a71582661c73ea7ebdb135a2 Mon Sep 17 00:00:00 2001 From: Jonas Jaszkowic Date: Thu, 4 Jul 2024 17:19:39 +0200 Subject: [PATCH 10/12] 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$; From 4a6dc2eeaa36b42e3d5eba61471a5715df80cd0d Mon Sep 17 00:00:00 2001 From: Jonas Jaszkowic Date: Thu, 4 Jul 2024 17:25:55 +0200 Subject: [PATCH 11/12] fix: wrong import --- supabase/functions/check_contact_request/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/check_contact_request/index.ts b/supabase/functions/check_contact_request/index.ts index 85a8d3b4..aaae7eb7 100644 --- a/supabase/functions/check_contact_request/index.ts +++ b/supabase/functions/check_contact_request/index.ts @@ -1,7 +1,7 @@ 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"; +import { checkIfContactRequestIsAllowed } from "../_shared/contact-request-checks.ts"; const ENV_VARS = [ "SUPABASE_URL", From ceb5942ca31264e52e551826503676a58f239cb8 Mon Sep 17 00:00:00 2001 From: Jonas Jaszkowic Date: Mon, 8 Jul 2024 16:20:56 +0200 Subject: [PATCH 12/12] feat: add max temperature --- supabase/functions/gdk_stats/index.ts | 1 + .../20240708094214_fix_db_stats_function.sql | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 supabase/migrations/20240708094214_fix_db_stats_function.sql diff --git a/supabase/functions/gdk_stats/index.ts b/supabase/functions/gdk_stats/index.ts index 64eaee2f..cc156ed7 100644 --- a/supabase/functions/gdk_stats/index.ts +++ b/supabase/functions/gdk_stats/index.ts @@ -142,6 +142,7 @@ const getMonthlyWeather = async (): Promise => { return data.map((month: any) => ({ month: month.month, averageTemperatureCelsius: month.avg_temperature_celsius, + maximumTemperatureCelsius: month.max_temperature_celsius, totalRainfallLiters: month.total_rainfall_liters, })); }; 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$