From 3d5850099708590447dd9f82e8f333ff2713ff9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Tue, 1 Jul 2025 16:55:51 +0200 Subject: [PATCH 01/20] Supabase local development setup --- package-lock.json | 122 ++- package.json | 1 + supabase/.gitignore | 8 + supabase/config.toml | 322 +++++++ .../20250701144235_remote_schema.sql | 827 ++++++++++++++++++ .../20250701144258_remote_schema.sql | 34 + 6 files changed, 1307 insertions(+), 7 deletions(-) create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/20250701144235_remote_schema.sql create mode 100644 supabase/migrations/20250701144258_remote_schema.sql diff --git a/package-lock.json b/package-lock.json index 08b4194..914a67e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.11", + "supabase": "^2.30.4", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "tailwindcss": "^4.0.0", @@ -1515,6 +1516,23 @@ "node": "*" } }, + "node_modules/bin-links": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-5.0.0.tgz", + "integrity": "sha512-sdleLVfCjBtgO5cNjA2HVRvWBJAHs4zwenaCPMNJAJU0yNxpzj80IpjOIimkpkr+mhlA+how5poQtt53PygbHA==", + "dev": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^7.0.0", + "npm-normalize-package-bin": "^4.0.0", + "proc-log": "^5.0.0", + "read-cmd-shim": "^5.0.0", + "write-file-atomic": "^6.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -1605,6 +1623,16 @@ "node": ">=6" } }, + "node_modules/cmd-shim": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-7.0.0.tgz", + "integrity": "sha512-rtpaCbr164TPPh+zFdkWpCyZuKkjpAzODfaZCf/SVJZzJN+4bHQb/LP3Jzq5/+84um3XXY8r548XiWKSborwVw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2149,6 +2177,16 @@ "node": ">= 14" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -2643,6 +2681,16 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/npm-normalize-package-bin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", + "integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -2888,6 +2936,16 @@ } } }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/qrcode": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", @@ -2932,6 +2990,16 @@ "quoted-printable": "bin/quoted-printable" } }, + "node_modules/read-cmd-shim": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-5.0.0.tgz", + "integrity": "sha512-SEbJV7tohp3DAAILbEMPXavBjAnMN0tVnh4+9G8ihV4Pq3HYF9h8QNez9zkJ1ILkv9G2BjdzwctznGZXgu/HGw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3136,6 +3204,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/simple-icons": { "version": "15.3.0", "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-15.3.0.tgz", @@ -3204,6 +3285,26 @@ "node": ">=8" } }, + "node_modules/supabase": { + "version": "2.30.4", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.30.4.tgz", + "integrity": "sha512-AOCyd2vmBBMTXbnahiCU0reRNxKS4n5CrPciUF2tcTrQ8dLzl1HwcLfe5DrG8E0QRcKHPDdzprmh/2+y4Ta5MA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bin-links": "^5.0.0", + "https-proxy-agent": "^7.0.2", + "node-fetch": "^3.3.2", + "tar": "7.4.3" + }, + "bin": { + "supabase": "bin/supabase" + }, + "engines": { + "npm": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -3265,13 +3366,6 @@ "typescript": ">=5.0.0" } }, - "node_modules/svelte-kit": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/svelte-kit/-/svelte-kit-1.2.0.tgz", - "integrity": "sha512-RRaOHBhpDv4g2v9tcq8iNw055Pt0MlLps6JVA7/40f4KAbtztXSI4T6MZYbHRirO708urfAAMx6Qow+tQfCHug==", - "hasInstallScript": true, - "license": "MIT" - }, "node_modules/tailwindcss": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.7.tgz", @@ -3551,6 +3645,20 @@ "node": ">=8" } }, + "node_modules/write-file-atomic": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz", + "integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/ws": { "version": "8.18.2", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", diff --git a/package.json b/package.json index e904f68..b069470 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "prettier": "^3.4.2", "prettier-plugin-svelte": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.11", + "supabase": "^2.30.4", "svelte": "^5.0.0", "svelte-check": "^4.0.0", "tailwindcss": "^4.0.0", diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..262d9ee --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,322 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "scan-wave" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +[edge_runtime] +enabled = true +# Configure one of the supported request policies: `oneshot`, `per_worker`. +# Use `oneshot` for hot reload, or `per_worker` for load testing. +policy = "oneshot" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 1 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/supabase/migrations/20250701144235_remote_schema.sql b/supabase/migrations/20250701144235_remote_schema.sql new file mode 100644 index 0000000..05aaa3b --- /dev/null +++ b/supabase/migrations/20250701144235_remote_schema.sql @@ -0,0 +1,827 @@ + + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + + +CREATE EXTENSION IF NOT EXISTS "pg_cron" WITH SCHEMA "pg_catalog"; + + + + + + +COMMENT ON SCHEMA "public" IS 'standard public schema'; + + + +CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" WITH SCHEMA "extensions"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "pgjwt" WITH SCHEMA "extensions"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions"; + + + + + + +CREATE TYPE "public"."section_posititon" AS ENUM ( + 'events_manager', + 'member' +); + + +ALTER TYPE "public"."section_posititon" OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."archive_event"("_event_id" "uuid") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + AS $$ +DECLARE + v_total bigint; + v_scanned bigint; + v_evt public.events%ROWTYPE; +BEGIN + ------------------------------------------------------------------------- + -- A. Fetch the event + ------------------------------------------------------------------------- + SELECT * INTO v_evt + FROM public.events + WHERE id = _event_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'archive_event_and_delete(): event % does not exist', _event_id; + END IF; + + ------------------------------------------------------------------------- + -- B. Count participants + ------------------------------------------------------------------------- + SELECT COUNT(*) AS total, + COUNT(*) FILTER (WHERE scanned) AS scanned + INTO v_total, v_scanned + FROM public.participants + WHERE event = _event_id; + + ------------------------------------------------------------------------- + -- C. Upsert into events_archived (now with section_id) + ------------------------------------------------------------------------- + INSERT INTO public.events_archived ( + id, created_at, date, name, + section_id, total_participants, scanned_participants ) + VALUES ( v_evt.id, clock_timestamp(), v_evt.date, v_evt.name, + v_evt.section_id, v_total, v_scanned ) + ON CONFLICT (id) DO UPDATE + SET created_at = EXCLUDED.created_at, + date = EXCLUDED.date, + name = EXCLUDED.name, + section_id = EXCLUDED.section_id, + total_participants = EXCLUDED.total_participants, + scanned_participants= EXCLUDED.scanned_participants; + + ------------------------------------------------------------------------- + -- D. Delete original event row (participants cascade away) + ------------------------------------------------------------------------- + DELETE FROM public.events + WHERE id = _event_id; +END; +$$; + + +ALTER FUNCTION "public"."archive_event"("_event_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."auto_archive_events"("_age_days" integer DEFAULT 7) RETURNS integer + LANGUAGE "plpgsql" SECURITY DEFINER + AS $$ +DECLARE + v_cnt int := 0; + v_event_id uuid; +BEGIN + FOR v_event_id IN + SELECT id + FROM public.events + WHERE date IS NOT NULL + AND date <= CURRENT_DATE - _age_days + LOOP + BEGIN + PERFORM public.archive_event(v_event_id); + v_cnt := v_cnt + 1; + EXCEPTION + WHEN others THEN + -- Optionally record the failure somewhere and continue + RAISE WARNING 'Failed to archive event %, %', v_event_id, SQLERRM; + END; + END LOOP; + + RETURN v_cnt; +END; +$$; + + +ALTER FUNCTION "public"."auto_archive_events"("_age_days" integer) OWNER TO "postgres"; + +SET default_tablespace = ''; + +SET default_table_access_method = "heap"; + + +CREATE TABLE IF NOT EXISTS "public"."events" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "created_by" "uuid" DEFAULT "auth"."uid"(), + "name" "text", + "date" "date", + "section_id" "uuid" +); + + +ALTER TABLE "public"."events" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."events" IS 'Table of all events created'; + + + +CREATE OR REPLACE FUNCTION "public"."create_event"("p_name" "text", "p_date" "date") RETURNS "public"."events" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public' + AS $$ +declare + v_user uuid := auth.uid(); -- current user + v_section uuid; -- their section_id + v_evt public.events%rowtype; -- the inserted event +begin + -- 1) lookup the user's section + select section_id + into v_section + from public.profiles + where id = v_user; + + if v_section is null then + raise exception 'no profile/section found for user %', v_user; + end if; + + -- 2) insert into events, filling created_by and section_id + insert into public.events ( + name, + date, + created_by, + section_id + ) + values ( + p_name, + p_date, + v_user, + v_section + ) + returning * into v_evt; + + -- 3) return the full row + return v_evt; +end; +$$; + + +ALTER FUNCTION "public"."create_event"("p_name" "text", "p_date" "date") OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."participants" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "created_by" "uuid" DEFAULT "auth"."uid"(), + "event" "uuid", + "name" "text", + "surname" "text", + "email" "text", + "scanned" boolean DEFAULT false, + "scanned_at" timestamp with time zone, + "scanned_by" "uuid", + "section_id" "uuid" +); + + +ALTER TABLE "public"."participants" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."participants" IS 'Table of all qrcodes issued'; + + + +CREATE OR REPLACE FUNCTION "public"."create_qrcodes_bulk"("p_section_id" "uuid", "p_event_id" "uuid", "p_names" "text"[], "p_surnames" "text"[], "p_emails" "text"[]) RETURNS SETOF "public"."participants" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'pg_temp' + AS $$BEGIN + ----------------------------------------------------------------- + -- 1) keep the array-length check exactly as before + ----------------------------------------------------------------- + IF array_length(p_names, 1) IS DISTINCT FROM + array_length(p_surnames,1) OR + array_length(p_names, 1) IS DISTINCT FROM + array_length(p_emails, 1) THEN + RAISE EXCEPTION + 'Names, surnames and emails arrays must all be the same length'; + END IF; + + RETURN QUERY + INSERT INTO public.participants (section_id, event, name, surname, email) + SELECT p_section_id, + p_event_id, + n, s, e + FROM unnest(p_names, p_surnames, p_emails) AS u(n, s, e) + RETURNING *; +END;$$; + + +ALTER FUNCTION "public"."create_qrcodes_bulk"("p_section_id" "uuid", "p_event_id" "uuid", "p_names" "text"[], "p_surnames" "text"[], "p_emails" "text"[]) OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."handle_new_user"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public', 'auth' + AS $$begin + insert into public.profiles(id, display_name, created_at, updated_at) + values (new.id, + coalesce(new.raw_user_meta_data ->> 'display_name', -- meta-data name if present + split_part(new.email, '@', 1)), -- fallback: part of the email + now(), now()); + return new; +end;$$; + + +ALTER FUNCTION "public"."handle_new_user"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."scan_ticket"("_ticket_id" "uuid") RETURNS "void" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO 'public' + AS $$BEGIN + UPDATE participants + SET scanned = true, + scanned_at = NOW(), + scanned_by = auth.uid() + WHERE id = _ticket_id; + + -- optionally, make sure exactly one row was updated + IF NOT FOUND THEN + RAISE EXCEPTION 'Ticket % not found or already scanned', _ticket_id; + END IF; +END;$$; + + +ALTER FUNCTION "public"."scan_ticket"("_ticket_id" "uuid") OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."events_archived" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "date" "date", + "name" "text" NOT NULL, + "total_participants" numeric, + "scanned_participants" numeric, + "section_id" "uuid" +); + + +ALTER TABLE "public"."events_archived" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."profiles" ( + "id" "uuid" NOT NULL, + "display_name" "text", + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "section_id" "uuid", + "section_position" "public"."section_posititon" DEFAULT 'member'::"public"."section_posititon" NOT NULL +); + + +ALTER TABLE "public"."profiles" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."sections" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, + "name" "text" NOT NULL +); + + +ALTER TABLE "public"."sections" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."sections" IS 'List of ESN sections using the app'; + + + +ALTER TABLE ONLY "public"."events_archived" + ADD CONSTRAINT "events_archived_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."events" + ADD CONSTRAINT "events_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."profiles" + ADD CONSTRAINT "profiles_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."participants" + ADD CONSTRAINT "qrcodes_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."sections" + ADD CONSTRAINT "sections_name_key" UNIQUE ("name"); + + + +ALTER TABLE ONLY "public"."sections" + ADD CONSTRAINT "sections_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."events_archived" + ADD CONSTRAINT "events_archived_section_id_fkey" FOREIGN KEY ("section_id") REFERENCES "public"."sections"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."events" + ADD CONSTRAINT "events_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id"); + + + +ALTER TABLE ONLY "public"."events" + ADD CONSTRAINT "events_section_id_fkey" FOREIGN KEY ("section_id") REFERENCES "public"."sections"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."participants" + ADD CONSTRAINT "participants_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."participants" + ADD CONSTRAINT "participants_event_fkey" FOREIGN KEY ("event") REFERENCES "public"."events"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."participants" + ADD CONSTRAINT "participants_scanned_by_fkey" FOREIGN KEY ("scanned_by") REFERENCES "public"."profiles"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."profiles" + ADD CONSTRAINT "profiles_id_fkey" FOREIGN KEY ("id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."profiles" + ADD CONSTRAINT "profiles_section_id_fkey" FOREIGN KEY ("section_id") REFERENCES "public"."sections"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."participants" + ADD CONSTRAINT "qrcodes_scanned_by_fkey" FOREIGN KEY ("scanned_by") REFERENCES "auth"."users"("id"); + + + +ALTER TABLE ONLY "public"."participants" + ADD CONSTRAINT "qrcodes_section_id_fkey" FOREIGN KEY ("section_id") REFERENCES "public"."sections"("id") ON DELETE CASCADE; + + + +CREATE POLICY "Access only to section resources" ON "public"."events_archived" FOR SELECT TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM "public"."profiles" "p" + WHERE ("p"."section_id" = "events_archived"."section_id")))); + + + +CREATE POLICY "Enable select for authenticated users only" ON "public"."profiles" FOR SELECT TO "authenticated" USING (true); + + + +CREATE POLICY "Enable select for authenticated users only" ON "public"."sections" FOR SELECT TO "authenticated" USING (true); + + + +CREATE POLICY "Only display section resources" ON "public"."events" FOR SELECT TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM "public"."profiles" "p" + WHERE ("p"."section_id" = "events"."section_id")))); + + + +CREATE POLICY "Only display section resources" ON "public"."participants" FOR SELECT TO "authenticated" USING ((EXISTS ( SELECT 1 + FROM "public"."profiles" "p" + WHERE ("p"."section_id" = "participants"."section_id")))); + + + +ALTER TABLE "public"."events" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."events_archived" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."participants" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."profiles" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."sections" ENABLE ROW LEVEL SECURITY; + + + + +ALTER PUBLICATION "supabase_realtime" OWNER TO "postgres"; + + + + + +GRANT USAGE ON SCHEMA "public" TO "postgres"; +GRANT USAGE ON SCHEMA "public" TO "anon"; +GRANT USAGE ON SCHEMA "public" TO "authenticated"; +GRANT USAGE ON SCHEMA "public" TO "service_role"; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +GRANT ALL ON FUNCTION "public"."archive_event"("_event_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."archive_event"("_event_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."archive_event"("_event_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."auto_archive_events"("_age_days" integer) TO "anon"; +GRANT ALL ON FUNCTION "public"."auto_archive_events"("_age_days" integer) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."auto_archive_events"("_age_days" integer) TO "service_role"; + + + +GRANT ALL ON TABLE "public"."events" TO "anon"; +GRANT ALL ON TABLE "public"."events" TO "authenticated"; +GRANT ALL ON TABLE "public"."events" TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."create_event"("p_name" "text", "p_date" "date") TO "anon"; +GRANT ALL ON FUNCTION "public"."create_event"("p_name" "text", "p_date" "date") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."create_event"("p_name" "text", "p_date" "date") TO "service_role"; + + + +GRANT ALL ON TABLE "public"."participants" TO "anon"; +GRANT ALL ON TABLE "public"."participants" TO "authenticated"; +GRANT ALL ON TABLE "public"."participants" TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."create_qrcodes_bulk"("p_section_id" "uuid", "p_event_id" "uuid", "p_names" "text"[], "p_surnames" "text"[], "p_emails" "text"[]) TO "anon"; +GRANT ALL ON FUNCTION "public"."create_qrcodes_bulk"("p_section_id" "uuid", "p_event_id" "uuid", "p_names" "text"[], "p_surnames" "text"[], "p_emails" "text"[]) TO "authenticated"; +GRANT ALL ON FUNCTION "public"."create_qrcodes_bulk"("p_section_id" "uuid", "p_event_id" "uuid", "p_names" "text"[], "p_surnames" "text"[], "p_emails" "text"[]) TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."handle_new_user"() TO "anon"; +GRANT ALL ON FUNCTION "public"."handle_new_user"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."handle_new_user"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."scan_ticket"("_ticket_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."scan_ticket"("_ticket_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."scan_ticket"("_ticket_id" "uuid") TO "service_role"; + + + + + + + + + + + + + + + + + + + + + + + + +GRANT ALL ON TABLE "public"."events_archived" TO "anon"; +GRANT ALL ON TABLE "public"."events_archived" TO "authenticated"; +GRANT ALL ON TABLE "public"."events_archived" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."profiles" TO "anon"; +GRANT ALL ON TABLE "public"."profiles" TO "authenticated"; +GRANT ALL ON TABLE "public"."profiles" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."sections" TO "anon"; +GRANT ALL ON TABLE "public"."sections" TO "authenticated"; +GRANT ALL ON TABLE "public"."sections" TO "service_role"; + + + + + + + + + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "service_role"; + + + + + + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "service_role"; + + + + + + +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "postgres"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "anon"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "authenticated"; +ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "service_role"; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +RESET ALL; diff --git a/supabase/migrations/20250701144258_remote_schema.sql b/supabase/migrations/20250701144258_remote_schema.sql new file mode 100644 index 0000000..0ca0894 --- /dev/null +++ b/supabase/migrations/20250701144258_remote_schema.sql @@ -0,0 +1,34 @@ +revoke select on table "auth"."schema_migrations" from "postgres"; + +CREATE TRIGGER on_auth_users_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_new_user(); + + +grant delete on table "storage"."s3_multipart_uploads" to "postgres"; + +grant insert on table "storage"."s3_multipart_uploads" to "postgres"; + +grant references on table "storage"."s3_multipart_uploads" to "postgres"; + +grant select on table "storage"."s3_multipart_uploads" to "postgres"; + +grant trigger on table "storage"."s3_multipart_uploads" to "postgres"; + +grant truncate on table "storage"."s3_multipart_uploads" to "postgres"; + +grant update on table "storage"."s3_multipart_uploads" to "postgres"; + +grant delete on table "storage"."s3_multipart_uploads_parts" to "postgres"; + +grant insert on table "storage"."s3_multipart_uploads_parts" to "postgres"; + +grant references on table "storage"."s3_multipart_uploads_parts" to "postgres"; + +grant select on table "storage"."s3_multipart_uploads_parts" to "postgres"; + +grant trigger on table "storage"."s3_multipart_uploads_parts" to "postgres"; + +grant truncate on table "storage"."s3_multipart_uploads_parts" to "postgres"; + +grant update on table "storage"."s3_multipart_uploads_parts" to "postgres"; + + -- 2.49.1 From 5fd647d894fb1a8d81123ffaad78b77014f574db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Wed, 2 Jul 2025 21:04:45 +0200 Subject: [PATCH 02/20] Out with the old flow --- .../private/events/creator/+page.svelte | 131 --------- .../events/creator/emailtest/+page.svelte | 110 -------- .../events/creator/finish/+page.svelte | 252 ------------------ .../creator/steps/StepConnectGoogle.svelte | 75 ------ .../creator/steps/StepCraftEmail.svelte | 69 ----- .../creator/steps/StepCreateEvent.svelte | 84 ------ .../events/creator/steps/StepOverview.svelte | 77 ------ .../steps/StepUploadParticipants.svelte | 158 ----------- src/routes/private/events/event/+page.svelte | 162 ----------- 9 files changed, 1118 deletions(-) delete mode 100644 src/routes/private/events/creator/+page.svelte delete mode 100644 src/routes/private/events/creator/emailtest/+page.svelte delete mode 100644 src/routes/private/events/creator/finish/+page.svelte delete mode 100644 src/routes/private/events/creator/steps/StepConnectGoogle.svelte delete mode 100644 src/routes/private/events/creator/steps/StepCraftEmail.svelte delete mode 100644 src/routes/private/events/creator/steps/StepCreateEvent.svelte delete mode 100644 src/routes/private/events/creator/steps/StepOverview.svelte delete mode 100644 src/routes/private/events/creator/steps/StepUploadParticipants.svelte delete mode 100644 src/routes/private/events/event/+page.svelte diff --git a/src/routes/private/events/creator/+page.svelte b/src/routes/private/events/creator/+page.svelte deleted file mode 100644 index 4a2818f..0000000 --- a/src/routes/private/events/creator/+page.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - -{#if isAddingParticipants} -
-

