From e6c549018ea9014a0007aabf1142a6a78592cbb9 Mon Sep 17 00:00:00 2001 From: austin616 Date: Fri, 20 Feb 2026 17:40:45 -0600 Subject: [PATCH 1/3] feat: supabase migrations added --- supabase/.gitignore | 8 + supabase/config.toml | 308 +++ .../20260220232436_remote_schema.sql | 1757 +++++++++++++++++ .../20260220234500_add_user_status.sql | 10 + 4 files changed, 2083 insertions(+) create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/20260220232436_remote_schema.sql create mode 100644 supabase/migrations/20260220234500_add_user_status.sql 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..7d1d8f1 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,308 @@ +# 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 = "UT-Marketplace" + +[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 = 15 + +[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] +# 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 = "https://utmarketplace.vercel.app/" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://utmarketplace.vercel.app/auth/callback", "https://utmarketplace.vercel.app/auth/email-confirmed"] +# 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 + +# 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 = true +# 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 = "1m0s" +# 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 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 = true +verify_enabled = true + +# 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 + +# 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/20260220232436_remote_schema.sql b/supabase/migrations/20260220232436_remote_schema.sql new file mode 100644 index 0000000..edb4086 --- /dev/null +++ b/supabase/migrations/20260220232436_remote_schema.sql @@ -0,0 +1,1757 @@ + + +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; + + +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 "supabase_vault" WITH SCHEMA "vault"; + + + + + + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions"; + + + + + + +CREATE TYPE "public"."favorite_type" AS ENUM ( + 'favorite', + 'watchlist' +); + + +ALTER TYPE "public"."favorite_type" OWNER TO "postgres"; + + +CREATE TYPE "public"."listing_category" AS ENUM ( + 'electronics', + 'furniture', + 'books', + 'clothing', + 'vehicles', + 'sports', + 'other', + 'tech', + 'textbooks', + 'subleases', + 'kitchen' +); + + +ALTER TYPE "public"."listing_category" OWNER TO "postgres"; + + +CREATE TYPE "public"."listing_condition" AS ENUM ( + 'new', + 'like_new', + 'good', + 'fair', + 'poor' +); + + +ALTER TYPE "public"."listing_condition" OWNER TO "postgres"; + + +CREATE TYPE "public"."notification_type" AS ENUM ( + 'message', + 'favorite', + 'watchlist', + 'listing_sold', + 'listing_inquiry', + 'rating', + 'system' +); + + +ALTER TYPE "public"."notification_type" OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."cleanup_old_notifications"() RETURNS integer + LANGUAGE "sql" SECURITY DEFINER + AS $$ + WITH deleted AS ( + DELETE FROM user_notifications + WHERE created_at < NOW() - INTERVAL '30 days' + RETURNING id + ) + SELECT COUNT(*)::INTEGER FROM deleted; +$$; + + +ALTER FUNCTION "public"."cleanup_old_notifications"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."create_notification"("p_user_id" "uuid", "p_type" "public"."notification_type", "p_title" "text", "p_message" "text", "p_actor_id" "uuid" DEFAULT NULL::"uuid", "p_data" "jsonb" DEFAULT '{}'::"jsonb", "p_listing_id" "uuid" DEFAULT NULL::"uuid") RETURNS "uuid" + LANGUAGE "plpgsql" + AS $$ +DECLARE + notification_id UUID; +BEGIN + INSERT INTO notifications (user_id, actor_id, type, title, message, data, listing_id) + VALUES (p_user_id, p_actor_id, p_type, p_title, p_message, p_data, p_listing_id) + RETURNING id INTO notification_id; + + RETURN notification_id; +END; +$$; + + +ALTER FUNCTION "public"."create_notification"("p_user_id" "uuid", "p_type" "public"."notification_type", "p_title" "text", "p_message" "text", "p_actor_id" "uuid", "p_data" "jsonb", "p_listing_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."ensure_user_profile"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + AS $$ +BEGIN + INSERT INTO public.users (id, email, display_name, created_at, updated_at) + VALUES ( + NEW.id, + NEW.email, + COALESCE( + NEW.raw_user_meta_data->>'display_name', + split_part(NEW.email, '@', 1) -- Use email username as default + ), + NOW(), + NOW() + ) + ON CONFLICT (id) DO UPDATE SET + email = NEW.email, + display_name = COALESCE( + NEW.raw_user_meta_data->>'display_name', + split_part(NEW.email, '@', 1), + users.display_name + ), + updated_at = NOW(); + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."ensure_user_profile"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_listing_engagement_stats"("p_listing_id" "uuid") RETURNS TABLE("favorite_count" bigint, "watchlist_count" bigint) + LANGUAGE "plpgsql" + AS $$ +BEGIN + RETURN QUERY + SELECT + COUNT(*) FILTER (WHERE type = 'favorite') as favorite_count, + COUNT(*) FILTER (WHERE type = 'watchlist') as watchlist_count + FROM user_favorites + WHERE listing_id = p_listing_id; +END; +$$; + + +ALTER FUNCTION "public"."get_listing_engagement_stats"("p_listing_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_or_create_conversation"("p_user1_id" "uuid", "p_user2_id" "uuid") RETURNS "uuid" + LANGUAGE "plpgsql" + AS $$ +BEGIN + RETURN get_or_create_conversation(p_user1_id, p_user2_id, NULL); +END; +$$; + + +ALTER FUNCTION "public"."get_or_create_conversation"("p_user1_id" "uuid", "p_user2_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_or_create_conversation"("p_user1_id" "uuid", "p_user2_id" "uuid", "p_listing_id" "uuid") RETURNS "uuid" + LANGUAGE "plpgsql" + AS $$ +DECLARE + conversation_id UUID; + participant1_id UUID; + participant2_id UUID; +BEGIN + -- Ensure consistent ordering of participants + IF p_user1_id < p_user2_id THEN + participant1_id := p_user1_id; + participant2_id := p_user2_id; + ELSE + participant1_id := p_user2_id; + participant2_id := p_user1_id; + END IF; + + -- Try to find existing conversation + SELECT id INTO conversation_id + FROM conversations + WHERE participant_1_id = participant1_id + AND participant_2_id = participant2_id + AND (listing_id = p_listing_id OR (listing_id IS NULL AND p_listing_id IS NULL)); + + -- If not found, create new conversation + IF conversation_id IS NULL THEN + INSERT INTO conversations (participant_1_id, participant_2_id, listing_id) + VALUES (participant1_id, participant2_id, p_listing_id) + RETURNING id INTO conversation_id; + END IF; + + RETURN conversation_id; +END; +$$; + + +ALTER FUNCTION "public"."get_or_create_conversation"("p_user1_id" "uuid", "p_user2_id" "uuid", "p_listing_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_unread_notification_count"("p_user_id" "uuid") RETURNS integer + LANGUAGE "sql" SECURITY DEFINER + AS $$ + SELECT COUNT(*)::INTEGER + FROM user_notifications + WHERE user_id = p_user_id + AND is_read = FALSE; +$$; + + +ALTER FUNCTION "public"."get_unread_notification_count"("p_user_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_user_by_email"("p_email" "text") RETURNS TABLE("id" "uuid", "email" "text", "display_name" "text", "profile_image_url" "text") + LANGUAGE "plpgsql" + AS $$ +BEGIN + RETURN QUERY + SELECT u.id, u.email, u.display_name, u.profile_image_url + FROM users u + WHERE u.email = p_email; +END; +$$; + + +ALTER FUNCTION "public"."get_user_by_email"("p_email" "text") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."get_user_favorite_status"("p_user_id" "uuid", "p_listing_id" "uuid") RETURNS TABLE("is_favorited" boolean, "is_watchlisted" boolean) + LANGUAGE "plpgsql" + AS $$ +BEGIN + RETURN QUERY + SELECT + EXISTS(SELECT 1 FROM user_favorites WHERE user_id = p_user_id AND listing_id = p_listing_id AND type = 'favorite') as is_favorited, + EXISTS(SELECT 1 FROM user_favorites WHERE user_id = p_user_id AND listing_id = p_listing_id AND type = 'watchlist') as is_watchlisted; +END; +$$; + + +ALTER FUNCTION "public"."get_user_favorite_status"("p_user_id" "uuid", "p_listing_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."mark_all_notifications_read"("p_user_id" "uuid") RETURNS "void" + LANGUAGE "plpgsql" + AS $$ +BEGIN + UPDATE notifications + SET is_read = true + WHERE user_id = p_user_id AND is_read = false; +END; +$$; + + +ALTER FUNCTION "public"."mark_all_notifications_read"("p_user_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."mark_all_user_notifications_read"("p_user_id" "uuid") RETURNS "void" + LANGUAGE "sql" SECURITY DEFINER + AS $$ + UPDATE user_notifications + SET is_read = TRUE, read_at = NOW() + WHERE user_id = p_user_id + AND is_read = FALSE; +$$; + + +ALTER FUNCTION "public"."mark_all_user_notifications_read"("p_user_id" "uuid") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."sync_user_settings"() RETURNS "trigger" + LANGUAGE "plpgsql" + AS $$ +BEGIN + -- When a user is inserted/updated in users table, sync to user_settings + IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN + INSERT INTO public.user_settings (email, display_name, profile_image_url, bio, phone, push_token, created_at, updated_at) + VALUES (NEW.email, NEW.display_name, NEW.profile_image_url, NEW.bio, NEW.phone, NEW.push_token, NEW.created_at, NEW.updated_at) + ON CONFLICT (email) DO UPDATE SET + display_name = EXCLUDED.display_name, + profile_image_url = EXCLUDED.profile_image_url, + bio = EXCLUDED.bio, + phone = EXCLUDED.phone, + push_token = EXCLUDED.push_token, + updated_at = EXCLUDED.updated_at; + RETURN NEW; + END IF; + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."sync_user_settings"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."toggle_user_favorite"("p_user_id" "uuid", "p_listing_id" "uuid", "p_type" "public"."favorite_type") RETURNS boolean + LANGUAGE "plpgsql" + AS $$ +DECLARE + existing_record user_favorites%ROWTYPE; + result BOOLEAN; +BEGIN + -- Check if the record already exists + SELECT * INTO existing_record + FROM user_favorites + WHERE user_id = p_user_id + AND listing_id = p_listing_id + AND type = p_type; + + IF FOUND THEN + -- Record exists, remove it + DELETE FROM user_favorites + WHERE user_id = p_user_id + AND listing_id = p_listing_id + AND type = p_type; + result := FALSE; + ELSE + -- Record doesn't exist, add it + INSERT INTO user_favorites (user_id, listing_id, type) + VALUES (p_user_id, p_listing_id, p_type); + result := TRUE; + END IF; + + RETURN result; +END; +$$; + + +ALTER FUNCTION "public"."toggle_user_favorite"("p_user_id" "uuid", "p_listing_id" "uuid", "p_type" "public"."favorite_type") OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."update_listing_favorite_count"() RETURNS "trigger" + LANGUAGE "plpgsql" + AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE public.listings + SET favorite_count = favorite_count + 1 + WHERE id = NEW.listing_id; + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + UPDATE public.listings + SET favorite_count = favorite_count - 1 + WHERE id = OLD.listing_id; + RETURN OLD; + END IF; + RETURN NULL; +END; +$$; + + +ALTER FUNCTION "public"."update_listing_favorite_count"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."update_updated_at_column"() RETURNS "trigger" + LANGUAGE "plpgsql" + AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."update_updated_at_column"() OWNER TO "postgres"; + + +CREATE OR REPLACE FUNCTION "public"."update_user_rating"() RETURNS "trigger" + LANGUAGE "plpgsql" + AS $$ +BEGIN + UPDATE public.users + SET + rating_average = ( + SELECT COALESCE(AVG(rating), 0) + FROM public.reviews + WHERE reviewed_id = NEW.reviewed_id + ), + rating_count = ( + SELECT COUNT(*) + FROM public.reviews + WHERE reviewed_id = NEW.reviewed_id + ) + WHERE id = NEW.reviewed_id; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION "public"."update_user_rating"() OWNER TO "postgres"; + +SET default_tablespace = ''; + +SET default_table_access_method = "heap"; + + +CREATE TABLE IF NOT EXISTS "public"."conversations" ( + "id" "uuid" DEFAULT "extensions"."uuid_generate_v4"() NOT NULL, + "participant_1_id" "uuid" NOT NULL, + "participant_2_id" "uuid" NOT NULL, + "listing_id" "uuid", + "last_message_at" timestamp with time zone DEFAULT "now"(), + "created_at" timestamp with time zone DEFAULT "now"() +); + + +ALTER TABLE "public"."conversations" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."listings" ( + "id" "uuid" DEFAULT "extensions"."uuid_generate_v4"() NOT NULL, + "user_id" "uuid" NOT NULL, + "title" "text" NOT NULL, + "description" "text", + "price" numeric(10,2), + "category" "public"."listing_category" NOT NULL, + "condition" "public"."listing_condition" NOT NULL, + "images" "text"[] DEFAULT '{}'::"text"[], + "location" "text", + "location_lat" double precision, + "location_lng" double precision, + "is_sold" boolean DEFAULT false, + "is_draft" boolean DEFAULT false, + "is_featured" boolean DEFAULT false, + "view_count" integer DEFAULT 0, + "favorite_count" integer DEFAULT 0, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "status" character varying(20) DEFAULT 'pending'::character varying, + "denial_reason" "text", + CONSTRAINT "listings_status_check" CHECK ((("status")::"text" = ANY ((ARRAY['pending'::character varying, 'approved'::character varying, 'denied'::character varying])::"text"[]))) +); + + +ALTER TABLE "public"."listings" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."listings"."status" IS 'Listing approval status: pending, approved, or denied'; + + + +COMMENT ON COLUMN "public"."listings"."denial_reason" IS 'Reason for denial when status is denied'; + + + +CREATE TABLE IF NOT EXISTS "public"."users" ( + "id" "uuid" NOT NULL, + "email" "text" NOT NULL, + "display_name" "text", + "bio" "text", + "profile_image_url" "text", + "phone" "text", + "notification_preferences" "jsonb" DEFAULT '{"push_notifications": true, "email_notifications": true, "message_notifications": true, "favorite_notifications": true}'::"jsonb", + "push_token" "text", + "is_verified" boolean DEFAULT false, + "rating_average" numeric(3,2) DEFAULT 0.00, + "rating_count" integer DEFAULT 0, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"(), + "is_admin" boolean DEFAULT false NOT NULL, + "onboard_complete" boolean DEFAULT false +); + + +ALTER TABLE "public"."users" OWNER TO "postgres"; + + +CREATE OR REPLACE VIEW "public"."listing_details" AS + SELECT "l"."id", + "l"."user_id", + "l"."title", + "l"."description", + "l"."price", + "l"."category", + "l"."condition", + "l"."images", + "l"."location", + "l"."location_lat", + "l"."location_lng", + "l"."is_sold", + "l"."is_draft", + "l"."is_featured", + "l"."view_count", + "l"."favorite_count", + "l"."created_at", + "l"."updated_at", + CASE + WHEN (("u"."display_name" IS NOT NULL) AND (TRIM(BOTH FROM "u"."display_name") <> ''::"text")) THEN "u"."display_name" + ELSE "split_part"("u"."email", '@'::"text", 1) + END AS "user_name", + "u"."profile_image_url" AS "user_image", + "u"."email" AS "user_email", + "u"."rating_average", + "u"."rating_count" + FROM ("public"."listings" "l" + JOIN "public"."users" "u" ON (("l"."user_id" = "u"."id"))); + + +ALTER VIEW "public"."listing_details" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."listing_reports" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "listing_id" "uuid" NOT NULL, + "reporter_id" "uuid" NOT NULL, + "reason" character varying(50) NOT NULL, + "description" "text", + "status" character varying(20) DEFAULT 'pending'::character varying, + "created_at" timestamp without time zone DEFAULT "now"(), + "reviewed_at" timestamp without time zone, + "reviewed_by" "uuid", + "admin_notes" "text", + CONSTRAINT "listing_reports_status_check" CHECK ((("status")::"text" = ANY ((ARRAY['pending'::character varying, 'reviewed'::character varying, 'resolved'::character varying, 'dismissed'::character varying])::"text"[]))) +); + + +ALTER TABLE "public"."listing_reports" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."messages" ( + "id" "uuid" DEFAULT "extensions"."uuid_generate_v4"() NOT NULL, + "sender_id" "uuid" NOT NULL, + "receiver_id" "uuid" NOT NULL, + "listing_id" "uuid", + "content" "text" NOT NULL, + "is_read" boolean DEFAULT false, + "created_at" timestamp with time zone DEFAULT "now"(), + "encrypted_content" "text", + "nonce" "text", + "is_encrypted" boolean DEFAULT false +); + + +ALTER TABLE "public"."messages" OWNER TO "postgres"; + + +COMMENT ON COLUMN "public"."messages"."encrypted_content" IS 'Base64-encoded ciphertext (TweetNaCl box)'; + + + +COMMENT ON COLUMN "public"."messages"."nonce" IS 'Base64-encoded nonce for decryption'; + + + +COMMENT ON COLUMN "public"."messages"."is_encrypted" IS 'Flag indicating whether message uses E2E encryption'; + + + +CREATE TABLE IF NOT EXISTS "public"."reviews" ( + "id" "uuid" DEFAULT "extensions"."uuid_generate_v4"() NOT NULL, + "reviewer_id" "uuid" NOT NULL, + "reviewed_id" "uuid" NOT NULL, + "listing_id" "uuid", + "rating" integer NOT NULL, + "comment" "text", + "created_at" timestamp with time zone DEFAULT "now"(), + CONSTRAINT "reviews_rating_check" CHECK ((("rating" >= 1) AND ("rating" <= 5))) +); + + +ALTER TABLE "public"."reviews" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."terms_and_conditions" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "title" character varying(255) NOT NULL, + "content" "text" NOT NULL, + "version" integer DEFAULT 1 NOT NULL, + "last_updated" timestamp with time zone DEFAULT "now"(), + "created_at" timestamp with time zone DEFAULT "now"(), + "created_by" "uuid" +); + + +ALTER TABLE "public"."terms_and_conditions" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."user_encryption_keys" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "user_id" "uuid" NOT NULL, + "public_key" "text" NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"() +); + + +ALTER TABLE "public"."user_encryption_keys" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."user_encryption_keys" IS 'Stores public keys for end-to-end encryption. Private keys never leave user devices.'; + + + +COMMENT ON COLUMN "public"."user_encryption_keys"."public_key" IS 'Base64-encoded Curve25519 public key for message encryption'; + + + +CREATE TABLE IF NOT EXISTS "public"."user_favorites" ( + "id" "uuid" DEFAULT "extensions"."uuid_generate_v4"() NOT NULL, + "user_id" "uuid" NOT NULL, + "listing_id" "uuid" NOT NULL, + "type" "public"."favorite_type" NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() +); + + +ALTER TABLE "public"."user_favorites" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."user_keys" ( + "user_id" "uuid" NOT NULL, + "public_key" "text" NOT NULL, + "encrypted_private_key" "text" NOT NULL, + "created_at" timestamp with time zone DEFAULT "now"() NOT NULL +); + + +ALTER TABLE "public"."user_keys" OWNER TO "postgres"; + + +COMMENT ON TABLE "public"."user_keys" IS 'Contains relevant user keys.'; + + + +CREATE TABLE IF NOT EXISTS "public"."user_notifications" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "user_id" "uuid" NOT NULL, + "type" "text" NOT NULL, + "title" "text" NOT NULL, + "message" "text" NOT NULL, + "data" "jsonb" DEFAULT '{}'::"jsonb", + "actor_id" "uuid", + "actor_name" "text", + "listing_id" "uuid", + "message_id" "uuid", + "review_id" "uuid", + "is_read" boolean DEFAULT false, + "push_sent" boolean DEFAULT false, + "created_at" timestamp with time zone DEFAULT "now"(), + "read_at" timestamp with time zone, + CONSTRAINT "user_notifications_type_check" CHECK (("type" = ANY (ARRAY['message'::"text", 'review'::"text", 'listing'::"text", 'system'::"text"]))), + CONSTRAINT "user_notifications_user_id_created_at_idx" CHECK ((("user_id" IS NOT NULL) AND ("created_at" IS NOT NULL))) +); + + +ALTER TABLE "public"."user_notifications" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."user_reports" ( + "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL, + "reported_user_id" "uuid" NOT NULL, + "reporter_id" "uuid" NOT NULL, + "reason" character varying(50) NOT NULL, + "description" "text", + "status" character varying(20) DEFAULT 'pending'::character varying, + "created_at" timestamp without time zone DEFAULT "now"(), + "reviewed_at" timestamp without time zone, + "reviewed_by" "uuid", + "admin_notes" "text", + CONSTRAINT "user_reports_status_check" CHECK ((("status")::"text" = ANY ((ARRAY['pending'::character varying, 'reviewed'::character varying, 'resolved'::character varying, 'dismissed'::character varying])::"text"[]))) +); + + +ALTER TABLE "public"."user_reports" OWNER TO "postgres"; + + +CREATE TABLE IF NOT EXISTS "public"."user_settings" ( + "email" "text" NOT NULL, + "display_name" "text", + "profile_image_url" "text", + "bio" "text", + "phone" "text", + "push_token" "text", + "created_at" timestamp with time zone DEFAULT "now"(), + "updated_at" timestamp with time zone DEFAULT "now"() +); + + +ALTER TABLE "public"."user_settings" OWNER TO "postgres"; + + +ALTER TABLE ONLY "public"."conversations" + ADD CONSTRAINT "conversations_participant_1_id_participant_2_id_listing_id_key" UNIQUE ("participant_1_id", "participant_2_id", "listing_id"); + + + +ALTER TABLE ONLY "public"."conversations" + ADD CONSTRAINT "conversations_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."listing_reports" + ADD CONSTRAINT "listing_reports_listing_id_reporter_id_key" UNIQUE ("listing_id", "reporter_id"); + + + +ALTER TABLE ONLY "public"."listing_reports" + ADD CONSTRAINT "listing_reports_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."listings" + ADD CONSTRAINT "listings_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."messages" + ADD CONSTRAINT "messages_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."reviews" + ADD CONSTRAINT "reviews_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."reviews" + ADD CONSTRAINT "reviews_reviewer_id_reviewed_id_listing_id_key" UNIQUE ("reviewer_id", "reviewed_id", "listing_id"); + + + +ALTER TABLE ONLY "public"."terms_and_conditions" + ADD CONSTRAINT "terms_and_conditions_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."user_encryption_keys" + ADD CONSTRAINT "user_encryption_keys_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."user_encryption_keys" + ADD CONSTRAINT "user_encryption_keys_user_id_key" UNIQUE ("user_id"); + + + +ALTER TABLE ONLY "public"."user_favorites" + ADD CONSTRAINT "user_favorites_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."user_favorites" + ADD CONSTRAINT "user_favorites_user_id_listing_id_type_key" UNIQUE ("user_id", "listing_id", "type"); + + + +ALTER TABLE ONLY "public"."user_keys" + ADD CONSTRAINT "user_keys_pkey" PRIMARY KEY ("user_id"); + + + +ALTER TABLE ONLY "public"."user_notifications" + ADD CONSTRAINT "user_notifications_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."user_reports" + ADD CONSTRAINT "user_reports_pkey" PRIMARY KEY ("id"); + + + +ALTER TABLE ONLY "public"."user_reports" + ADD CONSTRAINT "user_reports_reported_user_id_reporter_id_key" UNIQUE ("reported_user_id", "reporter_id"); + + + +ALTER TABLE ONLY "public"."user_settings" + ADD CONSTRAINT "user_settings_pkey" PRIMARY KEY ("email"); + + + +ALTER TABLE ONLY "public"."users" + ADD CONSTRAINT "users_email_key" UNIQUE ("email"); + + + +ALTER TABLE ONLY "public"."users" + ADD CONSTRAINT "users_pkey" PRIMARY KEY ("id"); + + + +CREATE INDEX "idx_conversations_listing_id" ON "public"."conversations" USING "btree" ("listing_id"); + + + +CREATE INDEX "idx_conversations_participant_1" ON "public"."conversations" USING "btree" ("participant_1_id"); + + + +CREATE INDEX "idx_conversations_participant_2" ON "public"."conversations" USING "btree" ("participant_2_id"); + + + +CREATE INDEX "idx_listings_approved_not_sold" ON "public"."listings" USING "btree" ("status", "is_sold") WHERE ((("status")::"text" = 'approved'::"text") AND ("is_sold" = false)); + + + +CREATE INDEX "idx_listings_category" ON "public"."listings" USING "btree" ("category"); + + + +CREATE INDEX "idx_listings_created_at" ON "public"."listings" USING "btree" ("created_at" DESC); + + + +CREATE INDEX "idx_listings_is_sold" ON "public"."listings" USING "btree" ("is_sold"); + + + +CREATE INDEX "idx_listings_location" ON "public"."listings" USING "btree" ("location_lat", "location_lng"); + + + +CREATE INDEX "idx_listings_price" ON "public"."listings" USING "btree" ("price"); + + + +CREATE INDEX "idx_listings_status" ON "public"."listings" USING "btree" ("status"); + + + +CREATE INDEX "idx_listings_user_id" ON "public"."listings" USING "btree" ("user_id"); + + + +CREATE INDEX "idx_listings_user_status" ON "public"."listings" USING "btree" ("user_id", "status"); + + + +CREATE INDEX "idx_messages_created_at" ON "public"."messages" USING "btree" ("created_at" DESC); + + + +CREATE INDEX "idx_messages_is_encrypted" ON "public"."messages" USING "btree" ("is_encrypted"); + + + +CREATE INDEX "idx_messages_listing_id" ON "public"."messages" USING "btree" ("listing_id"); + + + +CREATE INDEX "idx_messages_receiver_id" ON "public"."messages" USING "btree" ("receiver_id"); + + + +CREATE INDEX "idx_messages_sender_id" ON "public"."messages" USING "btree" ("sender_id"); + + + +CREATE INDEX "idx_reviews_listing_id" ON "public"."reviews" USING "btree" ("listing_id"); + + + +CREATE INDEX "idx_reviews_reviewed_id" ON "public"."reviews" USING "btree" ("reviewed_id"); + + + +CREATE INDEX "idx_reviews_reviewer_id" ON "public"."reviews" USING "btree" ("reviewer_id"); + + + +CREATE INDEX "idx_terms_created_at" ON "public"."terms_and_conditions" USING "btree" ("created_at" DESC); + + + +CREATE INDEX "idx_terms_version" ON "public"."terms_and_conditions" USING "btree" ("version" DESC); + + + +CREATE INDEX "idx_user_encryption_keys_user_id" ON "public"."user_encryption_keys" USING "btree" ("user_id"); + + + +CREATE INDEX "idx_user_favorites_listing_id" ON "public"."user_favorites" USING "btree" ("listing_id"); + + + +CREATE INDEX "idx_user_favorites_type" ON "public"."user_favorites" USING "btree" ("type"); + + + +CREATE INDEX "idx_user_favorites_user_id" ON "public"."user_favorites" USING "btree" ("user_id"); + + + +CREATE INDEX "idx_user_keys_user_id" ON "public"."user_keys" USING "btree" ("user_id"); + + + +CREATE INDEX "idx_user_notifications_unread" ON "public"."user_notifications" USING "btree" ("user_id") WHERE ("is_read" = false); + + + +CREATE INDEX "idx_user_notifications_user_created" ON "public"."user_notifications" USING "btree" ("user_id", "created_at" DESC); + + + +CREATE OR REPLACE TRIGGER "sync_user_settings_trigger" AFTER INSERT OR UPDATE ON "public"."users" FOR EACH ROW EXECUTE FUNCTION "public"."sync_user_settings"(); + + + +CREATE OR REPLACE TRIGGER "trigger_listings_updated_at" BEFORE UPDATE ON "public"."listings" FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + + + +CREATE OR REPLACE TRIGGER "trigger_update_listing_favorite_count" AFTER INSERT OR DELETE ON "public"."user_favorites" FOR EACH ROW EXECUTE FUNCTION "public"."update_listing_favorite_count"(); + + + +CREATE OR REPLACE TRIGGER "trigger_update_user_rating" AFTER INSERT OR UPDATE ON "public"."reviews" FOR EACH ROW EXECUTE FUNCTION "public"."update_user_rating"(); + + + +CREATE OR REPLACE TRIGGER "trigger_users_updated_at" BEFORE UPDATE ON "public"."users" FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + + + +CREATE OR REPLACE TRIGGER "update_user_encryption_keys_updated_at" BEFORE UPDATE ON "public"."user_encryption_keys" FOR EACH ROW EXECUTE FUNCTION "public"."update_updated_at_column"(); + + + +ALTER TABLE ONLY "public"."conversations" + ADD CONSTRAINT "conversations_listing_id_fkey" FOREIGN KEY ("listing_id") REFERENCES "public"."listings"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."conversations" + ADD CONSTRAINT "conversations_participant_1_id_fkey" FOREIGN KEY ("participant_1_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."conversations" + ADD CONSTRAINT "conversations_participant_2_id_fkey" FOREIGN KEY ("participant_2_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."listing_reports" + ADD CONSTRAINT "listing_reports_listing_id_fkey" FOREIGN KEY ("listing_id") REFERENCES "public"."listings"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."listing_reports" + ADD CONSTRAINT "listing_reports_reporter_id_fkey" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."listing_reports" + ADD CONSTRAINT "listing_reports_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "public"."users"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."listings" + ADD CONSTRAINT "listings_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."messages" + ADD CONSTRAINT "messages_listing_id_fkey" FOREIGN KEY ("listing_id") REFERENCES "public"."listings"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."messages" + ADD CONSTRAINT "messages_receiver_id_fkey" FOREIGN KEY ("receiver_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."messages" + ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."reviews" + ADD CONSTRAINT "reviews_listing_id_fkey" FOREIGN KEY ("listing_id") REFERENCES "public"."listings"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."reviews" + ADD CONSTRAINT "reviews_reviewed_id_fkey" FOREIGN KEY ("reviewed_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."reviews" + ADD CONSTRAINT "reviews_reviewer_id_fkey" FOREIGN KEY ("reviewer_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."terms_and_conditions" + ADD CONSTRAINT "terms_and_conditions_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id"); + + + +ALTER TABLE ONLY "public"."user_encryption_keys" + ADD CONSTRAINT "user_encryption_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_favorites" + ADD CONSTRAINT "user_favorites_listing_id_fkey" FOREIGN KEY ("listing_id") REFERENCES "public"."listings"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_favorites" + ADD CONSTRAINT "user_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_keys" + ADD CONSTRAINT "user_keys_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_notifications" + ADD CONSTRAINT "user_notifications_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."user_notifications" + ADD CONSTRAINT "user_notifications_listing_id_fkey" FOREIGN KEY ("listing_id") REFERENCES "public"."listings"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."user_notifications" + ADD CONSTRAINT "user_notifications_message_id_fkey" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_notifications" + ADD CONSTRAINT "user_notifications_review_id_fkey" FOREIGN KEY ("review_id") REFERENCES "public"."reviews"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_notifications" + ADD CONSTRAINT "user_notifications_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_reports" + ADD CONSTRAINT "user_reports_reported_user_id_fkey" FOREIGN KEY ("reported_user_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_reports" + ADD CONSTRAINT "user_reports_reporter_id_fkey" FOREIGN KEY ("reporter_id") REFERENCES "public"."users"("id") ON DELETE CASCADE; + + + +ALTER TABLE ONLY "public"."user_reports" + ADD CONSTRAINT "user_reports_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "public"."users"("id") ON DELETE SET NULL; + + + +ALTER TABLE ONLY "public"."users" + ADD CONSTRAINT "users_id_fkey" FOREIGN KEY ("id") REFERENCES "auth"."users"("id") ON DELETE CASCADE; + + + +CREATE POLICY "Allow inserting notifications for any user" ON "public"."user_notifications" FOR INSERT WITH CHECK (true); + + + +CREATE POLICY "Allow reading user keys" ON "public"."user_keys" FOR SELECT USING (true); + + + +CREATE POLICY "Anyone can view published listings" ON "public"."listings" FOR SELECT USING ((NOT "is_draft")); + + + +CREATE POLICY "Anyone can view reviews" ON "public"."reviews" FOR SELECT USING (true); + + + +CREATE POLICY "Public keys are readable by all authenticated users" ON "public"."user_encryption_keys" FOR SELECT TO "authenticated" USING (true); + + + +CREATE POLICY "Users can create conversations" ON "public"."conversations" FOR INSERT WITH CHECK ((("auth"."uid"() = "participant_1_id") OR ("auth"."uid"() = "participant_2_id"))); + + + +CREATE POLICY "Users can create reviews" ON "public"."reviews" FOR INSERT WITH CHECK (("auth"."uid"() = "reviewer_id")); + + + +CREATE POLICY "Users can delete only their own public key" ON "public"."user_encryption_keys" FOR DELETE TO "authenticated" USING (("auth"."uid"() = "user_id")); + + + +CREATE POLICY "Users can delete own favorites" ON "public"."user_favorites" FOR DELETE USING (("auth"."uid"() = "user_id")); + + + +CREATE POLICY "Users can delete own listings" ON "public"."listings" FOR DELETE USING (("auth"."uid"() = "user_id")); + + + +CREATE POLICY "Users can delete own reviews" ON "public"."reviews" FOR DELETE USING (("auth"."uid"() = "reviewer_id")); + + + +CREATE POLICY "Users can delete their own notifications" ON "public"."user_notifications" FOR DELETE USING (("user_id" = "auth"."uid"())); + + + +CREATE POLICY "Users can insert own keys" ON "public"."user_keys" FOR INSERT WITH CHECK ((( SELECT "auth"."uid"() AS "uid") = "user_id")); + + + +CREATE POLICY "Users can insert own listings" ON "public"."listings" FOR INSERT WITH CHECK (("auth"."uid"() = "user_id")); + + + +CREATE POLICY "Users can insert own profile" ON "public"."users" FOR INSERT WITH CHECK (("auth"."uid"() = "id")); + + + +CREATE POLICY "Users can insert their own public key" ON "public"."user_encryption_keys" FOR INSERT TO "authenticated" WITH CHECK (("auth"."uid"() = "user_id")); + + + +CREATE POLICY "Users can manage own favorites" ON "public"."user_favorites" FOR INSERT WITH CHECK (("auth"."uid"() = "user_id")); + + + +CREATE POLICY "Users can send messages" ON "public"."messages" FOR INSERT WITH CHECK (("auth"."uid"() = "sender_id")); + + + +CREATE POLICY "Users can update only their own public key" ON "public"."user_encryption_keys" FOR UPDATE TO "authenticated" USING (("auth"."uid"() = "user_id")) WITH CHECK (("auth"."uid"() = "user_id")); + + + +CREATE POLICY "Users can update own favorites" ON "public"."user_favorites" FOR UPDATE USING (("auth"."uid"() = "user_id")); + + + +CREATE POLICY "Users can update own keys" ON "public"."user_keys" FOR UPDATE USING (("auth"."uid"() = "user_id")); + + + +CREATE POLICY "Users can update own listings" ON "public"."listings" FOR UPDATE USING (("auth"."uid"() = "user_id")); + + + +CREATE POLICY "Users can update own messages" ON "public"."messages" FOR UPDATE USING ((("auth"."uid"() = "sender_id") OR ("auth"."uid"() = "receiver_id"))); + + + +CREATE POLICY "Users can update own profile" ON "public"."users" FOR UPDATE USING (("auth"."uid"() = "id")); + + + +CREATE POLICY "Users can update own reviews" ON "public"."reviews" FOR UPDATE USING (("auth"."uid"() = "reviewer_id")); + + + +CREATE POLICY "Users can update their own notifications" ON "public"."user_notifications" FOR UPDATE USING (("user_id" = "auth"."uid"())) WITH CHECK (("user_id" = "auth"."uid"())); + + + +CREATE POLICY "Users can view all users" ON "public"."users" FOR SELECT USING (true); + + + +CREATE POLICY "Users can view own conversations" ON "public"."conversations" FOR SELECT USING ((("auth"."uid"() = "participant_1_id") OR ("auth"."uid"() = "participant_2_id"))); + + + +CREATE POLICY "Users can view own favorites" ON "public"."user_favorites" FOR SELECT USING (("auth"."uid"() = "user_id")); + + + +CREATE POLICY "Users can view own listings" ON "public"."listings" FOR SELECT USING (("auth"."uid"() = "user_id")); + + + +CREATE POLICY "Users can view own messages" ON "public"."messages" FOR SELECT USING ((("auth"."uid"() = "sender_id") OR ("auth"."uid"() = "receiver_id"))); + + + +CREATE POLICY "Users can view their own notifications" ON "public"."user_notifications" FOR SELECT USING (true); + + + +CREATE POLICY "admin can edit" ON "public"."listings" FOR UPDATE USING ((EXISTS ( SELECT 1 + FROM "public"."users" + WHERE (("users"."id" = "auth"."uid"()) AND ("users"."is_admin" = true))))) WITH CHECK ((EXISTS ( SELECT 1 + FROM "public"."users" + WHERE (("users"."id" = "auth"."uid"()) AND ("users"."is_admin" = true))))); + + + +ALTER TABLE "public"."conversations" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."listings" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."messages" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."reviews" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."user_encryption_keys" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."user_favorites" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."user_keys" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."user_notifications" ENABLE ROW LEVEL SECURITY; + + +ALTER TABLE "public"."users" 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"."cleanup_old_notifications"() TO "anon"; +GRANT ALL ON FUNCTION "public"."cleanup_old_notifications"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."cleanup_old_notifications"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."create_notification"("p_user_id" "uuid", "p_type" "public"."notification_type", "p_title" "text", "p_message" "text", "p_actor_id" "uuid", "p_data" "jsonb", "p_listing_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."create_notification"("p_user_id" "uuid", "p_type" "public"."notification_type", "p_title" "text", "p_message" "text", "p_actor_id" "uuid", "p_data" "jsonb", "p_listing_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."create_notification"("p_user_id" "uuid", "p_type" "public"."notification_type", "p_title" "text", "p_message" "text", "p_actor_id" "uuid", "p_data" "jsonb", "p_listing_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."ensure_user_profile"() TO "anon"; +GRANT ALL ON FUNCTION "public"."ensure_user_profile"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."ensure_user_profile"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_listing_engagement_stats"("p_listing_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_listing_engagement_stats"("p_listing_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_listing_engagement_stats"("p_listing_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_or_create_conversation"("p_user1_id" "uuid", "p_user2_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_or_create_conversation"("p_user1_id" "uuid", "p_user2_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_or_create_conversation"("p_user1_id" "uuid", "p_user2_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_or_create_conversation"("p_user1_id" "uuid", "p_user2_id" "uuid", "p_listing_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_or_create_conversation"("p_user1_id" "uuid", "p_user2_id" "uuid", "p_listing_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_or_create_conversation"("p_user1_id" "uuid", "p_user2_id" "uuid", "p_listing_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_unread_notification_count"("p_user_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_unread_notification_count"("p_user_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_unread_notification_count"("p_user_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_user_by_email"("p_email" "text") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_user_by_email"("p_email" "text") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_user_by_email"("p_email" "text") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."get_user_favorite_status"("p_user_id" "uuid", "p_listing_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."get_user_favorite_status"("p_user_id" "uuid", "p_listing_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."get_user_favorite_status"("p_user_id" "uuid", "p_listing_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."mark_all_notifications_read"("p_user_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."mark_all_notifications_read"("p_user_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."mark_all_notifications_read"("p_user_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."mark_all_user_notifications_read"("p_user_id" "uuid") TO "anon"; +GRANT ALL ON FUNCTION "public"."mark_all_user_notifications_read"("p_user_id" "uuid") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."mark_all_user_notifications_read"("p_user_id" "uuid") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."sync_user_settings"() TO "anon"; +GRANT ALL ON FUNCTION "public"."sync_user_settings"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."sync_user_settings"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."toggle_user_favorite"("p_user_id" "uuid", "p_listing_id" "uuid", "p_type" "public"."favorite_type") TO "anon"; +GRANT ALL ON FUNCTION "public"."toggle_user_favorite"("p_user_id" "uuid", "p_listing_id" "uuid", "p_type" "public"."favorite_type") TO "authenticated"; +GRANT ALL ON FUNCTION "public"."toggle_user_favorite"("p_user_id" "uuid", "p_listing_id" "uuid", "p_type" "public"."favorite_type") TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."update_listing_favorite_count"() TO "anon"; +GRANT ALL ON FUNCTION "public"."update_listing_favorite_count"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."update_listing_favorite_count"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."update_updated_at_column"() TO "anon"; +GRANT ALL ON FUNCTION "public"."update_updated_at_column"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."update_updated_at_column"() TO "service_role"; + + + +GRANT ALL ON FUNCTION "public"."update_user_rating"() TO "anon"; +GRANT ALL ON FUNCTION "public"."update_user_rating"() TO "authenticated"; +GRANT ALL ON FUNCTION "public"."update_user_rating"() TO "service_role"; + + + + + + + + + + + + + + + + + + +GRANT ALL ON TABLE "public"."conversations" TO "anon"; +GRANT ALL ON TABLE "public"."conversations" TO "authenticated"; +GRANT ALL ON TABLE "public"."conversations" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."listings" TO "anon"; +GRANT ALL ON TABLE "public"."listings" TO "authenticated"; +GRANT ALL ON TABLE "public"."listings" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."users" TO "anon"; +GRANT ALL ON TABLE "public"."users" TO "authenticated"; +GRANT ALL ON TABLE "public"."users" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."listing_details" TO "anon"; +GRANT ALL ON TABLE "public"."listing_details" TO "authenticated"; +GRANT ALL ON TABLE "public"."listing_details" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."listing_reports" TO "anon"; +GRANT ALL ON TABLE "public"."listing_reports" TO "authenticated"; +GRANT ALL ON TABLE "public"."listing_reports" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."messages" TO "anon"; +GRANT ALL ON TABLE "public"."messages" TO "authenticated"; +GRANT ALL ON TABLE "public"."messages" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."reviews" TO "anon"; +GRANT ALL ON TABLE "public"."reviews" TO "authenticated"; +GRANT ALL ON TABLE "public"."reviews" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."terms_and_conditions" TO "anon"; +GRANT ALL ON TABLE "public"."terms_and_conditions" TO "authenticated"; +GRANT ALL ON TABLE "public"."terms_and_conditions" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."user_encryption_keys" TO "anon"; +GRANT ALL ON TABLE "public"."user_encryption_keys" TO "authenticated"; +GRANT ALL ON TABLE "public"."user_encryption_keys" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."user_favorites" TO "anon"; +GRANT ALL ON TABLE "public"."user_favorites" TO "authenticated"; +GRANT ALL ON TABLE "public"."user_favorites" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."user_keys" TO "anon"; +GRANT ALL ON TABLE "public"."user_keys" TO "authenticated"; +GRANT ALL ON TABLE "public"."user_keys" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."user_notifications" TO "anon"; +GRANT ALL ON TABLE "public"."user_notifications" TO "authenticated"; +GRANT ALL ON TABLE "public"."user_notifications" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."user_reports" TO "anon"; +GRANT ALL ON TABLE "public"."user_reports" TO "authenticated"; +GRANT ALL ON TABLE "public"."user_reports" TO "service_role"; + + + +GRANT ALL ON TABLE "public"."user_settings" TO "anon"; +GRANT ALL ON TABLE "public"."user_settings" TO "authenticated"; +GRANT ALL ON TABLE "public"."user_settings" 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"; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +drop extension if exists "pg_net"; + +alter table "public"."listing_reports" drop constraint "listing_reports_status_check"; + +alter table "public"."listings" drop constraint "listings_status_check"; + +alter table "public"."user_reports" drop constraint "user_reports_status_check"; + +alter table "public"."listing_reports" add constraint "listing_reports_status_check" CHECK (((status)::text = ANY ((ARRAY['pending'::character varying, 'reviewed'::character varying, 'resolved'::character varying, 'dismissed'::character varying])::text[]))) not valid; + +alter table "public"."listing_reports" validate constraint "listing_reports_status_check"; + +alter table "public"."listings" add constraint "listings_status_check" CHECK (((status)::text = ANY ((ARRAY['pending'::character varying, 'approved'::character varying, 'denied'::character varying])::text[]))) not valid; + +alter table "public"."listings" validate constraint "listings_status_check"; + +alter table "public"."user_reports" add constraint "user_reports_status_check" CHECK (((status)::text = ANY ((ARRAY['pending'::character varying, 'reviewed'::character varying, 'resolved'::character varying, 'dismissed'::character varying])::text[]))) not valid; + +alter table "public"."user_reports" validate constraint "user_reports_status_check"; + +CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.ensure_user_profile(); + + + create policy "Allow authenticated users to upload avatars" + on "storage"."objects" + as permissive + for insert + to authenticated +with check ((bucket_id = 'avatars'::text)); + + + + create policy "Allow authenticated users to upload listing images" + on "storage"."objects" + as permissive + for insert + to authenticated +with check ((bucket_id = 'listing-images'::text)); + + + + create policy "Allow public access to avatars" + on "storage"."objects" + as permissive + for select + to public +using ((bucket_id = 'avatars'::text)); + + + + create policy "Allow public access to listing images" + on "storage"."objects" + as permissive + for select + to public +using ((bucket_id = 'listing-images'::text)); + + + + create policy "Allow users to delete own listing images" + on "storage"."objects" + as permissive + for delete + to authenticated +using (((bucket_id = 'listing-images'::text) AND ((auth.uid())::text = (storage.foldername(name))[1]))); + + + + create policy "Allow users to update own avatars" + on "storage"."objects" + as permissive + for update + to authenticated +using (((bucket_id = 'avatars'::text) AND ((auth.uid())::text = (storage.foldername(name))[1]))); + + + diff --git a/supabase/migrations/20260220234500_add_user_status.sql b/supabase/migrations/20260220234500_add_user_status.sql new file mode 100644 index 0000000..420a7e0 --- /dev/null +++ b/supabase/migrations/20260220234500_add_user_status.sql @@ -0,0 +1,10 @@ +alter table public.users + add column if not exists status text not null default 'pending'; + +alter table public.users + add column if not exists email_verified_at timestamptz; + +update public.users +set status = 'active', + email_verified_at = coalesce(email_verified_at, now()) +where status is null or status = 'pending'; From 16d16d12c2fca6cb8c1c41bca0770fb03c735251 Mon Sep 17 00:00:00 2001 From: austin616 Date: Fri, 20 Feb 2026 17:41:11 -0600 Subject: [PATCH 2/3] feat: email verification flow logic --- app/auth/callback/route.ts | 160 +++++++++++++++++--------------- app/lib/database/UserService.ts | 44 ++++++--- 2 files changed, 117 insertions(+), 87 deletions(-) diff --git a/app/auth/callback/route.ts b/app/auth/callback/route.ts index 7bf5ba0..0eb6f91 100644 --- a/app/auth/callback/route.ts +++ b/app/auth/callback/route.ts @@ -15,95 +15,107 @@ export async function GET(request: NextRequest) { const type = requestUrl.searchParams.get('type'); const next = requestUrl.searchParams.get('next') || '/'; - // If this is an email confirmation, redirect to the confirmation page - if (type === 'signup') { - const email = requestUrl.searchParams.get('email') || ''; - // Redirect to the confirmation page with confirmed status - return NextResponse.redirect( - `${requestUrl.origin}/auth/confirmation?confirmed=true&email=${encodeURIComponent(email)}` - ); + // If no code is present, fall back to a safe redirect. + if (!code) { + if (type === 'signup') { + const email = requestUrl.searchParams.get('email') || ''; + return NextResponse.redirect( + `${requestUrl.origin}/auth/confirmation?email=${encodeURIComponent(email)}` + ); + } + + return NextResponse.redirect(requestUrl.origin + next); } - if (code) { - const cookieStore = await cookies(); - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - get(name: string) { - return cookieStore.get(name)?.value; - }, - set(name: string, value: string, options: any) { - cookieStore.set(name, value, options); - }, - remove(name: string, options: any) { - cookieStore.set(name, '', options); - }, + const cookieStore = await cookies(); + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + get(name: string) { + return cookieStore.get(name)?.value; }, - } - ); + set(name: string, value: string, options: any) { + cookieStore.set(name, value, options); + }, + remove(name: string, options: any) { + cookieStore.set(name, '', options); + }, + }, + } + ); - const { data: { user }, error } = await supabase.auth.exchangeCodeForSession(code); - - if (error) { - console.error('Error exchanging code for session:', error); + const { data: { user }, error } = await supabase.auth.exchangeCodeForSession(code); + + if (error) { + console.error('Error exchanging code for session:', error); + return NextResponse.redirect( + `${requestUrl.origin}/auth/signin?error=${encodeURIComponent('Could not authenticate user')}` + ); + } + + if (user) { + // Validate email domain for new users + if (user.email && !validateEmailDomain(user.email)) { + console.error('Invalid email domain for user:', user.email); return NextResponse.redirect( - `${requestUrl.origin}/auth/signin?error=${encodeURIComponent('Could not authenticate user')}` + `${requestUrl.origin}/auth/signin?error=${encodeURIComponent('Please use your UT Austin email address')}` ); } - if (user) { - // Validate email domain for new users - if (user.email && !validateEmailDomain(user.email)) { - console.error('Invalid email domain for user:', user.email); - return NextResponse.redirect( - `${requestUrl.origin}/auth/signin?error=${encodeURIComponent('Please use your UT Austin email address')}` - ); - } - - // Check if this is a new user (first time signing in) - const existingProfile = await UserService.getUserProfile(user.id); - - if (!existingProfile) { - // This is a new user - create their profile and redirect to onboarding - await UserService.upsertUserProfile({ - id: user.id, - email: user.email || '', - display_name: user.user_metadata?.full_name || user.user_metadata?.name || user.email?.split('@')[0] || 'User', - profile_image_url: user.user_metadata?.avatar_url || user.user_metadata?.picture || null, - bio: null, - phone: null, - location: null, - onboard_complete: false, - notification_preferences: { - email_notifications: true, - browser_notifications: true - } - }); - - // Redirect to onboarding for new users - return NextResponse.redirect( - `${requestUrl.origin}/auth/confirmation/onboard` - ); - } + // Check if this is a new user (first time signing in) + const existingProfile = await UserService.getUserProfile(user.id); + + if (!existingProfile) { + // This is a new user - create their profile and redirect to onboarding + await UserService.upsertUserProfile({ + id: user.id, + email: user.email || '', + display_name: user.user_metadata?.full_name || user.user_metadata?.name || user.email?.split('@')[0] || 'User', + profile_image_url: user.user_metadata?.avatar_url || user.user_metadata?.picture || null, + bio: null, + phone: null, + location: null, + onboard_complete: false, + status: 'active', + email_verified_at: new Date().toISOString(), + notification_preferences: { + email_notifications: true, + browser_notifications: true + } + }); - // Check if existing user has completed onboarding - if (!existingProfile.onboard_complete) { - return NextResponse.redirect( - `${requestUrl.origin}/auth/confirmation/onboard` - ); - } + // Redirect to onboarding for new users + return NextResponse.redirect( + `${requestUrl.origin}/auth/confirmation/onboard` + ); } - // If this is an email confirmation, redirect to the confirmation page - if (user?.new_email) { + // If an existing user is still pending, activate them after verification. + if (existingProfile.status && existingProfile.status !== 'active') { + await UserService.updateUserProfile({ + id: user.id, + status: 'active', + email_verified_at: new Date().toISOString(), + }); + } + + // Check if existing user has completed onboarding + if (!existingProfile.onboard_complete) { return NextResponse.redirect( - `${requestUrl.origin}/auth/confirmation?confirmed=true&email=${encodeURIComponent(user.new_email)}` + `${requestUrl.origin}/auth/confirmation/onboard` ); } } + + // If this is an email change confirmation, keep existing behavior. + if (user?.new_email) { + return NextResponse.redirect( + `${requestUrl.origin}/auth/confirmation?confirmed=true&email=${encodeURIComponent(user.new_email)}` + ); + } // Default redirect after sign in return NextResponse.redirect(requestUrl.origin + next); -} \ No newline at end of file +} diff --git a/app/lib/database/UserService.ts b/app/lib/database/UserService.ts index 0844251..7bf9ece 100644 --- a/app/lib/database/UserService.ts +++ b/app/lib/database/UserService.ts @@ -10,6 +10,8 @@ export interface UserProfile { phone?: string; location?: string; onboard_complete?: boolean; + status?: 'pending' | 'active'; + email_verified_at?: string | null; notification_preferences?: { email_notifications: boolean; browser_notifications: boolean; @@ -37,6 +39,8 @@ export interface CreateUserProfileParams { phone?: string; location?: string; onboard_complete?: boolean; + status?: 'pending' | 'active'; + email_verified_at?: string | null; notification_preferences?: { email_notifications: boolean; browser_notifications: boolean; @@ -51,6 +55,8 @@ export interface UpdateUserProfileParams { phone?: string; location?: string; onboard_complete?: boolean; + status?: 'pending' | 'active'; + email_verified_at?: string | null; } export interface CreateRatingParams { @@ -86,26 +92,38 @@ export class UserService { phone, location, onboard_complete, + status, + email_verified_at, notification_preferences } = params; try { dbLogger.info('Upserting user profile', { userId: id, email }); + const payload: Record = { + id, + email, + display_name: display_name || email.split('@')[0], + profile_image_url, + bio, + phone, + location, + onboard_complete, + notification_preferences, + updated_at: new Date().toISOString(), + }; + + if (status !== undefined) { + payload.status = status; + } + + if (email_verified_at !== undefined) { + payload.email_verified_at = email_verified_at; + } + const { data, error } = await supabase .from('users') - .upsert({ - id, - email, - display_name: display_name || email.split('@')[0], - profile_image_url, - bio, - phone, - location, - onboard_complete, - notification_preferences, - updated_at: new Date().toISOString(), - }, { + .upsert(payload, { onConflict: 'id' }) .select() @@ -535,4 +553,4 @@ export class UserService { return []; } } -} \ No newline at end of file +} From 8c2a9f7df2a0cc3156a916e8aa1080d4dab87285 Mon Sep 17 00:00:00 2001 From: austin616 Date: Fri, 20 Feb 2026 18:03:17 -0600 Subject: [PATCH 3/3] feat: onboarding UI changes --- app/auth/confirmation/onboard/page.tsx | 570 +++++++++------------ app/settings/page.tsx | 54 +- components/layout/AdminRedirectWrapper.tsx | 10 +- 3 files changed, 312 insertions(+), 322 deletions(-) diff --git a/app/auth/confirmation/onboard/page.tsx b/app/auth/confirmation/onboard/page.tsx index e68caf8..8e46d2b 100644 --- a/app/auth/confirmation/onboard/page.tsx +++ b/app/auth/confirmation/onboard/page.tsx @@ -5,239 +5,185 @@ import { useRouter } from 'next/navigation'; import { useAuth } from '../../../context/AuthContext'; import { supabase } from '../../../lib/supabaseClient'; import { motion, AnimatePresence } from 'framer-motion'; -import { - Search, - Plus, - MessageCircle, - Heart, - Star, - Shield, - Users, - CheckCircle, +import { + ArrowRight, + Bell, + CheckCircle2, ChevronLeft, ChevronRight, - ArrowRight + MessageCircle, + Plus, + Search, + ShieldCheck, + Tag, } from 'lucide-react'; const slides = [ { id: 1, - title: "Welcome to UT Marketplace! 🀘", - subtitle: "The safest way for Longhorns to buy & sell", - content: ( -
- -
🀘
-