- Adding Participants to "{event.name}" -

-
-{/if} - -{#if step == 0} - -{:else if step == 1} - -{:else if step == 2} - -{:else if step == 3} - -{:else if step == 4} - -{/if} - -
- -
-
- - - Step {step + 1} of {steps.length} - - -
-
diff --git a/src/routes/private/events/creator/emailtest/+page.svelte b/src/routes/private/events/creator/emailtest/+page.svelte deleted file mode 100644 index 3d67361..0000000 --- a/src/routes/private/events/creator/emailtest/+page.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - -
-

Test Email Sender

- {#if !authorized} -
-

Google not connected.

- -
-
- - - - - -
- {/if} - {#if error} -
{error}
- {/if} - {#if success} -
{success}
- {/if} -
diff --git a/src/routes/private/events/creator/finish/+page.svelte b/src/routes/private/events/creator/finish/+page.svelte deleted file mode 100644 index 93dc831..0000000 --- a/src/routes/private/events/creator/finish/+page.svelte +++ /dev/null @@ -1,252 +0,0 @@ - - - -
-

- {all_data.isAddingParticipants ? 'Using existing event' : 'Creating event'} -

- {#if event_status === StepStatus.Waiting} - Waiting... - {:else if event_status === StepStatus.Loading} - Creating event... - {:else if event_status === StepStatus.Success} - - {all_data.isAddingParticipants ? 'Using existing event.' : 'Event created successfully.'} - - {:else if event_status === StepStatus.Failure} - Failed to create event. - {/if} -
- - -
-

Creating QR codes for participants

- {#if participants_status === StepStatus.Waiting} - Waiting... - {:else if participants_status === StepStatus.Loading} - Creating entries... - {:else if participants_status === StepStatus.Success} - QR codes created successfully. - {:else if participants_status === StepStatus.Failure} - Failed to create QR codes. - {/if} -
- - -
-

Sending emails

-

After pressing send, you must not exit this window until the mail are all sent!

- {#if email_status === StepStatus.Waiting} -
- Waiting... - -
- {:else} -
    - {#each createdParticipants as p, i} -
  • - {#if mailStatuses[i]?.status === 'success'} - - - - {:else if mailStatuses[i]?.status === 'failure'} - - - - - - {:else} - - - - {/if} - {p.name} {p.surname} - {p.email} -
  • - {/each} -
- {#if email_status === StepStatus.Loading} - Sending emails... - {:else if email_status === StepStatus.Success} - Emails sent successfully. - {:else if email_status === StepStatus.Failure} - Failed to send emails. - {/if} - {/if} -
diff --git a/src/routes/private/events/creator/steps/StepConnectGoogle.svelte b/src/routes/private/events/creator/steps/StepConnectGoogle.svelte deleted file mode 100644 index 4d381c9..0000000 --- a/src/routes/private/events/creator/steps/StepConnectGoogle.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - -
- {#if loading} -
- - - - - Checking Google connection... -
- {:else} - {#if !authorized} -
-

You haven’t connected your Google account yet.

- -
- {:else} -
- - - - Your connection to Google is good, proceed to next step -
- {/if} - {/if} -
diff --git a/src/routes/private/events/creator/steps/StepCraftEmail.svelte b/src/routes/private/events/creator/steps/StepCraftEmail.svelte deleted file mode 100644 index 085baaf..0000000 --- a/src/routes/private/events/creator/steps/StepCraftEmail.svelte +++ /dev/null @@ -1,69 +0,0 @@ - - -{#if showForm} -
-

Craft Email

- - - -
-{:else} - -
-

Email Preview

-
- {email.subject} -
-
- {email.body} -
-
-{/if} \ No newline at end of file diff --git a/src/routes/private/events/creator/steps/StepCreateEvent.svelte b/src/routes/private/events/creator/steps/StepCreateEvent.svelte deleted file mode 100644 index ad0856e..0000000 --- a/src/routes/private/events/creator/steps/StepCreateEvent.svelte +++ /dev/null @@ -1,84 +0,0 @@ - - -{#if showForm} -
-

Create Event

- - - -
-{/if} - -{#if !showForm} - {#if !readonly} - - {/if} -
-

Event Preview

- {#if Object.keys(event).length > 0} -
    -
  • {event.name}
  • -
  • {event.date}
  • -
- {:else} - No event created yet... - {/if} -
-{/if} diff --git a/src/routes/private/events/creator/steps/StepOverview.svelte b/src/routes/private/events/creator/steps/StepOverview.svelte deleted file mode 100644 index 8f23aa9..0000000 --- a/src/routes/private/events/creator/steps/StepOverview.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - - -
-

Event Overview

-
    -
  • Name: {event.name}
  • -
  • Date: {event.date}
  • -
-
- - -
-

Email Preview

-
Subject: {email.subject}
-
- -
{email.body}
-
-
- - -
-

Participants ({participants.length})

-
    - {#each participants.slice(0, 10) as p} -
  • - {p.name} {p.surname} - - {p.email} -
  • - {/each} -
-

Note: Only the first 10 participants are shown.

-
- - - -
- {#if !stepConditions[0]} -

Please provide an event name before proceeding.

- {/if} - {#if !stepConditions[1]} -

Please add at least one participant before proceeding.

- {/if} - {#if !stepConditions[2]} -

Please provide an email subject before proceeding.

- {/if} - {#if !stepConditions[3]} -

Please provide an email body before proceeding.

- {/if} -
diff --git a/src/routes/private/events/creator/steps/StepUploadParticipants.svelte b/src/routes/private/events/creator/steps/StepUploadParticipants.svelte deleted file mode 100644 index d973d4f..0000000 --- a/src/routes/private/events/creator/steps/StepUploadParticipants.svelte +++ /dev/null @@ -1,158 +0,0 @@ - - -{#if showForm} - {#if errors.length > 0} -
-
    - {#each errors as err} -
  • {err}
  • - {/each} -
-
- {/if} -
-

Upload Participants

- - -
-{:else} - -{/if} - -{#if !showForm} -
-
-

Participants ({participants.length})

- {#if participants.length > 0} - {#if participants.some((p) => !p.email_valid)} - Some emails appear invalid - {:else} - All emails appear valid - {/if} - {/if} -
- -
    - {#each participants as p} -
  • - {#if p.email_valid} - - - - {:else} - - - - - - {/if} - {p.name} {p.surname} - - {p.email} -
  • - {/each} -
-
-{/if} diff --git a/src/routes/private/events/event/+page.svelte b/src/routes/private/events/event/+page.svelte deleted file mode 100644 index fbe63d5..0000000 --- a/src/routes/private/events/event/+page.svelte +++ /dev/null @@ -1,162 +0,0 @@ - - -

Event Overview

- -
-
-
- {#if loading} -
-
- {:else} - {event_data?.name} - {event_data?.date} - {/if} -
- {#if loading} -
- {:else if event_data} - - Add Participants - - {/if} -
-
- -
-
- - - - {#if loading} -
- {:else} - Scanned ({scannedCount}) - {/if} -
-
-
- - - - - - {#if loading} -
- {:else} - Not scanned ({notScannedCount}) - {/if} -
-
- -
-

- {#if loading} -
- {:else} - Participants ({participants.length}) - {/if} -

-
    - {#if loading} -
  • -
    -
  • - {:else} - {#each participants as p} -
  • - {#if p.scanned} - - - - {:else} - - - - - - {/if} - {p.name} {p.surname} - - {#if p.scanned_by} -
    - - {new Date(p.scanned_at).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - hour12: false - })} - - by {p.scanned_by.display_name} -
    - {/if} -
  • - {/each} - {/if} -
-
-- 2.49.1 From 822f1a73425bfa31cada633a9006f36b6485000c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Wed, 2 Jul 2025 21:50:45 +0200 Subject: [PATCH 03/20] First stage of the new flow --- .env.example | 7 +- .github/copilot-instructions.md | 83 +- src/lib/gmail.ts | 80 ++ src/lib/google-server.ts | 38 + src/lib/google.ts | 144 ++-- src/lib/sheets.ts | 58 ++ src/routes/api/auth/refresh/+server.ts | 30 + .../api/sheets/[sheetId]/data/+server.ts | 22 + src/routes/api/sheets/recent/+server.ts | 20 + src/routes/auth/google/+server.ts | 8 + src/routes/auth/google/callback/+server.ts | 110 +++ src/routes/private/api/gmail/+server.ts | 2 +- src/routes/private/events/+page.svelte | 53 +- .../private/events/event/new/+page.server.ts | 7 + .../private/events/event/new/+page.svelte | 795 ++++++++++++++++++ 15 files changed, 1317 insertions(+), 140 deletions(-) create mode 100644 src/lib/gmail.ts create mode 100644 src/lib/google-server.ts create mode 100644 src/lib/sheets.ts create mode 100644 src/routes/api/auth/refresh/+server.ts create mode 100644 src/routes/api/sheets/[sheetId]/data/+server.ts create mode 100644 src/routes/api/sheets/recent/+server.ts create mode 100644 src/routes/auth/google/+server.ts create mode 100644 src/routes/auth/google/callback/+server.ts create mode 100644 src/routes/private/events/event/new/+page.server.ts create mode 100644 src/routes/private/events/event/new/+page.svelte diff --git a/.env.example b/.env.example index e3df937..32ce1d1 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,7 @@ PUBLIC_SUPABASE_URL=https://abc.supabase.co -PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI16C_s \ No newline at end of file +PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI16C_s + +# Google OAuth Configuration +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret +GOOGLE_REDIRECT_URI=http://localhost:5173 \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c53c216..a67c7e3 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -85,4 +85,85 @@ NEVER $: label syntax; use $state(), $derived(), and $effect(). If you want to use supabse client in the browser, it is stored in the data variable obtained from let { data } = $props(); -Using `on:click` to listen to the click event is deprecated. Use the event attribute `onclick` instead \ No newline at end of file +Using `on:click` to listen to the click event is deprecated. Use the event attribute `onclick` instead + +onsubmit|preventDefault={handleSubmit} is depracated, do not use it! + +Loading session using page.server.ts is not needed as the session is already available in the locals object. + + +The database schema in supabase is as follows: +-- WARNING: This schema is for context only and is not meant to be run. +-- Table order and constraints may not be valid for execution. + +CREATE TABLE public.events ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + created_by uuid DEFAULT auth.uid(), + name text, + date date, + section_id uuid, + email_subject text, + email_body text, + sheet_id text, + name_column numeric, + surname_column numeric, + email_column numeric, + confirmation_column numeric, + CONSTRAINT events_pkey PRIMARY KEY (id), + CONSTRAINT events_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id), + CONSTRAINT events_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id) +); +CREATE TABLE public.events_archived ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + date date, + name text NOT NULL, + total_participants numeric, + scanned_participants numeric, + section_id uuid, + CONSTRAINT events_archived_pkey PRIMARY KEY (id), + CONSTRAINT events_archived_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id) +); +CREATE TABLE public.participants ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + created_by uuid DEFAULT auth.uid(), + event uuid, + name text, + surname text, + email text, + scanned boolean DEFAULT false, + scanned_at timestamp with time zone, + scanned_by uuid, + section_id uuid, + CONSTRAINT participants_pkey PRIMARY KEY (id), + CONSTRAINT participants_created_by_fkey FOREIGN KEY (created_by) REFERENCES auth.users(id), + CONSTRAINT participants_event_fkey FOREIGN KEY (event) REFERENCES public.events(id), + CONSTRAINT participants_scanned_by_fkey FOREIGN KEY (scanned_by) REFERENCES public.profiles(id), + CONSTRAINT qrcodes_scanned_by_fkey FOREIGN KEY (scanned_by) REFERENCES auth.users(id), + CONSTRAINT qrcodes_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id) +); +CREATE TABLE public.profiles ( + id uuid NOT NULL, + display_name text, + created_at timestamp with time zone DEFAULT now(), + updated_at timestamp with time zone DEFAULT now(), + section_id uuid, + section_position USER-DEFINED NOT NULL DEFAULT 'member'::section_posititon, + CONSTRAINT profiles_pkey PRIMARY KEY (id), + CONSTRAINT profiles_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id), + CONSTRAINT profiles_section_id_fkey FOREIGN KEY (section_id) REFERENCES public.sections(id) +); +CREATE TABLE public.sections ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + created_at timestamp with time zone NOT NULL DEFAULT now(), + name text NOT NULL UNIQUE, + CONSTRAINT sections_pkey PRIMARY KEY (id) +); + +An event is created by calling RPC databse function create_event +by passing the following parameters: +- name, date, email_subject, email_body, sheet_id, name_column, surname_column, email_column, confirmation_column + + diff --git a/src/lib/gmail.ts b/src/lib/gmail.ts new file mode 100644 index 0000000..c46488d --- /dev/null +++ b/src/lib/gmail.ts @@ -0,0 +1,80 @@ +import { google } from 'googleapis'; +import quotedPrintable from 'quoted-printable'; +import { getAuthenticatedClient } from './google-server.js'; + +export function createEmailTemplate(text: string): string { + return ` + + + + + +
+

${text}

+ QR Code +
+
+
+
+
+
+
+
+

This email has been generated with the help of ScanWave

+
+
+ +`; +} + +export async function sendGmail( + refreshToken: string, + { to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string } +) { + const oauth = getAuthenticatedClient(refreshToken); + const gmail = google.gmail({ version: 'v1', auth: oauth }); + + const message_html = createEmailTemplate(text); + const boundary = 'BOUNDARY'; + const nl = '\r\n'; + + // Convert HTML to a Buffer, then to latin1 string for quotedPrintable.encode + const htmlBuffer = Buffer.from(message_html, 'utf8'); + const htmlLatin1 = htmlBuffer.toString('latin1'); + const htmlQP = quotedPrintable.encode(htmlLatin1); + const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl); + + const rawParts = [ + 'MIME-Version: 1.0', + `To: ${to}`, + `Subject: ${subject}`, + `Content-Type: multipart/related; boundary="${boundary}"`, + '', + `--${boundary}`, + 'Content-Type: text/html; charset="UTF-8"', + 'Content-Transfer-Encoding: quoted-printable', + '', + htmlQP, + '', + `--${boundary}`, + 'Content-Type: image/png', + 'Content-Transfer-Encoding: base64', + 'Content-ID: ', + 'Content-Disposition: inline; filename="qr.png"', + '', + qrLines, + '', + `--${boundary}--`, + '' + ]; + + const rawMessage = rawParts.join(nl); + const raw = Buffer.from(rawMessage).toString('base64url'); + + await gmail.users.messages.send({ + userId: 'me', + requestBody: { raw } + }); +} diff --git a/src/lib/google-server.ts b/src/lib/google-server.ts new file mode 100644 index 0000000..7d98157 --- /dev/null +++ b/src/lib/google-server.ts @@ -0,0 +1,38 @@ +import { google } from 'googleapis'; +import { env } from '$env/dynamic/private'; + +export const scopes = [ + 'https://www.googleapis.com/auth/gmail.send', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/spreadsheets.readonly' +]; + +export function getOAuthClient() { + return new google.auth.OAuth2( + env.GOOGLE_CLIENT_ID, + env.GOOGLE_CLIENT_SECRET, + env.GOOGLE_REDIRECT_URI + ); +} + +export function createAuthUrl() { + return getOAuthClient().generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', + scope: scopes, + redirect_uri: env.GOOGLE_REDIRECT_URI + }); +} + +export async function exchangeCodeForTokens(code: string) { + const { tokens } = await getOAuthClient().getToken(code); + if (!tokens.refresh_token) throw new Error('No refresh_token returned'); + return tokens.refresh_token; +} + +export function getAuthenticatedClient(refreshToken: string) { + const oauth = getOAuthClient(); + oauth.setCredentials({ refresh_token: refreshToken }); + return oauth; +} diff --git a/src/lib/google.ts b/src/lib/google.ts index 792deb2..718199b 100644 --- a/src/lib/google.ts +++ b/src/lib/google.ts @@ -1,108 +1,60 @@ -import { google } from 'googleapis'; -import { env } from '$env/dynamic/private'; -import quotedPrintable from 'quoted-printable'; // tiny, zero-dep package +import { browser } from '$app/environment'; +// Client-side only functions export const scopes = [ 'https://www.googleapis.com/auth/gmail.send', - 'https://www.googleapis.com/auth/userinfo.email' + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/drive.readonly', + 'https://www.googleapis.com/auth/spreadsheets.readonly' ]; -export function getOAuthClient() { - return new google.auth.OAuth2( - env.GOOGLE_CLIENT_ID, - env.GOOGLE_CLIENT_SECRET, - env.GOOGLE_REDIRECT_URI - ); +// Client-side functions for browser environment +export async function initGoogleAuth(): Promise { + if (!browser) return; + // Google Auth initialization is handled by the OAuth flow + // No initialization needed for our server-side approach } -export function createAuthUrl() { - return getOAuthClient().generateAuthUrl({ - access_type: 'offline', - prompt: 'consent', - scope: scopes - }); +export function getAuthUrl(): string { + if (!browser) return ''; + // This should be obtained from the server + return '/auth/google'; } -export async function exchangeCodeForTokens(code: string) { - const { tokens } = await getOAuthClient().getToken(code); - if (!tokens.refresh_token) throw new Error('No refresh_token returned'); - return tokens.refresh_token; +export async function isTokenValid(accessToken: string): Promise { + if (!browser) return false; + + try { + const response = await fetch(`https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${accessToken}`); + const data = await response.json(); + + if (response.ok && data.expires_in && data.expires_in > 0) { + return true; + } + return false; + } catch (error) { + console.error('Error validating token:', error); + return false; + } } -export async function sendGmail( - refreshToken: string, - { to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string } -) { - const oauth = getOAuthClient(); - oauth.setCredentials({ refresh_token: refreshToken }); - - const gmail = google.gmail({ version: 'v1', auth: oauth }); - - const message_html = - ` - - - - - -
-

${text}

- QR Code -
-
-
-
-
-
-
-
-

This email has been generated with the help of ScanWave

-
-
- -`; - - const boundary = 'BOUNDARY'; - const nl = '\r\n'; // RFC-5322 line ending - - // Convert HTML to a Buffer, then to latin1 string for quotedPrintable.encode - const htmlBuffer = Buffer.from(message_html, 'utf8'); - const htmlLatin1 = htmlBuffer.toString('latin1'); - const htmlQP = quotedPrintable.encode(htmlLatin1); - const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl); - - const rawParts = [ - 'MIME-Version: 1.0', - `To: ${to}`, - `Subject: ${subject}`, - `Content-Type: multipart/related; boundary="${boundary}"`, - '', - `--${boundary}`, - 'Content-Type: text/html; charset="UTF-8"', - 'Content-Transfer-Encoding: quoted-printable', - '', - htmlQP, - '', - `--${boundary}`, - 'Content-Type: image/png', - 'Content-Transfer-Encoding: base64', - 'Content-ID: ', - 'Content-Disposition: inline; filename="qr.png"', - '', - qrLines, - '', - `--${boundary}--`, - '' - ]; - - const rawMessage = rawParts.join(nl); - - const raw = Buffer.from(rawMessage).toString('base64url'); - - await gmail.users.messages.send({ - userId: 'me', - requestBody: { raw } - }); +export async function refreshAccessToken(refreshToken: string): Promise { + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ refreshToken }) + }); + + if (response.ok) { + const data = await response.json(); + return data.accessToken; + } + return null; + } catch (error) { + console.error('Error refreshing token:', error); + return null; + } } diff --git a/src/lib/sheets.ts b/src/lib/sheets.ts new file mode 100644 index 0000000..b8dcc4c --- /dev/null +++ b/src/lib/sheets.ts @@ -0,0 +1,58 @@ +import { google } from 'googleapis'; +import { getAuthenticatedClient } from './google-server.js'; + +export interface GoogleSheet { + id: string; + name: string; + modifiedTime: string; + webViewLink: string; +} + +export interface SheetData { + values: string[][]; +} + +export async function getRecentSpreadsheets(refreshToken: string, limit: number = 10): Promise { + const oauth = getAuthenticatedClient(refreshToken); + const drive = google.drive({ version: 'v3', auth: oauth }); + + const response = await drive.files.list({ + q: "mimeType='application/vnd.google-apps.spreadsheet'", + orderBy: 'modifiedTime desc', + pageSize: limit, + fields: 'files(id,name,modifiedTime,webViewLink)' + }); + + return response.data.files?.map(file => ({ + id: file.id!, + name: file.name!, + modifiedTime: file.modifiedTime!, + webViewLink: file.webViewLink! + })) || []; +} + +export async function getSpreadsheetData(refreshToken: string, spreadsheetId: string, range: string = 'A1:Z10'): Promise { + const oauth = getAuthenticatedClient(refreshToken); + const sheets = google.sheets({ version: 'v4', auth: oauth }); + + const response = await sheets.spreadsheets.values.get({ + spreadsheetId, + range + }); + + return { + values: response.data.values || [] + }; +} + +export async function getSpreadsheetInfo(refreshToken: string, spreadsheetId: string) { + const oauth = getAuthenticatedClient(refreshToken); + const sheets = google.sheets({ version: 'v4', auth: oauth }); + + const response = await sheets.spreadsheets.get({ + spreadsheetId, + fields: 'properties.title,sheets.properties(title,sheetId)' + }); + + return response.data; +} diff --git a/src/routes/api/auth/refresh/+server.ts b/src/routes/api/auth/refresh/+server.ts new file mode 100644 index 0000000..e6561ee --- /dev/null +++ b/src/routes/api/auth/refresh/+server.ts @@ -0,0 +1,30 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getOAuthClient } from '$lib/google-server.js'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const { refreshToken } = await request.json(); + + if (!refreshToken) { + return json({ error: 'Refresh token is required' }, { status: 400 }); + } + + const oauth = getOAuthClient(); + oauth.setCredentials({ refresh_token: refreshToken }); + + const { credentials } = await oauth.refreshAccessToken(); + + if (!credentials.access_token) { + return json({ error: 'Failed to refresh token' }, { status: 500 }); + } + + return json({ + accessToken: credentials.access_token, + expiresIn: credentials.expiry_date + }); + } catch (error) { + console.error('Error refreshing access token:', error); + return json({ error: 'Failed to refresh access token' }, { status: 500 }); + } +}; diff --git a/src/routes/api/sheets/[sheetId]/data/+server.ts b/src/routes/api/sheets/[sheetId]/data/+server.ts new file mode 100644 index 0000000..263e099 --- /dev/null +++ b/src/routes/api/sheets/[sheetId]/data/+server.ts @@ -0,0 +1,22 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getSpreadsheetData } from '$lib/sheets.js'; + +export const GET: RequestHandler = async ({ params, request }) => { + try { + const { sheetId } = params; + const authHeader = request.headers.get('authorization'); + + if (!authHeader?.startsWith('Bearer ')) { + return json({ error: 'Missing or invalid authorization header' }, { status: 401 }); + } + + const refreshToken = authHeader.slice(7); + const sheetData = await getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); + + return json(sheetData); + } catch (error) { + console.error('Error fetching spreadsheet data:', error); + return json({ error: 'Failed to fetch spreadsheet data' }, { status: 500 }); + } +}; diff --git a/src/routes/api/sheets/recent/+server.ts b/src/routes/api/sheets/recent/+server.ts new file mode 100644 index 0000000..d9b94a2 --- /dev/null +++ b/src/routes/api/sheets/recent/+server.ts @@ -0,0 +1,20 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getRecentSpreadsheets } from '$lib/sheets.js'; + +export const GET: RequestHandler = async ({ request }) => { + try { + const authHeader = request.headers.get('authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return json({ error: 'Missing or invalid authorization header' }, { status: 401 }); + } + + const refreshToken = authHeader.slice(7); + const sheets = await getRecentSpreadsheets(refreshToken, 20); + + return json(sheets); + } catch (error) { + console.error('Error fetching recent spreadsheets:', error); + return json({ error: 'Failed to fetch spreadsheets' }, { status: 500 }); + } +}; diff --git a/src/routes/auth/google/+server.ts b/src/routes/auth/google/+server.ts new file mode 100644 index 0000000..d407c22 --- /dev/null +++ b/src/routes/auth/google/+server.ts @@ -0,0 +1,8 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { createAuthUrl } from '$lib/google-server.js'; + +export const GET: RequestHandler = () => { + const authUrl = createAuthUrl(); + throw redirect(302, authUrl); +}; diff --git a/src/routes/auth/google/callback/+server.ts b/src/routes/auth/google/callback/+server.ts new file mode 100644 index 0000000..840490c --- /dev/null +++ b/src/routes/auth/google/callback/+server.ts @@ -0,0 +1,110 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getOAuthClient } from '$lib/google-server.js'; + +export const GET: RequestHandler = async ({ url }) => { + try { + const code = url.searchParams.get('code'); + const error = url.searchParams.get('error'); + + if (error) { + console.error('Google OAuth error:', error); + throw redirect(302, '/private/events?error=google_auth_denied'); + } + + if (!code) { + throw redirect(302, '/private/events?error=missing_auth_code'); + } + + // Exchange code for tokens + const oauth = getOAuthClient(); + const { tokens } = await oauth.getToken(code); + + if (!tokens.refresh_token || !tokens.access_token) { + throw redirect(302, '/private/events?error=incomplete_tokens'); + } + + // Create a success page with tokens that closes the popup and communicates with parent + const html = ` + + + + Google Authentication Success + + + +
+
✓ Authentication successful!
+
Closing window...
+
+ + +`; + + return new Response(html, { + headers: { + 'Content-Type': 'text/html' + } + }); + } catch (error) { + console.error('Error handling Google OAuth callback:', error); + throw redirect(302, '/private/events?error=google_auth_failed'); + } +}; diff --git a/src/routes/private/api/gmail/+server.ts b/src/routes/private/api/gmail/+server.ts index ca0e7d4..62b73e8 100644 --- a/src/routes/private/api/gmail/+server.ts +++ b/src/routes/private/api/gmail/+server.ts @@ -3,9 +3,9 @@ import { json, redirect } from '@sveltejs/kit'; import { createAuthUrl, exchangeCodeForTokens, - sendGmail, getOAuthClient } from '$lib/google'; +import { sendGmail } from '$lib/gmail'; /* ───────────── GET ───────────── */ export const GET: RequestHandler = async ({ url }) => { diff --git a/src/routes/private/events/+page.svelte b/src/routes/private/events/+page.svelte index 47dc1f6..01b0edc 100644 --- a/src/routes/private/events/+page.svelte +++ b/src/routes/private/events/+page.svelte @@ -1,53 +1,24 @@

All Events

- {#if loading} - {#each Array(4) as _} - New Event diff --git a/src/routes/private/events/event/new/+page.server.ts b/src/routes/private/events/event/new/+page.server.ts new file mode 100644 index 0000000..036c394 --- /dev/null +++ b/src/routes/private/events/event/new/+page.server.ts @@ -0,0 +1,7 @@ +export const load = async ({ locals }: { locals: any }) => { + const { session } = await locals.safeGetSession(); + + return { + session + }; +}; diff --git a/src/routes/private/events/event/new/+page.svelte b/src/routes/private/events/event/new/+page.svelte new file mode 100644 index 0000000..bad1c12 --- /dev/null +++ b/src/routes/private/events/event/new/+page.svelte @@ -0,0 +1,795 @@ + + +
+ +
+

Create New Event

+
+ {#each Array(totalSteps) as _, index} +
+
+ {index + 1} +
+ {#if index < totalSteps - 1} +
+ {/if} +
+ {/each} +
+

Step {currentStep + 1} of {totalSteps}: {stepTitle}

+
+ + +
+ {#if currentStep === 0} + +
+
+

Connect Your Google Account

+

+ To create events and import participants from Google Sheets, you need to connect your Google account. +

+ + {#if authData.checking} +
+
+ Checking connection... +
+ {:else if authData.isConnected} +
+
+
+ + + +
+
+

+ Google account connected successfully! +

+

+ You can now access Google Sheets and Gmail features. +

+
+
+
+ {:else} +
+
+
+ + + +
+
+

+ Google account not connected +

+

+ Please connect your Google account to continue with event creation. +

+
+
+
+ +
+ + + {#if authData.connecting && authData.showCancelOption} + +

+ Taking too long? You can cancel and try again. +

+ {/if} +
+ {/if} + + {#if authData.error} +
+
+
+ + + +
+
+

+ Connection Error +

+

+ {authData.error} +

+
+
+
+ {/if} + + {#if errors.auth} +

{errors.auth}

+ {/if} +
+
+ + {:else if currentStep === 1} + +
+
+ + + {#if errors.name} +

{errors.name}

+ {/if} +
+ +
+ + + {#if errors.date} +

{errors.date}

+ {/if} +
+
+ + {:else if currentStep === 2} + +
+
+

Select Google Sheet

+ + {#if sheetsData.loading && sheetsData.availableSheets.length === 0} +
+ {#each Array(5) as _} +
+
+
+
+ {/each} +
+ {:else if sheetsData.availableSheets.length === 0} +
+

No Google Sheets found.

+ +
+ {:else} +
+ {#if !sheetsData.expandedSheetList && sheetsData.selectedSheet} + +
+
+
{sheetsData.selectedSheet.name}
+
+ Modified: {new Date(sheetsData.selectedSheet.modifiedTime).toLocaleDateString()} +
+
+ +
+ {:else} + +
+

Available Sheets

+ {#if sheetsData.selectedSheet} + + {/if} +
+
+ {#each sheetsData.availableSheets as sheet} + + {/each} +
+ {/if} +
+ {/if} + + {#if errors.sheet} +

{errors.sheet}

+ {/if} +
+ + {#if sheetsData.selectedSheet && sheetsData.sheetData.length > 0} +
+

Column Mapping

+ + +
+

Column Mapping Instructions:

+

+ Select what each column represents by using the dropdown in each column header. + Make sure to assign Name, Surname, Email, and Confirmation columns. +

+
+ +
+ + + + {#each sheetsData.sheetData[0] || [] as header, index} + + {/each} + + + + {#each sheetsData.sheetData.slice(0, 10) as row, rowIndex} + + {#each row as cell, cellIndex} + + {/each} + + {/each} + +
+
+
+ Column {index + 1} + ({header || 'Empty'}) +
+ + {#if sheetsData.columnMapping.name === index + 1} + Name Column + {:else if sheetsData.columnMapping.surname === index + 1} + Surname Column + {:else if sheetsData.columnMapping.email === index + 1} + Email Column + {:else if sheetsData.columnMapping.confirmation === index + 1} + Confirmation Column + {/if} +
+
+ + {cell || ''} + +
+
+

Showing first 10 rows

+
+ {/if} + + {#if sheetsData.loading && sheetsData.selectedSheet} +
+
Loading sheet data...
+
+ {/if} + + {#if errors.sheetData} +

{errors.sheetData}

+ {/if} +
+ + {:else if currentStep === 3} + +
+
+ + + {#if errors.subject} +

{errors.subject}

+ {/if} +
+ +
+ + + {#if errors.body} +

{errors.body}

+ {/if} +
+ + +
+

Preview

+
+
Subject: {emailData.subject || 'No subject'}
+
{emailData.body || 'No content'}
+
+
+
+ {/if} + + {#if errors.submit} +
+

{errors.submit}

+
+ {/if} +
+ + +
+ + +
+ {#if currentStep < totalSteps - 1} + + {:else} + + {/if} +
+
+
-- 2.49.1 From c2949e4bfeff1fbb09aa8a8395960a3a2b1b80f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Wed, 2 Jul 2025 23:23:15 +0200 Subject: [PATCH 04/20] Create event creation structuring and polishing --- .../private/events/event/new/+page.server.ts | 7 - .../private/events/event/new/+page.svelte | 524 +++--------------- .../new/components/EmailSettingsStep.svelte | 43 ++ .../new/components/EventDetailsStep.svelte | 44 ++ .../new/components/GoogleAuthStep.svelte | 144 +++++ .../new/components/GoogleSheetsStep.svelte | 213 +++++++ .../new/components/StepNavigation.svelte | 42 ++ .../event/new/components/StepNavigator.svelte | 28 + 8 files changed, 600 insertions(+), 445 deletions(-) delete mode 100644 src/routes/private/events/event/new/+page.server.ts create mode 100644 src/routes/private/events/event/new/components/EmailSettingsStep.svelte create mode 100644 src/routes/private/events/event/new/components/EventDetailsStep.svelte create mode 100644 src/routes/private/events/event/new/components/GoogleAuthStep.svelte create mode 100644 src/routes/private/events/event/new/components/GoogleSheetsStep.svelte create mode 100644 src/routes/private/events/event/new/components/StepNavigation.svelte create mode 100644 src/routes/private/events/event/new/components/StepNavigator.svelte diff --git a/src/routes/private/events/event/new/+page.server.ts b/src/routes/private/events/event/new/+page.server.ts deleted file mode 100644 index 036c394..0000000 --- a/src/routes/private/events/event/new/+page.server.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const load = async ({ locals }: { locals: any }) => { - const { session } = await locals.safeGetSession(); - - return { - session - }; -}; diff --git a/src/routes/private/events/event/new/+page.svelte b/src/routes/private/events/event/new/+page.svelte index bad1c12..5f20ee6 100644 --- a/src/routes/private/events/event/new/+page.svelte +++ b/src/routes/private/events/event/new/+page.svelte @@ -2,6 +2,15 @@ import { onMount } from 'svelte'; import type { GoogleSheet } from '$lib/sheets.js'; import { isTokenValid, refreshAccessToken } from '$lib/google.js'; + import { goto } from '$app/navigation'; + + // Import Components + import GoogleAuthStep from './components/GoogleAuthStep.svelte'; + import EventDetailsStep from './components/EventDetailsStep.svelte'; + import GoogleSheetsStep from './components/GoogleSheetsStep.svelte'; + import EmailSettingsStep from './components/EmailSettingsStep.svelte'; + import StepNavigator from './components/StepNavigator.svelte'; + import StepNavigation from './components/StepNavigation.svelte'; let { data } = $props(); @@ -16,7 +25,8 @@ connecting: false, showCancelOption: false, token: null as string | null, - error: null as string | null + error: null as string | null, + userEmail: null as string | null }); // Step 1: Event Details @@ -71,13 +81,20 @@ const isValid = await isTokenValid(accessToken); authData.isConnected = isValid; authData.token = accessToken; + + if (isValid) { + // Fetch user info + await fetchUserInfo(accessToken); + } } else { authData.isConnected = false; + authData.userEmail = null; } } catch (error) { console.error('Error checking Google auth:', error); authData.isConnected = false; authData.error = 'Error checking Google connection'; + authData.userEmail = null; } finally { authData.checking = false; } @@ -170,6 +187,59 @@ authData.connecting = false; authData.showCancelOption = false; } + + async function fetchUserInfo(accessToken: string) { + try { + const response = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + if (response.ok) { + const userData = await response.json(); + authData.userEmail = userData.email; + } else { + console.error('Failed to fetch user info:', await response.text()); + authData.userEmail = null; + } + } catch (error) { + console.error('Error fetching user info:', error); + authData.userEmail = null; + } + } + + async function disconnectGoogle() { + try { + // First revoke the token at Google + const accessToken = localStorage.getItem('google_access_token'); + if (accessToken) { + await fetch(`https://accounts.google.com/o/oauth2/revoke?token=${accessToken}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + } + + // Remove tokens from local storage + localStorage.removeItem('google_access_token'); + localStorage.removeItem('google_refresh_token'); + + // Update auth state + authData.isConnected = false; + authData.token = null; + authData.userEmail = null; + + // Clear any selected sheets data + sheetsData.availableSheets = []; + sheetsData.selectedSheet = null; + sheetsData.sheetData = []; + } catch (error) { + console.error('Error disconnecting from Google:', error); + authData.error = 'Failed to disconnect from Google'; + } + } // Step navigation function nextStep() { @@ -300,7 +370,6 @@ loading = true; try { - // TODO: Replace with actual Supabase function call const { error } = await data.supabase.rpc('create_event', { p_name: eventData.name, p_date: eventData.date, @@ -316,7 +385,7 @@ if (error) throw error; // Redirect to events list or show success message - window.location.href = '/private/events'; + goto('/private/events'); } catch (error) { console.error('Error creating event:', error); errors.submit = 'Failed to create event. Please try again.'; @@ -336,423 +405,22 @@ if (currentStep === 3) return emailData.subject && emailData.body; return false; }); - - let stepTitle = $derived(() => { - if (currentStep === 0) return 'Connect Google Account'; - if (currentStep === 1) return 'Event Details'; - if (currentStep === 2) return 'Connect Google Sheets'; - if (currentStep === 3) return 'Email Settings'; - return ''; - });
-
-

Create New Event

-
- {#each Array(totalSteps) as _, index} -
-
- {index + 1} -
- {#if index < totalSteps - 1} -
- {/if} -
- {/each} -
-

Step {currentStep + 1} of {totalSteps}: {stepTitle}

-
+
{#if currentStep === 0} - -
-
-

Connect Your Google Account

-

- To create events and import participants from Google Sheets, you need to connect your Google account. -

- - {#if authData.checking} -
-
- Checking connection... -
- {:else if authData.isConnected} -
-
-
- - - -
-
-

- Google account connected successfully! -

-

- You can now access Google Sheets and Gmail features. -

-
-
-
- {:else} -
-
-
- - - -
-
-

- Google account not connected -

-

- Please connect your Google account to continue with event creation. -

-
-
-
- -
- - - {#if authData.connecting && authData.showCancelOption} - -

- Taking too long? You can cancel and try again. -

- {/if} -
- {/if} - - {#if authData.error} -
-
-
- - - -
-
-

- Connection Error -

-

- {authData.error} -

-
-
-
- {/if} - - {#if errors.auth} -

{errors.auth}

- {/if} -
-
- + {:else if currentStep === 1} - -
-
- - - {#if errors.name} -

{errors.name}

- {/if} -
- -
- - - {#if errors.date} -

{errors.date}

- {/if} -
-
- + {:else if currentStep === 2} - -
-
-

Select Google Sheet

- - {#if sheetsData.loading && sheetsData.availableSheets.length === 0} -
- {#each Array(5) as _} -
-
-
-
- {/each} -
- {:else if sheetsData.availableSheets.length === 0} -
-

No Google Sheets found.

- -
- {:else} -
- {#if !sheetsData.expandedSheetList && sheetsData.selectedSheet} - -
-
-
{sheetsData.selectedSheet.name}
-
- Modified: {new Date(sheetsData.selectedSheet.modifiedTime).toLocaleDateString()} -
-
- -
- {:else} - -
-

Available Sheets

- {#if sheetsData.selectedSheet} - - {/if} -
-
- {#each sheetsData.availableSheets as sheet} - - {/each} -
- {/if} -
- {/if} - - {#if errors.sheet} -

{errors.sheet}

- {/if} -
- - {#if sheetsData.selectedSheet && sheetsData.sheetData.length > 0} -
-

Column Mapping

- - -
-

Column Mapping Instructions:

-

- Select what each column represents by using the dropdown in each column header. - Make sure to assign Name, Surname, Email, and Confirmation columns. -

-
- -
- - - - {#each sheetsData.sheetData[0] || [] as header, index} - - {/each} - - - - {#each sheetsData.sheetData.slice(0, 10) as row, rowIndex} - - {#each row as cell, cellIndex} - - {/each} - - {/each} - -
-
-
- Column {index + 1} - ({header || 'Empty'}) -
- - {#if sheetsData.columnMapping.name === index + 1} - Name Column - {:else if sheetsData.columnMapping.surname === index + 1} - Surname Column - {:else if sheetsData.columnMapping.email === index + 1} - Email Column - {:else if sheetsData.columnMapping.confirmation === index + 1} - Confirmation Column - {/if} -
-
- - {cell || ''} - -
-
-

Showing first 10 rows

-
- {/if} - - {#if sheetsData.loading && sheetsData.selectedSheet} -
-
Loading sheet data...
-
- {/if} - - {#if errors.sheetData} -

{errors.sheetData}

- {/if} -
- + {:else if currentStep === 3} - -
-
- - - {#if errors.subject} -

{errors.subject}

- {/if} -
- -
- - - {#if errors.body} -

{errors.body}

- {/if} -
- - -
-

Preview

-
-
Subject: {emailData.subject || 'No subject'}
-
{emailData.body || 'No content'}
-
-
-
+ {/if} {#if errors.submit} @@ -763,33 +431,13 @@
-
- - -
- {#if currentStep < totalSteps - 1} - - {:else} - - {/if} -
-
+
diff --git a/src/routes/private/events/event/new/components/EmailSettingsStep.svelte b/src/routes/private/events/event/new/components/EmailSettingsStep.svelte new file mode 100644 index 0000000..560d9a5 --- /dev/null +++ b/src/routes/private/events/event/new/components/EmailSettingsStep.svelte @@ -0,0 +1,43 @@ + + +
+
+ + + {#if errors.subject} +

{errors.subject}

+ {/if} +
+ +
+ + + {#if errors.body} +

{errors.body}

+ {/if} +
+
diff --git a/src/routes/private/events/event/new/components/EventDetailsStep.svelte b/src/routes/private/events/event/new/components/EventDetailsStep.svelte new file mode 100644 index 0000000..3868340 --- /dev/null +++ b/src/routes/private/events/event/new/components/EventDetailsStep.svelte @@ -0,0 +1,44 @@ + + +
+
+

Event details

+ + + + {#if errors.name} +

{errors.name}

+ {/if} +
+ +
+ + + {#if errors.date} +

{errors.date}

+ {/if} +
+
diff --git a/src/routes/private/events/event/new/components/GoogleAuthStep.svelte b/src/routes/private/events/event/new/components/GoogleAuthStep.svelte new file mode 100644 index 0000000..a3f469f --- /dev/null +++ b/src/routes/private/events/event/new/components/GoogleAuthStep.svelte @@ -0,0 +1,144 @@ + + +
+
+

Connect Your Google Account

+

+ To create events and import participants from Google Sheets, you need to connect your Google account. +

+ + {#if authData.checking} +
+
+ Checking connection... +
+ {:else if authData.isConnected} +
+
+
+

+ Google account connected successfully! +

+ {#if authData.userEmail} +
+

+ {authData.userEmail} +

+
+ {/if} +

+ You can now access Google Sheets and Gmail features. +

+
+
+ +
+ +
+
+ {:else} +
+
+
+ + + +
+
+

+ Google account not connected +

+

+ Please connect your Google account to continue with event creation. +

+
+
+
+ +
+ + + {#if authData.connecting && authData.showCancelOption} + +

+ Taking too long? You can cancel and try again. +

+ {/if} +
+ {/if} + + {#if authData.error} +
+
+
+ + + +
+
+

+ Connection Error +

+

+ {authData.error} +

+
+
+
+ {/if} + + {#if errors.auth} +

{errors.auth}

+ {/if} +
+
diff --git a/src/routes/private/events/event/new/components/GoogleSheetsStep.svelte b/src/routes/private/events/event/new/components/GoogleSheetsStep.svelte new file mode 100644 index 0000000..2dac6ec --- /dev/null +++ b/src/routes/private/events/event/new/components/GoogleSheetsStep.svelte @@ -0,0 +1,213 @@ + + +
+
+

Select Google Sheet

+ + {#if sheetsData.loading && sheetsData.availableSheets.length === 0} +
+ {#each Array(5) as _} +
+
+
+
+ {/each} +
+ {:else if sheetsData.availableSheets.length === 0} +
+

No Google Sheets found.

+ +
+ {:else} +
+ {#if !sheetsData.expandedSheetList && sheetsData.selectedSheet} + +
+
+
{sheetsData.selectedSheet.name}
+
+ Modified: {new Date(sheetsData.selectedSheet.modifiedTime).toLocaleDateString()} +
+
+ +
+ {:else} + +
+

Available Sheets

+ {#if sheetsData.selectedSheet} + + {/if} +
+
+ {#each sheetsData.availableSheets as sheet} + + {/each} +
+ {/if} +
+ {/if} + + {#if errors.sheet} +

{errors.sheet}

+ {/if} +
+ + {#if sheetsData.selectedSheet && sheetsData.sheetData.length > 0} +
+

Column Mapping

+ + +
+

Column Mapping Instructions:

+

+ Select what each column represents by using the dropdown in each column header. + Make sure to assign Name, Surname, Email, and Confirmation columns. +

+
+ +
+ + + + {#each sheetsData.sheetData[0] || [] as header, index} + + {/each} + + + + {#each sheetsData.sheetData.slice(0, 10) as row, rowIndex} + + {#each row as cell, cellIndex} + + {/each} + + {/each} + +
+
+
+ {header || `Empty Column ${index + 1}`} +
+ +
+ {#if sheetsData.columnMapping.name === index + 1} + Name Column + {:else if sheetsData.columnMapping.surname === index + 1} + Surname Column + {:else if sheetsData.columnMapping.email === index + 1} + Email Column + {:else if sheetsData.columnMapping.confirmation === index + 1} + Confirmation Column + {:else} + Not Mapped + {/if} +
+
+
+ + {cell || ''} + +
+
+

Showing first 10 rows

+
+ {/if} + + {#if sheetsData.loading && sheetsData.selectedSheet} +
+
Loading sheet data...
+
+ {/if} + + {#if errors.sheetData} +

{errors.sheetData}

+ {/if} +
diff --git a/src/routes/private/events/event/new/components/StepNavigation.svelte b/src/routes/private/events/event/new/components/StepNavigation.svelte new file mode 100644 index 0000000..207caba --- /dev/null +++ b/src/routes/private/events/event/new/components/StepNavigation.svelte @@ -0,0 +1,42 @@ + + +
+ + +
+ {#if currentStep < totalSteps - 1} + + {:else} + + {/if} +
+
diff --git a/src/routes/private/events/event/new/components/StepNavigator.svelte b/src/routes/private/events/event/new/components/StepNavigator.svelte new file mode 100644 index 0000000..f26fb87 --- /dev/null +++ b/src/routes/private/events/event/new/components/StepNavigator.svelte @@ -0,0 +1,28 @@ + + +
+
+ {#each Array(totalSteps) as _, index} +
+
+ {index + 1} +
+ {#if index < totalSteps - 1} +
+ {/if} +
+ {/each} +
+
-- 2.49.1 From 878198fabde848e327c60b5a3e08a91df49edc73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Wed, 2 Jul 2025 23:33:35 +0200 Subject: [PATCH 05/20] API reformatting --- src/lib/google.ts | 37 ++++++++++++++- src/routes/private/api/google/README.md | 47 +++++++++++++++++++ .../api/google/auth/refresh/+server.ts | 30 ++++++++++++ .../private/api/google/auth/revoke/+server.ts | 31 ++++++++++++ .../api/google/auth/userinfo/+server.ts | 33 +++++++++++++ .../google/sheets/[sheetId]/data/+server.ts | 22 +++++++++ .../api/google/sheets/recent/+server.ts | 20 ++++++++ .../private/events/event/new/+page.svelte | 33 +++++-------- 8 files changed, 230 insertions(+), 23 deletions(-) create mode 100644 src/routes/private/api/google/README.md create mode 100644 src/routes/private/api/google/auth/refresh/+server.ts create mode 100644 src/routes/private/api/google/auth/revoke/+server.ts create mode 100644 src/routes/private/api/google/auth/userinfo/+server.ts create mode 100644 src/routes/private/api/google/sheets/[sheetId]/data/+server.ts create mode 100644 src/routes/private/api/google/sheets/recent/+server.ts diff --git a/src/lib/google.ts b/src/lib/google.ts index 718199b..4977884 100644 --- a/src/lib/google.ts +++ b/src/lib/google.ts @@ -40,7 +40,7 @@ export async function isTokenValid(accessToken: string): Promise { export async function refreshAccessToken(refreshToken: string): Promise { try { - const response = await fetch('/api/auth/refresh', { + const response = await fetch('/private/api/google/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -58,3 +58,38 @@ export async function refreshAccessToken(refreshToken: string): Promise { + try { + const response = await fetch('/private/api/google/auth/userinfo', { + headers: { + 'Authorization': `Bearer ${accessToken}` + } + }); + + if (response.ok) { + return await response.json(); + } + return null; + } catch (error) { + console.error('Error fetching user info:', error); + return null; + } +} + +export async function revokeToken(accessToken: string): Promise { + try { + const response = await fetch('/private/api/google/auth/revoke', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ accessToken }) + }); + + return response.ok; + } catch (error) { + console.error('Error revoking token:', error); + return false; + } +} diff --git a/src/routes/private/api/google/README.md b/src/routes/private/api/google/README.md new file mode 100644 index 0000000..6d1f46b --- /dev/null +++ b/src/routes/private/api/google/README.md @@ -0,0 +1,47 @@ +# Google API Integration + +This directory contains unified endpoints for Google API integration, all protected under the `/private` route to ensure only authenticated users can access them. + +## Auth Endpoints + +### `/private/api/google/auth/refresh` + +- **Method**: POST +- **Purpose**: Refreshes an access token using a refresh token +- **Body**: `{ "refreshToken": "your-refresh-token" }` +- **Response**: `{ "accessToken": "new-access-token", "expiresIn": 3600 }` + +### `/private/api/google/auth/userinfo` + +- **Method**: GET +- **Purpose**: Gets information about the authenticated user +- **Headers**: Authorization: Bearer `access_token` +- **Response**: `{ "email": "user@example.com", "name": "User Name", "picture": "profile-pic-url" }` + +### `/private/api/google/auth/revoke` + +- **Method**: POST +- **Purpose**: Revokes an access token +- **Body**: `{ "accessToken": "token-to-revoke" }` +- **Response**: `{ "success": true }` + +## Sheets Endpoints + +### `/private/api/google/sheets/recent` + +- **Method**: GET +- **Purpose**: Gets a list of recent spreadsheets +- **Headers**: Authorization: Bearer `refresh_token` +- **Response**: Array of spreadsheet objects + +### `/private/api/google/sheets/[sheetId]/data` + +- **Method**: GET +- **Purpose**: Gets data from a specific spreadsheet +- **Headers**: Authorization: Bearer `refresh_token` +- **URL Parameters**: sheetId - The ID of the spreadsheet +- **Response**: Spreadsheet data including values array + +## Client Usage + +Use the utility functions in `$lib/google.ts` to interact with these endpoints. diff --git a/src/routes/private/api/google/auth/refresh/+server.ts b/src/routes/private/api/google/auth/refresh/+server.ts new file mode 100644 index 0000000..e6561ee --- /dev/null +++ b/src/routes/private/api/google/auth/refresh/+server.ts @@ -0,0 +1,30 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getOAuthClient } from '$lib/google-server.js'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const { refreshToken } = await request.json(); + + if (!refreshToken) { + return json({ error: 'Refresh token is required' }, { status: 400 }); + } + + const oauth = getOAuthClient(); + oauth.setCredentials({ refresh_token: refreshToken }); + + const { credentials } = await oauth.refreshAccessToken(); + + if (!credentials.access_token) { + return json({ error: 'Failed to refresh token' }, { status: 500 }); + } + + return json({ + accessToken: credentials.access_token, + expiresIn: credentials.expiry_date + }); + } catch (error) { + console.error('Error refreshing access token:', error); + return json({ error: 'Failed to refresh access token' }, { status: 500 }); + } +}; diff --git a/src/routes/private/api/google/auth/revoke/+server.ts b/src/routes/private/api/google/auth/revoke/+server.ts new file mode 100644 index 0000000..b01adee --- /dev/null +++ b/src/routes/private/api/google/auth/revoke/+server.ts @@ -0,0 +1,31 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ request }) => { + try { + const { accessToken } = await request.json(); + + if (!accessToken) { + return json({ error: 'Access token is required' }, { status: 400 }); + } + + // Call Google's token revocation endpoint + const response = await fetch(`https://accounts.google.com/o/oauth2/revoke?token=${accessToken}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + + if (response.ok) { + return json({ success: true }); + } else { + const error = await response.text(); + console.error('Error revoking token:', error); + return json({ error: 'Failed to revoke token' }, { status: 500 }); + } + } catch (error) { + console.error('Error revoking access token:', error); + return json({ error: 'Failed to revoke access token' }, { status: 500 }); + } +}; diff --git a/src/routes/private/api/google/auth/userinfo/+server.ts b/src/routes/private/api/google/auth/userinfo/+server.ts new file mode 100644 index 0000000..cb98710 --- /dev/null +++ b/src/routes/private/api/google/auth/userinfo/+server.ts @@ -0,0 +1,33 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getOAuthClient } from '$lib/google-server.js'; +import { google } from 'googleapis'; + +export const GET: RequestHandler = async ({ request }) => { + try { + const authHeader = request.headers.get('authorization'); + + if (!authHeader?.startsWith('Bearer ')) { + return json({ error: 'Missing or invalid authorization header' }, { status: 401 }); + } + + const accessToken = authHeader.slice(7); + + // Create OAuth client with the token + const oauth = getOAuthClient(); + oauth.setCredentials({ access_token: accessToken }); + + // Call the userinfo endpoint to get user details + const oauth2 = google.oauth2({ version: 'v2', auth: oauth }); + const userInfo = await oauth2.userinfo.get(); + + return json({ + email: userInfo.data.email, + name: userInfo.data.name, + picture: userInfo.data.picture + }); + } catch (error) { + console.error('Error fetching user info:', error); + return json({ error: 'Failed to fetch user info' }, { status: 500 }); + } +}; diff --git a/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts b/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts new file mode 100644 index 0000000..263e099 --- /dev/null +++ b/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts @@ -0,0 +1,22 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getSpreadsheetData } from '$lib/sheets.js'; + +export const GET: RequestHandler = async ({ params, request }) => { + try { + const { sheetId } = params; + const authHeader = request.headers.get('authorization'); + + if (!authHeader?.startsWith('Bearer ')) { + return json({ error: 'Missing or invalid authorization header' }, { status: 401 }); + } + + const refreshToken = authHeader.slice(7); + const sheetData = await getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); + + return json(sheetData); + } catch (error) { + console.error('Error fetching spreadsheet data:', error); + return json({ error: 'Failed to fetch spreadsheet data' }, { status: 500 }); + } +}; diff --git a/src/routes/private/api/google/sheets/recent/+server.ts b/src/routes/private/api/google/sheets/recent/+server.ts new file mode 100644 index 0000000..d9b94a2 --- /dev/null +++ b/src/routes/private/api/google/sheets/recent/+server.ts @@ -0,0 +1,20 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getRecentSpreadsheets } from '$lib/sheets.js'; + +export const GET: RequestHandler = async ({ request }) => { + try { + const authHeader = request.headers.get('authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return json({ error: 'Missing or invalid authorization header' }, { status: 401 }); + } + + const refreshToken = authHeader.slice(7); + const sheets = await getRecentSpreadsheets(refreshToken, 20); + + return json(sheets); + } catch (error) { + console.error('Error fetching recent spreadsheets:', error); + return json({ error: 'Failed to fetch spreadsheets' }, { status: 500 }); + } +}; diff --git a/src/routes/private/events/event/new/+page.svelte b/src/routes/private/events/event/new/+page.svelte index 5f20ee6..b3868bb 100644 --- a/src/routes/private/events/event/new/+page.svelte +++ b/src/routes/private/events/event/new/+page.svelte @@ -1,8 +1,8 @@ `; - return new Response(html, { headers: { 'Content-Type': 'text/html' } }); - } catch (err) { - return new Response((err as Error).message, { status: 500 }); - } - } - - return new Response('Bad request', { status: 400 }); -}; - -/* ───────────── POST ───────────── */ -export const POST: RequestHandler = async ({ request }) => { - const { action, refreshToken, to, subject, text, qr_code } = await request.json(); - - /* send e-mail */ - if (action === 'send') { - if (!refreshToken) return new Response('Missing token', { status: 401 }); - try { - await sendGmail(refreshToken, { to, subject, text, qr_code }); - return json({ ok: true }); - } catch (err) { - return new Response((err as Error).message, { status: 500 }); - } - } - - /* revoke token */ - if (action === 'revoke') { - if (!refreshToken) return new Response('Missing token', { status: 401 }); - try { - await getOAuthClient().revokeToken(refreshToken); - return json({ ok: true }); - } catch (err) { - return new Response((err as Error).message, { status: 500 }); - } - } - - /* validate token */ - if (action === 'validate') { - if (!refreshToken) { - return json({ valid: false }); - } - try { - const oAuth2Client = getOAuthClient(); - oAuth2Client.setCredentials({ refresh_token: refreshToken }); - await oAuth2Client.getAccessToken(); // This will throw if invalid - return json({ valid: true }); - } catch (err) { - return json({ valid: false, error: (err as Error).message }); - } - } - - return new Response('Bad request', { status: 400 }); -}; diff --git a/src/routes/private/api/google/auth/refresh/+server.ts b/src/routes/private/api/google/auth/refresh/+server.ts index e6561ee..83eecbb 100644 --- a/src/routes/private/api/google/auth/refresh/+server.ts +++ b/src/routes/private/api/google/auth/refresh/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getOAuthClient } from '$lib/google-server.js'; +import { authServer } from '$lib/google/index.js'; export const POST: RequestHandler = async ({ request }) => { try { @@ -10,7 +10,7 @@ export const POST: RequestHandler = async ({ request }) => { return json({ error: 'Refresh token is required' }, { status: 400 }); } - const oauth = getOAuthClient(); + const oauth = authServer.getOAuthClient(); oauth.setCredentials({ refresh_token: refreshToken }); const { credentials } = await oauth.refreshAccessToken(); diff --git a/src/routes/private/api/google/auth/userinfo/+server.ts b/src/routes/private/api/google/auth/userinfo/+server.ts index cb98710..ac76ea0 100644 --- a/src/routes/private/api/google/auth/userinfo/+server.ts +++ b/src/routes/private/api/google/auth/userinfo/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getOAuthClient } from '$lib/google-server.js'; +import { authServer } from '$lib/google/index.js'; import { google } from 'googleapis'; export const GET: RequestHandler = async ({ request }) => { @@ -14,7 +14,7 @@ export const GET: RequestHandler = async ({ request }) => { const accessToken = authHeader.slice(7); // Create OAuth client with the token - const oauth = getOAuthClient(); + const oauth = authServer.getOAuthClient(); oauth.setCredentials({ access_token: accessToken }); // Call the userinfo endpoint to get user details diff --git a/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts b/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts index 263e099..e87c1ff 100644 --- a/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts +++ b/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getSpreadsheetData } from '$lib/sheets.js'; +import { sheets } from '$lib/google/index.js'; export const GET: RequestHandler = async ({ params, request }) => { try { @@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ params, request }) => { } const refreshToken = authHeader.slice(7); - const sheetData = await getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); + const sheetData = await sheets.getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); return json(sheetData); } catch (error) { diff --git a/src/routes/private/api/google/sheets/recent/+server.ts b/src/routes/private/api/google/sheets/recent/+server.ts index d9b94a2..ed01813 100644 --- a/src/routes/private/api/google/sheets/recent/+server.ts +++ b/src/routes/private/api/google/sheets/recent/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getRecentSpreadsheets } from '$lib/sheets.js'; +import { sheets } from '$lib/google/index.js'; export const GET: RequestHandler = async ({ request }) => { try { @@ -10,9 +10,9 @@ export const GET: RequestHandler = async ({ request }) => { } const refreshToken = authHeader.slice(7); - const sheets = await getRecentSpreadsheets(refreshToken, 20); + const spreadsheets = await sheets.getRecentSpreadsheets(refreshToken, 20); - return json(sheets); + return json(spreadsheets); } catch (error) { console.error('Error fetching recent spreadsheets:', error); return json({ error: 'Failed to fetch spreadsheets' }, { status: 500 }); diff --git a/src/routes/private/events/event/new/+page.svelte b/src/routes/private/events/event/new/+page.svelte index b3868bb..3e87601 100644 --- a/src/routes/private/events/event/new/+page.svelte +++ b/src/routes/private/events/event/new/+page.svelte @@ -1,7 +1,7 @@

All Events

- {#each data.events as event} - -
- {event.name} - {event.date} + {#if loading} + + {#each Array(4) as _} +
+
+
+
+
-
- {/each} + {/each} + {:else if error} + +
+ {error} + +
+ {:else if events.length === 0} + +
+ No events found +
+ {:else} + + {#each events as event} + +
+ {event.name} + {event.date} +
+
+ {/each} + {/if}
Date: Tue, 8 Jul 2025 12:07:43 +0200 Subject: [PATCH 10/20] Restructure progress --- .github/copilot-instructions.md | 5 + src/lib/gmail.ts | 80 ---------------- src/lib/google-server.ts | 38 -------- src/lib/google.ts | 95 ------------------- src/lib/google/auth/server.ts | 1 + src/lib/google/client.ts | 13 ++- src/lib/google/client/index.ts | 5 - src/lib/google/client/types.ts | 14 --- src/lib/google/gmail/index.ts | 90 ------------------ src/lib/google/gmail/server.ts | 88 +++++++++++++++++ src/lib/google/index.ts | 9 -- src/lib/google/server.ts | 17 ++-- src/lib/google/sheets/client.ts | 23 +++++ src/lib/google/sheets/index.ts | 77 --------------- src/lib/google/sheets/server.ts | 89 +++++++++++++++++ src/lib/index.ts | 1 - src/lib/sheets.ts | 58 ----------- src/lib/{ => types}/types.ts | 0 src/routes/api/auth/refresh/+server.ts | 4 +- src/routes/api/events/+server.ts | 21 ---- src/routes/auth/google/+server.ts | 4 +- src/routes/auth/google/callback/+server.ts | 4 +- .../api/google/auth/refresh/+server.ts | 4 +- .../api/google/auth/userinfo/+server.ts | 4 +- .../google/sheets/[sheetId]/data/+server.ts | 4 +- .../api/google/sheets/recent/+server.ts | 4 +- src/routes/private/events/+page.server.ts | 0 src/routes/private/events/+page.svelte | 85 +++-------------- .../private/events/event/new/+page.svelte | 4 +- .../new/components/GoogleSheetsStep.svelte | 2 +- src/routes/private/scanner/+page.svelte | 4 +- .../private/scanner/TicketDisplay.svelte | 4 +- 32 files changed, 257 insertions(+), 594 deletions(-) delete mode 100644 src/lib/gmail.ts delete mode 100644 src/lib/google-server.ts delete mode 100644 src/lib/google.ts delete mode 100644 src/lib/google/client/index.ts delete mode 100644 src/lib/google/client/types.ts delete mode 100644 src/lib/google/gmail/index.ts create mode 100644 src/lib/google/gmail/server.ts delete mode 100644 src/lib/google/index.ts create mode 100644 src/lib/google/sheets/client.ts delete mode 100644 src/lib/google/sheets/index.ts create mode 100644 src/lib/google/sheets/server.ts delete mode 100644 src/lib/index.ts delete mode 100644 src/lib/sheets.ts rename src/lib/{ => types}/types.ts (100%) create mode 100644 src/routes/private/events/+page.server.ts diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a67c7e3..303229d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -92,6 +92,11 @@ onsubmit|preventDefault={handleSubmit} is depracated, do not use it! Loading session using page.server.ts is not needed as the session is already available in the locals object. +IMPORTANT: Always make sure that the client-side module are not importing secrets +or are running any sensritive code that could expose secrets to the client. +If any requests are needed to check sensitive infomration, create an api route and +fetch data from there instead of directly in the client-side module. + The database schema in supabase is as follows: -- WARNING: This schema is for context only and is not meant to be run. -- Table order and constraints may not be valid for execution. diff --git a/src/lib/gmail.ts b/src/lib/gmail.ts deleted file mode 100644 index c46488d..0000000 --- a/src/lib/gmail.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { google } from 'googleapis'; -import quotedPrintable from 'quoted-printable'; -import { getAuthenticatedClient } from './google-server.js'; - -export function createEmailTemplate(text: string): string { - return ` - - - - - -
-

${text}

- QR Code -
-
-
-
-
-
-
-
-

This email has been generated with the help of ScanWave

-
-
- -`; -} - -export async function sendGmail( - refreshToken: string, - { to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string } -) { - const oauth = getAuthenticatedClient(refreshToken); - const gmail = google.gmail({ version: 'v1', auth: oauth }); - - const message_html = createEmailTemplate(text); - const boundary = 'BOUNDARY'; - const nl = '\r\n'; - - // Convert HTML to a Buffer, then to latin1 string for quotedPrintable.encode - const htmlBuffer = Buffer.from(message_html, 'utf8'); - const htmlLatin1 = htmlBuffer.toString('latin1'); - const htmlQP = quotedPrintable.encode(htmlLatin1); - const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl); - - const rawParts = [ - 'MIME-Version: 1.0', - `To: ${to}`, - `Subject: ${subject}`, - `Content-Type: multipart/related; boundary="${boundary}"`, - '', - `--${boundary}`, - 'Content-Type: text/html; charset="UTF-8"', - 'Content-Transfer-Encoding: quoted-printable', - '', - htmlQP, - '', - `--${boundary}`, - 'Content-Type: image/png', - 'Content-Transfer-Encoding: base64', - 'Content-ID: ', - 'Content-Disposition: inline; filename="qr.png"', - '', - qrLines, - '', - `--${boundary}--`, - '' - ]; - - const rawMessage = rawParts.join(nl); - const raw = Buffer.from(rawMessage).toString('base64url'); - - await gmail.users.messages.send({ - userId: 'me', - requestBody: { raw } - }); -} diff --git a/src/lib/google-server.ts b/src/lib/google-server.ts deleted file mode 100644 index 7d98157..0000000 --- a/src/lib/google-server.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { google } from 'googleapis'; -import { env } from '$env/dynamic/private'; - -export const scopes = [ - 'https://www.googleapis.com/auth/gmail.send', - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/drive.readonly', - 'https://www.googleapis.com/auth/spreadsheets.readonly' -]; - -export function getOAuthClient() { - return new google.auth.OAuth2( - env.GOOGLE_CLIENT_ID, - env.GOOGLE_CLIENT_SECRET, - env.GOOGLE_REDIRECT_URI - ); -} - -export function createAuthUrl() { - return getOAuthClient().generateAuthUrl({ - access_type: 'offline', - prompt: 'consent', - scope: scopes, - redirect_uri: env.GOOGLE_REDIRECT_URI - }); -} - -export async function exchangeCodeForTokens(code: string) { - const { tokens } = await getOAuthClient().getToken(code); - if (!tokens.refresh_token) throw new Error('No refresh_token returned'); - return tokens.refresh_token; -} - -export function getAuthenticatedClient(refreshToken: string) { - const oauth = getOAuthClient(); - oauth.setCredentials({ refresh_token: refreshToken }); - return oauth; -} diff --git a/src/lib/google.ts b/src/lib/google.ts deleted file mode 100644 index 4977884..0000000 --- a/src/lib/google.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { browser } from '$app/environment'; - -// Client-side only functions -export const scopes = [ - 'https://www.googleapis.com/auth/gmail.send', - 'https://www.googleapis.com/auth/userinfo.email', - 'https://www.googleapis.com/auth/drive.readonly', - 'https://www.googleapis.com/auth/spreadsheets.readonly' -]; - -// Client-side functions for browser environment -export async function initGoogleAuth(): Promise { - if (!browser) return; - // Google Auth initialization is handled by the OAuth flow - // No initialization needed for our server-side approach -} - -export function getAuthUrl(): string { - if (!browser) return ''; - // This should be obtained from the server - return '/auth/google'; -} - -export async function isTokenValid(accessToken: string): Promise { - if (!browser) return false; - - try { - const response = await fetch(`https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=${accessToken}`); - const data = await response.json(); - - if (response.ok && data.expires_in && data.expires_in > 0) { - return true; - } - return false; - } catch (error) { - console.error('Error validating token:', error); - return false; - } -} - -export async function refreshAccessToken(refreshToken: string): Promise { - try { - const response = await fetch('/private/api/google/auth/refresh', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ refreshToken }) - }); - - if (response.ok) { - const data = await response.json(); - return data.accessToken; - } - return null; - } catch (error) { - console.error('Error refreshing token:', error); - return null; - } -} - -export async function getUserInfo(accessToken: string): Promise<{ email: string; name: string; picture: string } | null> { - try { - const response = await fetch('/private/api/google/auth/userinfo', { - headers: { - 'Authorization': `Bearer ${accessToken}` - } - }); - - if (response.ok) { - return await response.json(); - } - return null; - } catch (error) { - console.error('Error fetching user info:', error); - return null; - } -} - -export async function revokeToken(accessToken: string): Promise { - try { - const response = await fetch('/private/api/google/auth/revoke', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ accessToken }) - }); - - return response.ok; - } catch (error) { - console.error('Error revoking token:', error); - return false; - } -} diff --git a/src/lib/google/auth/server.ts b/src/lib/google/auth/server.ts index 6e937ae..5b4e373 100644 --- a/src/lib/google/auth/server.ts +++ b/src/lib/google/auth/server.ts @@ -26,6 +26,7 @@ export function getOAuthClient() { * @returns Auth URL for Google OAuth */ export function createAuthUrl() { + console.warn("CREATE AUTH URL"); return getOAuthClient().generateAuthUrl({ access_type: 'offline', prompt: 'consent', diff --git a/src/lib/google/client.ts b/src/lib/google/client.ts index dcce5f2..74da0a6 100644 --- a/src/lib/google/client.ts +++ b/src/lib/google/client.ts @@ -1,8 +1,13 @@ /** - * Client-side Google API integration module + * Google API integration module * - * This module provides utilities for interacting with Google APIs from the client-side. + * This module provides utilities for interacting with Google APIs: + * - Authentication (server and client-side) + * - Sheets API */ -// Re-export auth utilities -export * from './auth/client.js'; +// Google service modules +export * as googleAuthClient from './auth/client.ts'; + +export * as googleSheetsClient from './sheets/client.ts'; + diff --git a/src/lib/google/client/index.ts b/src/lib/google/client/index.ts deleted file mode 100644 index 589398b..0000000 --- a/src/lib/google/client/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Re-export client-side auth utilities -export * from '../auth/client.js'; - -// Re-export types -export * from './types.js'; diff --git a/src/lib/google/client/types.ts b/src/lib/google/client/types.ts deleted file mode 100644 index d6f5986..0000000 --- a/src/lib/google/client/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Client-side type definitions for Google API integration - */ - -export interface GoogleSheet { - id: string; - name: string; - modifiedTime: string; - webViewLink: string; -} - -export interface SheetData { - values: string[][]; -} diff --git a/src/lib/google/gmail/index.ts b/src/lib/google/gmail/index.ts deleted file mode 100644 index d281afc..0000000 --- a/src/lib/google/gmail/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { google } from 'googleapis'; -import quotedPrintable from 'quoted-printable'; -import { getAuthenticatedClient } from '../auth/server.js'; - -/** - * Create an HTML email template - * @param text - Email body text - * @returns HTML email template - */ -export function createEmailTemplate(text: string): string { - return ` - - - - - -
-

${text}

- QR Code -
-
-
-
-
-
-
-
-

This email has been generated with the help of ScanWave

-
-
- -`; -} - -/** - * Send an email through Gmail - * @param refreshToken - Google refresh token - * @param params - Email parameters (to, subject, text, qr_code) - */ -export async function sendGmail( - refreshToken: string, - { to, subject, text, qr_code }: { to: string; subject: string; text: string; qr_code: string } -) { - const oauth = getAuthenticatedClient(refreshToken); - const gmail = google.gmail({ version: 'v1', auth: oauth }); - - const message_html = createEmailTemplate(text); - const boundary = 'BOUNDARY'; - const nl = '\r\n'; - - // Convert HTML to a Buffer, then to latin1 string for quotedPrintable.encode - const htmlBuffer = Buffer.from(message_html, 'utf8'); - const htmlLatin1 = htmlBuffer.toString('latin1'); - const htmlQP = quotedPrintable.encode(htmlLatin1); - const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl); - - const rawParts = [ - 'MIME-Version: 1.0', - `To: ${to}`, - `Subject: ${subject}`, - `Content-Type: multipart/related; boundary="${boundary}"`, - '', - `--${boundary}`, - 'Content-Type: text/html; charset="UTF-8"', - 'Content-Transfer-Encoding: quoted-printable', - '', - htmlQP, - '', - `--${boundary}`, - 'Content-Type: image/png', - 'Content-Transfer-Encoding: base64', - 'Content-ID: ', - 'Content-Disposition: inline; filename="qr.png"', - '', - qrLines, - '', - `--${boundary}--`, - '' - ]; - - const rawMessage = rawParts.join(nl); - const raw = Buffer.from(rawMessage).toString('base64url'); - - await gmail.users.messages.send({ - userId: 'me', - requestBody: { raw } - }); -} diff --git a/src/lib/google/gmail/server.ts b/src/lib/google/gmail/server.ts new file mode 100644 index 0000000..c6838ca --- /dev/null +++ b/src/lib/google/gmail/server.ts @@ -0,0 +1,88 @@ +import { google } from 'googleapis'; +import quotedPrintable from 'quoted-printable'; +import { getAuthenticatedClient } from '../auth/server.js'; + +/** + * Create an HTML email template + * @param text - Email body text + * @returns HTML email template + */ +export function createEmailTemplate(text: string): string { + return ` + + + + + +
+

${text}

+ QR Code +
+ +`; +} + +/** + * Send an email through Gmail + * @param refreshToken - Google refresh token + * @param params - Email parameters (to, subject, text, qr_code) + */ +export async function sendGmail( + refreshToken: string, + { + to, + subject, + text, + qr_code + }: { + to: string; + subject: string; + text: string; + qr_code: string; + } +) { + const oauth = getAuthenticatedClient(refreshToken); + const gmail = google.gmail({ version: 'v1', auth: oauth }); + + const message_html = createEmailTemplate(text); + const boundary = 'BOUNDARY'; + const nl = '\r\n'; + + const htmlBuffer = Buffer.from(message_html, 'utf8'); + const htmlLatin1 = htmlBuffer.toString('latin1'); + const htmlQP = quotedPrintable.encode(htmlLatin1); + const qrLines = qr_code.replace(/.{1,76}/g, '$&' + nl); + + const rawParts = [ + 'MIME-Version: 1.0', + `To: ${to}`, + `Subject: ${subject}`, + `Content-Type: multipart/related; boundary="${boundary}"`, + '--' + boundary, + 'Content-Type: text/html; charset="UTF-8"', + 'Content-Transfer-Encoding: quoted-printable', + '', + htmlQP, + '', + '--' + boundary, + 'Content-Type: image/png', + 'Content-Transfer-Encoding: base64', + 'Content-ID: ', + 'Content-Disposition: inline; filename="qr.png"', + '', + qrLines, + '', + '--' + boundary + '--', + '' + ]; + + const rawMessage = rawParts.join(nl); + const raw = Buffer.from(rawMessage).toString('base64url'); + + await gmail.users.messages.send({ + userId: 'me', + requestBody: { raw } + }); +} diff --git a/src/lib/google/index.ts b/src/lib/google/index.ts deleted file mode 100644 index eb972b5..0000000 --- a/src/lib/google/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Google API integration module - * - * This module provides utilities for interacting with Google APIs. - * NOTE: This is a client-side module. For server-side code, import from '$lib/google/server.js' - */ - -// Re-export client-side auth utilities -export * from './auth/client.js'; diff --git a/src/lib/google/server.ts b/src/lib/google/server.ts index 2ed029e..55e1b66 100644 --- a/src/lib/google/server.ts +++ b/src/lib/google/server.ts @@ -1,14 +1,15 @@ /** - * Server-side Google API integration module + * Google API integration module * - * This module provides utilities for interacting with Google APIs from the server-side. + * This module provides utilities for interacting with Google APIs: + * - Authentication (server and client-side) + * - Sheets API + * - Gmail API */ -// Re-export server-side auth utilities -export * from './auth/server.js'; +// Google service modules +export * as googleAuthServer from './auth/server.ts'; -// Re-export sheets utilities -export * from './sheets/index.js'; +export * as googleSheetsServer from './sheets/server.ts'; -// Re-export Gmail utilities -export * from './gmail/index.js'; +export * as googleGmailServer from './gmail/server.ts'; diff --git a/src/lib/google/sheets/client.ts b/src/lib/google/sheets/client.ts new file mode 100644 index 0000000..21c74b5 --- /dev/null +++ b/src/lib/google/sheets/client.ts @@ -0,0 +1,23 @@ +// Client-side Sheets functions (use fetch to call protected API endpoints) + +/** + * Fetch recent spreadsheets via protected endpoint + */ +export async function getRecentSpreadsheetsClient(refreshToken: string, limit: number = 10) { + const response = await fetch(`/private/api/google/sheets/recent?limit=${limit}`, { + headers: { Authorization: `Bearer ${refreshToken}` } + }); + if (!response.ok) throw new Error('Failed to fetch recent sheets'); + return await response.json(); +} + +/** + * Fetch spreadsheet data via protected endpoint + */ +export async function getSpreadsheetDataClient(refreshToken: string, sheetId: string, range: string = 'A1:Z10') { + const response = await fetch(`/private/api/google/sheets/${sheetId}/data?range=${encodeURIComponent(range)}`, { + headers: { Authorization: `Bearer ${refreshToken}` } + }); + if (!response.ok) throw new Error('Failed to fetch spreadsheet data'); + return await response.json(); +} diff --git a/src/lib/google/sheets/index.ts b/src/lib/google/sheets/index.ts deleted file mode 100644 index 8eaf173..0000000 --- a/src/lib/google/sheets/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { google } from 'googleapis'; -import { getAuthenticatedClient } from '../auth/server.js'; - -export interface GoogleSheet { - id: string; - name: string; - modifiedTime: string; - webViewLink: string; -} - -export interface SheetData { - values: string[][]; -} - -/** - * Get a list of recent Google Sheets - * @param refreshToken - Google refresh token - * @param limit - Maximum number of sheets to return - * @returns List of Google Sheets - */ -export async function getRecentSpreadsheets(refreshToken: string, limit: number = 10): Promise { - const oauth = getAuthenticatedClient(refreshToken); - const drive = google.drive({ version: 'v3', auth: oauth }); - - const response = await drive.files.list({ - q: "mimeType='application/vnd.google-apps.spreadsheet'", - orderBy: 'modifiedTime desc', - pageSize: limit, - fields: 'files(id,name,modifiedTime,webViewLink)' - }); - - return response.data.files?.map(file => ({ - id: file.id!, - name: file.name!, - modifiedTime: file.modifiedTime!, - webViewLink: file.webViewLink! - })) || []; -} - -/** - * Get data from a Google Sheet - * @param refreshToken - Google refresh token - * @param spreadsheetId - ID of the spreadsheet - * @param range - Cell range to retrieve (default: A1:Z10) - * @returns Sheet data as a 2D array - */ -export async function getSpreadsheetData(refreshToken: string, spreadsheetId: string, range: string = 'A1:Z10'): Promise { - const oauth = getAuthenticatedClient(refreshToken); - const sheets = google.sheets({ version: 'v4', auth: oauth }); - - const response = await sheets.spreadsheets.values.get({ - spreadsheetId, - range - }); - - return { - values: response.data.values || [] - }; -} - -/** - * Get metadata about a Google Sheet - * @param refreshToken - Google refresh token - * @param spreadsheetId - ID of the spreadsheet - * @returns Spreadsheet metadata - */ -export async function getSpreadsheetInfo(refreshToken: string, spreadsheetId: string) { - const oauth = getAuthenticatedClient(refreshToken); - const sheets = google.sheets({ version: 'v4', auth: oauth }); - - const response = await sheets.spreadsheets.get({ - spreadsheetId, - fields: 'properties.title,sheets.properties(title,sheetId)' - }); - - return response.data; -} diff --git a/src/lib/google/sheets/server.ts b/src/lib/google/sheets/server.ts new file mode 100644 index 0000000..57a7268 --- /dev/null +++ b/src/lib/google/sheets/server.ts @@ -0,0 +1,89 @@ +import { google } from 'googleapis'; +import { getAuthenticatedClient } from '../auth/server.js'; + +export interface GoogleSheet { + id: string; + name: string; + modifiedTime: string; + webViewLink: string; +} + +export interface SheetData { + values: string[][]; +} + +/** + * Get a list of recent Google Sheets + * @param refreshToken - Google refresh token + * @param limit - Maximum number of sheets to return + * @returns List of Google Sheets + */ +export async function getRecentSpreadsheets( + refreshToken: string, + limit: number = 10 +): Promise { + const oauth = getAuthenticatedClient(refreshToken); + const drive = google.drive({ version: 'v3', auth: oauth }); + + const response = await drive.files.list({ + q: "mimeType='application/vnd.google-apps.spreadsheet'", + orderBy: 'modifiedTime desc', + pageSize: limit, + fields: 'files(id,name,modifiedTime,webViewLink)' + }); + + return ( + response.data.files?.map(file => ({ + id: file.id!, // eslint-disable-line @typescript-eslint/no-non-null-assertion + name: file.name!, + modifiedTime: file.modifiedTime!, + webViewLink: file.webViewLink! + })) || [] + ); +} + +/** + * Get data from a Google Sheet + * @param refreshToken - Google refresh token + * @param spreadsheetId - ID of the spreadsheet + * @param range - Cell range to retrieve (default: A1:Z10) + * @returns Sheet data as a 2D array + */ +export async function getSpreadsheetData( + refreshToken: string, + spreadsheetId: string, + range: string = 'A1:Z10' +): Promise { + const oauth = getAuthenticatedClient(refreshToken); + const sheets = google.sheets({ version: 'v4', auth: oauth }); + + const response = await sheets.spreadsheets.values.get({ + spreadsheetId, + range + }); + + return { + values: response.data.values || [] + }; +} + +/** + * Get metadata about a Google Sheet + * @param refreshToken - Google refresh token + * @param spreadsheetId - ID of the spreadsheet + * @returns Spreadsheet metadata + */ +export async function getSpreadsheetInfo( + refreshToken: string, + spreadsheetId: string +) { + const oauth = getAuthenticatedClient(refreshToken); + const sheets = google.sheets({ version: 'v4', auth: oauth }); + + const response = await sheets.spreadsheets.get({ + spreadsheetId, + fields: 'properties.title,sheets.properties(title,sheetId)' + }); + + return response.data; +} diff --git a/src/lib/index.ts b/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/src/lib/sheets.ts b/src/lib/sheets.ts deleted file mode 100644 index b8dcc4c..0000000 --- a/src/lib/sheets.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { google } from 'googleapis'; -import { getAuthenticatedClient } from './google-server.js'; - -export interface GoogleSheet { - id: string; - name: string; - modifiedTime: string; - webViewLink: string; -} - -export interface SheetData { - values: string[][]; -} - -export async function getRecentSpreadsheets(refreshToken: string, limit: number = 10): Promise { - const oauth = getAuthenticatedClient(refreshToken); - const drive = google.drive({ version: 'v3', auth: oauth }); - - const response = await drive.files.list({ - q: "mimeType='application/vnd.google-apps.spreadsheet'", - orderBy: 'modifiedTime desc', - pageSize: limit, - fields: 'files(id,name,modifiedTime,webViewLink)' - }); - - return response.data.files?.map(file => ({ - id: file.id!, - name: file.name!, - modifiedTime: file.modifiedTime!, - webViewLink: file.webViewLink! - })) || []; -} - -export async function getSpreadsheetData(refreshToken: string, spreadsheetId: string, range: string = 'A1:Z10'): Promise { - const oauth = getAuthenticatedClient(refreshToken); - const sheets = google.sheets({ version: 'v4', auth: oauth }); - - const response = await sheets.spreadsheets.values.get({ - spreadsheetId, - range - }); - - return { - values: response.data.values || [] - }; -} - -export async function getSpreadsheetInfo(refreshToken: string, spreadsheetId: string) { - const oauth = getAuthenticatedClient(refreshToken); - const sheets = google.sheets({ version: 'v4', auth: oauth }); - - const response = await sheets.spreadsheets.get({ - spreadsheetId, - fields: 'properties.title,sheets.properties(title,sheetId)' - }); - - return response.data; -} diff --git a/src/lib/types.ts b/src/lib/types/types.ts similarity index 100% rename from src/lib/types.ts rename to src/lib/types/types.ts diff --git a/src/routes/api/auth/refresh/+server.ts b/src/routes/api/auth/refresh/+server.ts index 83eecbb..cfbd733 100644 --- a/src/routes/api/auth/refresh/+server.ts +++ b/src/routes/api/auth/refresh/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { authServer } from '$lib/google/index.js'; +import { getOAuthClient } from '$lib/google/auth/server.js'; export const POST: RequestHandler = async ({ request }) => { try { @@ -10,7 +10,7 @@ export const POST: RequestHandler = async ({ request }) => { return json({ error: 'Refresh token is required' }, { status: 400 }); } - const oauth = authServer.getOAuthClient(); + const oauth = getOAuthClient(); oauth.setCredentials({ refresh_token: refreshToken }); const { credentials } = await oauth.refreshAccessToken(); diff --git a/src/routes/api/events/+server.ts b/src/routes/api/events/+server.ts index a352d1f..e69de29 100644 --- a/src/routes/api/events/+server.ts +++ b/src/routes/api/events/+server.ts @@ -1,21 +0,0 @@ -import { json } from '@sveltejs/kit'; -import type { RequestHandler } from './$types'; - -export const GET: RequestHandler = async ({ locals }) => { - try { - const { data: events, error } = await locals.supabase - .from('events') - .select('*') - .order('date', { ascending: false }); - - if (error) { - console.error('Error fetching events:', error); - return json({ error: error.message }, { status: 500 }); - } - - return json({ events }); - } catch (err) { - console.error('Error in events API:', err); - return json({ error: 'Internal server error' }, { status: 500 }); - } -}; diff --git a/src/routes/auth/google/+server.ts b/src/routes/auth/google/+server.ts index 49205f7..71ea732 100644 --- a/src/routes/auth/google/+server.ts +++ b/src/routes/auth/google/+server.ts @@ -1,8 +1,8 @@ import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { authServer } from '$lib/google/index.js'; +import { createAuthUrl } from '$lib/google/auth/server.js'; export const GET: RequestHandler = () => { - const authUrl = authServer.createAuthUrl(); + const authUrl = createAuthUrl(); throw redirect(302, authUrl); }; diff --git a/src/routes/auth/google/callback/+server.ts b/src/routes/auth/google/callback/+server.ts index d369a58..584aa48 100644 --- a/src/routes/auth/google/callback/+server.ts +++ b/src/routes/auth/google/callback/+server.ts @@ -1,6 +1,6 @@ import { redirect } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { authServer } from '$lib/google/index.js'; +import { googleAuthServer } from '$lib/google/server.ts'; export const GET: RequestHandler = async ({ url }) => { try { @@ -17,7 +17,7 @@ export const GET: RequestHandler = async ({ url }) => { } // Exchange code for tokens - const oauth = authServer.getOAuthClient(); + const oauth = googleAuthServer.getOAuthClient(); const { tokens } = await oauth.getToken(code); if (!tokens.refresh_token || !tokens.access_token) { diff --git a/src/routes/private/api/google/auth/refresh/+server.ts b/src/routes/private/api/google/auth/refresh/+server.ts index 5190a36..ca057c8 100644 --- a/src/routes/private/api/google/auth/refresh/+server.ts +++ b/src/routes/private/api/google/auth/refresh/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getOAuthClient } from '$lib/google/server.js'; +import { googleAuthServer } from '$lib/google/server.ts'; export const POST: RequestHandler = async ({ request }) => { try { @@ -10,7 +10,7 @@ export const POST: RequestHandler = async ({ request }) => { return json({ error: 'Refresh token is required' }, { status: 400 }); } - const oauth = getOAuthClient(); + const oauth = googleAuthServer.getOAuthClient(); oauth.setCredentials({ refresh_token: refreshToken }); const { credentials } = await oauth.refreshAccessToken(); diff --git a/src/routes/private/api/google/auth/userinfo/+server.ts b/src/routes/private/api/google/auth/userinfo/+server.ts index 3c978d7..2e44e75 100644 --- a/src/routes/private/api/google/auth/userinfo/+server.ts +++ b/src/routes/private/api/google/auth/userinfo/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getOAuthClient } from '$lib/google/server.js'; +import { googleAuthServer } from '$lib/google/server.ts'; import { google } from 'googleapis'; export const GET: RequestHandler = async ({ request }) => { @@ -14,7 +14,7 @@ export const GET: RequestHandler = async ({ request }) => { const accessToken = authHeader.slice(7); // Create OAuth client with the token - const oauth = getOAuthClient(); + const oauth = googleAuthServer.getOAuthClient(); oauth.setCredentials({ access_token: accessToken }); // Call the userinfo endpoint to get user details diff --git a/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts b/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts index 3126261..e87c1ff 100644 --- a/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts +++ b/src/routes/private/api/google/sheets/[sheetId]/data/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getSpreadsheetData } from '$lib/google/server.js'; +import { sheets } from '$lib/google/index.js'; export const GET: RequestHandler = async ({ params, request }) => { try { @@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ params, request }) => { } const refreshToken = authHeader.slice(7); - const sheetData = await getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); + const sheetData = await sheets.getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); return json(sheetData); } catch (error) { diff --git a/src/routes/private/api/google/sheets/recent/+server.ts b/src/routes/private/api/google/sheets/recent/+server.ts index d39f4e8..ed01813 100644 --- a/src/routes/private/api/google/sheets/recent/+server.ts +++ b/src/routes/private/api/google/sheets/recent/+server.ts @@ -1,6 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { getRecentSpreadsheets } from '$lib/google/server.js'; +import { sheets } from '$lib/google/index.js'; export const GET: RequestHandler = async ({ request }) => { try { @@ -10,7 +10,7 @@ export const GET: RequestHandler = async ({ request }) => { } const refreshToken = authHeader.slice(7); - const spreadsheets = await getRecentSpreadsheets(refreshToken, 20); + const spreadsheets = await sheets.getRecentSpreadsheets(refreshToken, 20); return json(spreadsheets); } catch (error) { diff --git a/src/routes/private/events/+page.server.ts b/src/routes/private/events/+page.server.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/private/events/+page.svelte b/src/routes/private/events/+page.svelte index ad261e2..1664850 100644 --- a/src/routes/private/events/+page.svelte +++ b/src/routes/private/events/+page.svelte @@ -1,85 +1,24 @@

All Events

- {#if loading} - - {#each Array(4) as _} - New Event diff --git a/src/routes/private/events/event/new/+page.svelte b/src/routes/private/events/event/new/+page.svelte index b130cf8..e2177ff 100644 --- a/src/routes/private/events/event/new/+page.svelte +++ b/src/routes/private/events/event/new/+page.svelte @@ -1,7 +1,7 @@ diff --git a/src/routes/private/events/event/new/+page.svelte b/src/routes/private/events/event/new/+page.svelte index e2177ff..e0eb92d 100644 --- a/src/routes/private/events/event/new/+page.svelte +++ b/src/routes/private/events/event/new/+page.svelte @@ -1,7 +1,7 @@
@@ -69,9 +135,9 @@
{:else} - +
-

Available Sheets

+

Google Sheets

{#if sheetsData.selectedSheet}
-
- {#each sheetsData.availableSheets as sheet} + + +
+ +
+ + + +
+ {#if searchQuery} - {/each} + {/if}
+ + {#if isSearching} + +
+ {#each Array(3) as _} +
+
+
+
+ {/each} +
+ {:else if searchQuery && searchResults.length === 0 && !searchError} + +
+

No sheets found matching "{searchQuery}"

+
+ {:else if searchError} + +
+

{searchError}

+ +
+ {:else if searchQuery && searchResults.length > 0} + +
+ {#each searchResults as sheet} + + {/each} +
+ {:else} + +
+ {#each sheetsData.availableSheets as sheet} + + {/each} +
+ {#if sheetsData.availableSheets.length === 0 && !sheetsData.loading} +
+

No recent sheets found. Try searching above.

+
+ {/if} + {/if} {/if}
{/if} @@ -134,7 +290,7 @@ aria-label={`Select data type for column ${index + 1}`} onclick={(e) => e.stopPropagation()} onchange={(e) => { - const value = e.target.value; + const value = (e.target as HTMLSelectElement).value; if (value === "none") return; // Reset previous selection if this column was already mapped @@ -187,6 +343,7 @@ sheetsData.columnMapping.confirmation === cellIndex + 1 ? 'font-medium text-amber-700' : 'text-gray-700' } + title={cell || ''} > {cell || ''} @@ -197,7 +354,17 @@
-

Showing first 10 rows

+
+

Showing first 10 rows

+ {#if sheetsData.sheetData[0] && sheetsData.sheetData[0].length > 3} +

+ + + + Scroll horizontally to see all {sheetsData.sheetData[0].length} columns +

+ {/if} +
{/if} @@ -211,3 +378,5 @@

{errors.sheetData}

{/if}
+ + -- 2.49.1 From 39bd1727986ef147bf7f13df6cf0e4a40e4d0b9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Tue, 8 Jul 2025 13:24:17 +0200 Subject: [PATCH 14/20] Fixed warnings from svelte about mutability --- src/routes/private/events/+page.svelte | 2 +- src/routes/private/events/event/new/+page.svelte | 8 ++++---- .../events/event/new/components/EmailSettingsStep.svelte | 2 +- .../events/event/new/components/EventDetailsStep.svelte | 2 +- .../events/event/new/components/GoogleSheetsStep.svelte | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/routes/private/events/+page.svelte b/src/routes/private/events/+page.svelte index 1664850..01b0edc 100644 --- a/src/routes/private/events/+page.svelte +++ b/src/routes/private/events/+page.svelte @@ -18,7 +18,7 @@ New Event diff --git a/src/routes/private/events/event/new/+page.svelte b/src/routes/private/events/event/new/+page.svelte index e0eb92d..5785dc6 100644 --- a/src/routes/private/events/event/new/+page.svelte +++ b/src/routes/private/events/event/new/+page.svelte @@ -417,13 +417,13 @@
{#if currentStep === 0} - + {:else if currentStep === 1} - + {:else if currentStep === 2} - + {:else if currentStep === 3} - + {/if} {#if errors.submit} diff --git a/src/routes/private/events/event/new/components/EmailSettingsStep.svelte b/src/routes/private/events/event/new/components/EmailSettingsStep.svelte index 560d9a5..3daf494 100644 --- a/src/routes/private/events/event/new/components/EmailSettingsStep.svelte +++ b/src/routes/private/events/event/new/components/EmailSettingsStep.svelte @@ -1,5 +1,5 @@ + + +
+

Event Overview

+
+ + +
+ {#if loading} + +
+
+
+
+
+
+ {:else if event} +
+ {:else if error} +
+

{error}

+
+ {/if} +
+ + +
+
+

Google Account

+

Required for syncing participants and sending emails

+
+ { + // Refresh the page or update UI state as needed + error = ''; + }} + onError={(errorMsg) => { + error = errorMsg; + }} + /> +
+ + +
+
+

Participants

+ +
+ + {#if participantsLoading} + +
+ {#each Array(5) as _} +
+
+
+
+
+
+
+ {/each} +
+ {:else if participants.length > 0} +
+ + + + + + + + + + + + {#each participants as participant} + + + + + + + + {/each} + +
NameSurnameEmailScannedEmail Sent
{participant.name}{participant.surname}{participant.email} + {#if participant.scanned} +
+ + + +
+ {:else} +
+ + + +
+ {/if} +
+ {#if participant.email_sent} +
+ + + +
+ {:else} +
+ + + +
+ {/if} +
+
+ {:else} +
+

+ No participants found. Click "Sync Participants" to load from Google Sheets. +

+
+ {/if} +
+ + +
+
+

Send Emails

+
+ {participants.filter(p => !p.email_sent).length} uncontacted participants +
+
+ + {#if sendingEmails} +
+
+ + + + + Sending {emailProgress.total} emails... Please wait. +
+
+ {:else} +
+ {#if participants.filter(p => !p.email_sent).length > 0} +
+
+ + + + + Warning: Do not close this window while emails are being sent. The process may take several minutes. + +
+
+ +
+ +
+ {:else} +
+
+ + + +
+

All participants have been contacted!

+

No pending emails to send.

+
+ {/if} +
+ {/if} +
+ + +{#if emailResults} +
+
+

Email Results

+
+ {emailResults.summary.success} successful, {emailResults.summary.errors} failed +
+
+ +
+
+
+
+ Sent: {emailResults.summary.success} +
+
+
+ Failed: {emailResults.summary.errors} +
+
+
+ Total: {emailResults.summary.total} +
+
+
+ + {#if emailResults.results.length > 0} +
+ + + + + + + + + + {#each emailResults.results as result} + + + + + + {/each} + +
NameEmailStatus
+ {result.participant.name} {result.participant.surname} + {result.participant.email} + {#if result.success} +
+ + + + Sent +
+ {:else} +
+ + + + Failed + {#if result.error} + ({result.error}) + {/if} +
+ {/if} +
+
+ {/if} +
+{/if} + +{#if error} +
+

{error}

+
+{/if} diff --git a/src/routes/private/events/event/view/components/GoogleAuthButton.svelte b/src/routes/private/events/event/view/components/GoogleAuthButton.svelte new file mode 100644 index 0000000..633a46c --- /dev/null +++ b/src/routes/private/events/event/view/components/GoogleAuthButton.svelte @@ -0,0 +1,117 @@ + + +{#if authState.isConnected} +
+
+ + + + Connected +
+ + {#if authState.userEmail} +
+ + + + {authState.userEmail} +
+ {/if} + + +
+{:else} +
+ + + {#if authState.error} +
+ {authState.error} +
+ {/if} +
+{/if} -- 2.49.1 From 6f563bbf7e078b1a12eb6d1bacd6fa8c32d2428b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Tue, 8 Jul 2025 15:56:01 +0200 Subject: [PATCH 16/20] All event overview improvements --- src/routes/private/events/+page.svelte | 237 ++++++++++++++++-- src/routes/private/events/SingleEvent.svelte | 2 +- .../private/events/archived/+page.svelte | 77 ------ .../events/event/archived/+page.svelte | 88 +++++++ 4 files changed, 308 insertions(+), 96 deletions(-) delete mode 100644 src/routes/private/events/archived/+page.svelte create mode 100644 src/routes/private/events/event/archived/+page.svelte diff --git a/src/routes/private/events/+page.svelte b/src/routes/private/events/+page.svelte index 01b0edc..a4002fa 100644 --- a/src/routes/private/events/+page.svelte +++ b/src/routes/private/events/+page.svelte @@ -1,25 +1,226 @@

All Events

+
- {#each data.events as event} - -
- {event.name} - {event.date} -
-
- {/each} + {#if loading} + + {#each Array(4) as _} +
+
+
+
+
+
+ {/each} + {:else if error} +
+

{error}

+ +
+ {:else if displayEvents.length === 0} +
+

No events found. Create your first event!

+
+ {:else} + {#each displayEvents as event} + + {/each} + {/if}
- - New Event - \ No newline at end of file + +
+ +
+ +
+ {#if isSearching} + + + + + {:else} + + + + {/if} +
+ {#if searchTerm} + + {/if} +
+ + + + New Event + +
\ No newline at end of file diff --git a/src/routes/private/events/SingleEvent.svelte b/src/routes/private/events/SingleEvent.svelte index ef08e73..5887730 100644 --- a/src/routes/private/events/SingleEvent.svelte +++ b/src/routes/private/events/SingleEvent.svelte @@ -3,7 +3,7 @@ diff --git a/src/routes/private/events/archived/+page.svelte b/src/routes/private/events/archived/+page.svelte deleted file mode 100644 index 2df8b53..0000000 --- a/src/routes/private/events/archived/+page.svelte +++ /dev/null @@ -1,77 +0,0 @@ - - -

Archived Event Overview

- -
-
- {#if loading} -
-
- {:else} - {event_data?.name} - {event_data?.date} - {/if} -
-
- -
-
- - - - {#if loading} -
- {:else} - Total participants ({event_data?.total_participants}) - {/if} -
-
-
- - - - {#if loading} -
- {:else} - Scanned participants ({event_data?.scanned_participants}) - {/if} -
-
diff --git a/src/routes/private/events/event/archived/+page.svelte b/src/routes/private/events/event/archived/+page.svelte new file mode 100644 index 0000000..37cdb56 --- /dev/null +++ b/src/routes/private/events/event/archived/+page.svelte @@ -0,0 +1,88 @@ + + +

Archived Event Overview

+ +
+
+ {#if loading} +
+
+ {:else} + {event_data?.name} + {event_data?.date} + {/if} +
+
+ +
+
+
+ + + + {#if loading} +
+ {:else} + Total participants ({event_data?.total_participants}) + {/if} +
+ +
+ + + + {#if loading} +
+ {:else} + Scanned participants ({event_data?.scanned_participants}) + {/if} +
+
+
-- 2.49.1 From af22543ec84780ffca002c249ffe4212facb4d58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Tue, 8 Jul 2025 16:35:27 +0200 Subject: [PATCH 17/20] Fix QR code generation, new scanner styling and ability to choose events. --- src/lib/types/types.ts | 6 +- src/routes/private/api/gmail/+server.ts | 13 +- .../private/events/event/view/+page.svelte | 2 - src/routes/private/scanner/+page.svelte | 141 ++++++++++++++++-- src/routes/private/scanner/QRScanner.svelte | 2 +- .../private/scanner/TicketDisplay.svelte | 79 ++++++---- 6 files changed, 191 insertions(+), 52 deletions(-) diff --git a/src/lib/types/types.ts b/src/lib/types/types.ts index 36fb4ed..c93af06 100644 --- a/src/lib/types/types.ts +++ b/src/lib/types/types.ts @@ -1,7 +1,9 @@ export enum ScanState { scanning, scan_successful, - scan_failed + already_scanned, + scan_failed, + wrong_event } export type TicketData = { @@ -22,7 +24,7 @@ export const defaultTicketData: TicketData = { name: '', surname: '', email: '', - event: '', + event: { id: '', name: '' }, created_at: new Date().toISOString(), created_by: null, scanned: false, diff --git a/src/routes/private/api/gmail/+server.ts b/src/routes/private/api/gmail/+server.ts index c34fec7..eea6c9b 100644 --- a/src/routes/private/api/gmail/+server.ts +++ b/src/routes/private/api/gmail/+server.ts @@ -16,15 +16,8 @@ interface EmailResult { error?: string; } -async function generateQRCode(participant: Participant, eventId: string): Promise { - const qrCodeData = JSON.stringify({ - participantId: participant.id, - eventId: eventId, - name: participant.name, - surname: participant.surname - }); - - const qrCodeBase64 = await QRCode.toDataURL(qrCodeData, { +async function generateQRCode(participantId: string): Promise { + const qrCodeBase64 = await QRCode.toDataURL(participantId, { type: 'image/png', margin: 2, scale: 8 @@ -43,7 +36,7 @@ async function sendEmailToParticipant( supabase: any ): Promise { try { - const qrCodeBase64Data = await generateQRCode(participant, eventId); + const qrCodeBase64Data = await generateQRCode(participant.id); // Send email with QR code await sendGmail(refreshToken, { diff --git a/src/routes/private/events/event/view/+page.svelte b/src/routes/private/events/event/view/+page.svelte index 4a96ac9..97ce1e3 100644 --- a/src/routes/private/events/event/view/+page.svelte +++ b/src/routes/private/events/event/view/+page.svelte @@ -162,8 +162,6 @@ p_emails: emails }); - console.log(syncError); - if (syncError) throw syncError; // Reload participants diff --git a/src/routes/private/scanner/+page.svelte b/src/routes/private/scanner/+page.svelte index 0a098ca..8220dab 100644 --- a/src/routes/private/scanner/+page.svelte +++ b/src/routes/private/scanner/+page.svelte @@ -1,6 +1,7 @@ - +
+

Code Scanner

- + +
+

Select Event

+ {#if isLoadingEvents} +
+
+
+ {:else if eventsError} +
+ {eventsError} + +
+ {:else if events.length === 0} +

No events found

+ {:else} + + {/if} +
+ + +
+ +
+ + +

Ticket Information

+ + + + {#if scan_state !== ScanState.scanning} +
+ +
+ {/if} +
diff --git a/src/routes/private/scanner/QRScanner.svelte b/src/routes/private/scanner/QRScanner.svelte index d8a520f..fe8cd90 100644 --- a/src/routes/private/scanner/QRScanner.svelte +++ b/src/routes/private/scanner/QRScanner.svelte @@ -45,7 +45,7 @@ }); -
+