- Join thousands of UT students buying and selling everything from textbooks to dorm essentials! -

-
-
-
-
- + step: 'Step 1', + title: 'Welcome to UT Marketplace', + subtitle: 'A UT-only community for safer buying and selling.', + points: [ + 'Verified @utexas.edu accounts only', + 'Listings reviewed before going live', + 'Built-in reporting and moderation tools', + ], + tip: { + title: 'Privacy tip', + body: 'Your UT email is never shown publicly. Review the Privacy Policy anytime in Settings or the Privacy page.', + }, + preview: ( +
+
+
+
+
-

UT Students Only

-
-
-
- +
+

Verified community

+

UT Marketplace

-

Campus Community

-
-
- + Active +
+
+ {['Furniture', 'Tech', 'Textbooks', 'Subleases'].map((item) => ( +
+ {item}
-

Safe & Secure

-
+ ))}
- ) + ), }, { id: 2, - title: "Browse & Discover", - subtitle: "Find exactly what you need", - content: ( -
- -
- -
- Search for textbooks, furniture, electronics... -
-
-
-
- πŸ“š -

Textbooks

-
-
- πŸ›οΈ -

Dorm

-
-
- πŸ‘• -

Clothing

-
-
- πŸ“± -

Electronics

+ step: 'Step 2', + title: 'Browse and search quickly', + subtitle: 'Find the right listing with filters and keyword search.', + points: [ + 'Search by title and description', + 'Filter by category and price', + 'Sort newest or oldest listings', + ], + preview: ( +
+
+ + Search β€œdesk lamp” +
+
+ {['Oak desk lamp', 'Dorm desk chair', 'Desk organizer'].map((item, index) => ( +
+
+

{item}

+

Posted {index + 1}h ago

+
+ $ {20 + index * 10}
-
- -

- Browse by category or search for specific items. Save favorites and get notified of new listings! -

+ ))} +
- ) + ), }, { id: 3, - title: "Create Listings", - subtitle: "Sell your items in seconds", - content: ( -
- -
-
- -
-
-
-
-
-
-
-
-
-
-
- $25 -
-
- -
+ step: 'Step 3', + title: 'Create your listing', + subtitle: 'Upload photos, set details, and submit for approval.', + points: [ + 'Drafts are saved automatically', + 'Add up to 5 photos per listing', + 'Approval keeps quality high', + ], + preview: ( +
+
+
+

New listing

+
- -

- Take photos, set your price, and post! Your listing goes live instantly to fellow Longhorns. -

+

$85

+

Calculus Textbook

+
+
+
Category: Textbooks
+
Condition: Good
+
Photos: 4
+
Status: Pending
+
- ) + ), }, { id: 4, - title: "Chat & Connect", - subtitle: "Message buyers and sellers directly", - content: ( -
- -
-
-
-
-
- Hey! Is this textbook still available? -
-

Sarah β€’ 2m ago

-
+ step: 'Step 4', + title: 'Chat and coordinate', + subtitle: 'Keep communication inside the app for safety.', + points: [ + 'Per-listing conversations', + 'Real-time notifications', + 'Meetups around campus spots', + ], + preview: ( +
+
+
+
JS
+
+ Can we meet at PCL at 3?
-
-
-
- Yes! Can meet at PCL this afternoon πŸ“š -
-

You β€’ now

-
-
- You -
+
+
+
+ That works. I’ll be there.
+
You
- -

- Built-in messaging lets you coordinate pickups, ask questions, and negotiate prices safely. -

+
+
+ Notifications keep you updated. +
- ) + ), }, { id: 5, - title: "You're All Set! πŸŽ‰", - subtitle: "Start exploring the marketplace", - content: ( -
- -
🎯
-
- - -

Browse Items

-

Find great deals from fellow students

-
- - -

Create Listing

-

Sell items you no longer need

-
+ step: 'Step 5', + title: 'Stay informed and protected', + subtitle: 'You control your profile, notifications, and privacy settings.', + points: [ + 'Manage notifications in Settings', + 'Review Terms and Privacy Policy any time', + 'Report suspicious activity directly', + ], + preview: ( +
+
+
+

Settings

+

Notifications & Privacy

- - -
- - Pro Tip: + +
+
+
+ Email notifications + On +
+
+ Browser notifications + On +
+
+ Privacy policy + View
-

- Use your UT email to verify your student status and build trust with other Longhorns! -

- +
- ) - } + ), + }, ]; export default function OnboardingPage() { @@ -248,13 +194,11 @@ export default function OnboardingPage() { const [direction, setDirection] = useState(1); useEffect(() => { - // If user is not authenticated, redirect to sign in if (!user) { router.push('/auth/signin'); return; } - - // Check if user has already completed onboarding + const checkOnboardingStatus = async () => { if (user) { const { data: profile } = await supabase @@ -262,16 +206,15 @@ export default function OnboardingPage() { .select('onboard_complete') .eq('id', user.id) .single(); - + if (profile?.onboard_complete) { - // User has already completed onboarding, redirect to home router.push('/'); return; } } setIsLoading(false); }; - + checkOnboardingStatus(); }, [user, router]); @@ -291,26 +234,23 @@ export default function OnboardingPage() { const handleComplete = async () => { if (!user) return; - + try { - // Mark onboarding as complete const { error } = await supabase .from('users') - .update({ + .update({ onboard_complete: true, - updated_at: new Date().toISOString() + updated_at: new Date().toISOString(), }) .eq('id', user.id); - + if (error) { console.error('Error updating onboarding status:', error); } - - // Redirect to home page + router.push('/'); } catch (error) { console.error('Error completing onboarding:', error); - // Still redirect even if there's an error router.push('/'); } }; @@ -322,11 +262,11 @@ export default function OnboardingPage() { if (isLoading) { return ( -
+
); @@ -334,144 +274,134 @@ export default function OnboardingPage() { const slideVariants = { enter: (direction: number) => ({ - x: direction > 0 ? 300 : -300, - opacity: 0 + x: direction > 0 ? 120 : -120, + opacity: 0, }), center: { zIndex: 1, x: 0, - opacity: 1 + opacity: 1, }, exit: (direction: number) => ({ zIndex: 0, - x: direction < 0 ? 300 : -300, - opacity: 0 - }) + x: direction < 0 ? 120 : -120, + opacity: 0, + }), }; + const activeSlide = slides[currentSlide]; + return ( -
- {/* Header with progress bar */} -
-
-
-

- UT Marketplace - 🀘 -

- - {currentSlide + 1} of {slides.length} - -
- - {/* Progress bar */} -
- -
- - {/* Dot indicators */} -
- {slides.map((_, index) => ( - +
+ {slides.map((_, index) => ( +
+ {currentSlide === slides.length - 1 ? ( - - Get Started - - + Get Started + + ) : ( )}
diff --git a/app/settings/page.tsx b/app/settings/page.tsx index e63103c..3e8f0ab 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -25,6 +25,7 @@ export default function SettingsPage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [imageUploading, setImageUploading] = useState(false); + const [devActionLoading, setDevActionLoading] = useState(false); const [settings, setSettings] = useState({ display_name: '', bio: '', @@ -180,6 +181,33 @@ export default function SettingsPage() { } }; + const resetOnboarding = async (redirectToOnboarding: boolean) => { + if (!user?.id) return; + + try { + setDevActionLoading(true); + const updatedProfile = await UserService.updateUserProfile({ + id: user.id, + onboard_complete: false, + }); + + if (!updatedProfile) { + throw new Error('Failed to reset onboarding status'); + } + + alert('Onboarding status cleared for this account.'); + + if (redirectToOnboarding) { + router.push('/auth/confirmation/onboard'); + } + } catch (error) { + console.error('Error resetting onboarding status:', error); + alert('Failed to reset onboarding status. Please try again.'); + } finally { + setDevActionLoading(false); + } + }; + if (loading) { return ; } @@ -303,6 +331,30 @@ export default function SettingsPage() {
+ {/* Developer Tools */} +
+

Developer Tools

+

+ Temporary controls for QA. These should be removed before launch. +

+
+ + +
+
+ {/* Save Button */}
); -} \ No newline at end of file +} diff --git a/components/layout/AdminRedirectWrapper.tsx b/components/layout/AdminRedirectWrapper.tsx index e1f53e8..2de3c50 100644 --- a/components/layout/AdminRedirectWrapper.tsx +++ b/components/layout/AdminRedirectWrapper.tsx @@ -14,6 +14,7 @@ const AdminRedirectWrapper: React.FC = ({ children, n const router = useRouter(); const pathname = usePathname(); const [mounted, setMounted] = useState(false); + const hideChrome = pathname?.startsWith('/auth/confirmation/onboard'); // Ensure component is mounted on client before making admin decisions useEffect(() => { @@ -45,6 +46,9 @@ const AdminRedirectWrapper: React.FC = ({ children, n // During SSR or initial hydration, always show regular layout to prevent mismatch if (!mounted || loading) { + if (hideChrome) { + return
{children}
; + } return ( <> {navbar} @@ -73,6 +77,10 @@ const AdminRedirectWrapper: React.FC = ({ children, n return <>{children}; } + if (hideChrome) { + return
{children}
; + } + // Regular users get the normal layout return ( <> @@ -85,4 +93,4 @@ const AdminRedirectWrapper: React.FC = ({ children, n ); }; -export default AdminRedirectWrapper; \ No newline at end of file +export default AdminRedirectWrapper;