diff --git a/db/ERD.png b/db/ERD.png index 02ecf6a..e7b083e 100644 Binary files a/db/ERD.png and b/db/ERD.png differ diff --git a/db/final_db.sql b/db/final_db.sql index 94d3802..8fe7ccf 100644 --- a/db/final_db.sql +++ b/db/final_db.sql @@ -1,529 +1,578 @@ --- ====================================================== --- LIVESTOCK MARKETPLACE - COMPLETE PRODUCTION SCHEMA --- ====================================================== - --- 1. EXTENSIONS --- ====================================================== -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -CREATE EXTENSION IF NOT EXISTS "postgis"; -- Crucial for "Near Me" filters - -SET timezone TO 'UTC'; - --- 2. GLOBAL FUNCTIONS (Timestamps) --- ====================================================== -CREATE OR REPLACE FUNCTION set_updated_at() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = NOW(); - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- 3. ENUM TYPES --- ====================================================== -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sex_enum') THEN - CREATE TYPE sex_enum AS ENUM ('M','F','Neutered'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'purpose_enum') THEN - CREATE TYPE purpose_enum AS ENUM ('dairy','meat','breeding','pet','work','other'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'health_status_enum') THEN - CREATE TYPE health_status_enum AS ENUM ('healthy','minor_issues','serious_issues'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'location_type_enum') THEN - CREATE TYPE location_type_enum AS ENUM ('farm','home','office','temporary_gps','other_saved','other'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'source_type_enum') THEN - CREATE TYPE source_type_enum AS ENUM ('gps','device_gps','manual','imported','unknown'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'listing_type_enum') THEN - CREATE TYPE listing_type_enum AS ENUM ('sale','stud_service','adoption','other'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'listing_status_enum') THEN - CREATE TYPE listing_status_enum AS ENUM ('active','sold','expired','hidden','deleted'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'seller_type_enum') THEN - CREATE TYPE seller_type_enum AS ENUM ('owner','farmer','broker','agent','other'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'listing_role_enum') THEN - CREATE TYPE listing_role_enum AS ENUM ('seller_buyer','service_provider'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_type_enum') THEN - CREATE TYPE user_type_enum AS ENUM ('user','admin','moderator'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'message_type_enum') THEN - CREATE TYPE message_type_enum AS ENUM ('text','media','both'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'media_type_enum') THEN - CREATE TYPE media_type_enum AS ENUM ('image','video','audio','document'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'communication_type_enum') THEN - CREATE TYPE communication_type_enum AS ENUM ('call','missed_call','voicemail'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'call_status_enum') THEN - CREATE TYPE call_status_enum AS ENUM ('initiated','ringing','answered','completed','failed','busy','no_answer'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'pregnancy_status_enum') THEN - CREATE TYPE pregnancy_status_enum AS ENUM ('none', 'pregnant', 'unknown'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'requirement_status_enum') THEN - CREATE TYPE requirement_status_enum AS ENUM ('open', 'fulfilled', 'cancelled'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'analytics_event_type_enum') THEN - CREATE TYPE analytics_event_type_enum AS ENUM ('views_count','bookmarks_count','enquiries_call_count','enquiries_whatsapp_count'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'listing_score_status_enum') THEN - CREATE TYPE listing_score_status_enum AS ENUM ('pending', 'processing', 'completed', 'failed'); - END IF; - IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'source_confidence_enum') THEN - CREATE TYPE source_confidence_enum AS ENUM ('high', 'medium', 'low', 'unknown'); - END IF; -END $$; - --- 4. USERS & AUTHENTICATION --- ====================================================== -CREATE TABLE subscription_plans ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(100) NOT NULL UNIQUE, - description TEXT, - price NUMERIC(10, 2), - currency VARCHAR(10) DEFAULT 'INR', - duration_days INT, -- Validity in days - features JSONB, -- Flexible JSON for features like {"max_listings": 10} - is_active BOOLEAN DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE TRIGGER trg_subscription_plans_updated_at BEFORE UPDATE ON subscription_plans FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -CREATE TABLE users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - phone_number VARCHAR(20) UNIQUE, - name VARCHAR(255), - avatar_url TEXT, - language VARCHAR(10), - timezone VARCHAR(50), - is_active BOOLEAN NOT NULL DEFAULT TRUE, - roles listing_role_enum[] NOT NULL DEFAULT '{seller_buyer}', - active_role listing_role_enum NOT NULL DEFAULT 'seller_buyer', - user_type user_type_enum NOT NULL DEFAULT 'user', - country_code VARCHAR(10) NOT NULL DEFAULT '+91', - - -- Seller Ratings (Cached for speed) - rating_average NUMERIC(3, 2) DEFAULT 0.00, - rating_count INT DEFAULT 0, - - -- Subscription Info - subscription_plan_id UUID REFERENCES subscription_plans(id) ON DELETE SET NULL, - subscription_expires_at TIMESTAMPTZ, - - deleted BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_login_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - -CREATE TABLE otp_requests ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - phone_number VARCHAR(20) NOT NULL, - country_code VARCHAR(10) NOT NULL DEFAULT '+91', - otp_hash VARCHAR(255) NOT NULL, - deleted BOOLEAN NOT NULL DEFAULT FALSE, - expires_at TIMESTAMPTZ NOT NULL, - consumed_at TIMESTAMPTZ, - attempt_count INT NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_otp_phone_unconsumed ON otp_requests (phone_number) WHERE consumed_at IS NULL; - -CREATE TABLE user_devices ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - device_identifier TEXT, - device_platform TEXT NOT NULL, - fcm_token TEXT, -- For Push Notifications - lat NUMERIC(10,7), - lng NUMERIC(10,7), - last_seen_at TIMESTAMPTZ, - is_active BOOLEAN NOT NULL DEFAULT TRUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(user_id, device_identifier) -); - -CREATE TABLE refresh_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash VARCHAR(255) NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 5. MASTER DATA (Species & Breeds) --- ====================================================== -CREATE TABLE species ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(120) NOT NULL UNIQUE, - deleted BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE breeds ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - species_id UUID NOT NULL REFERENCES species(id) ON DELETE RESTRICT, - name VARCHAR(150) NOT NULL, - description TEXT, - deleted BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(species_id, name) -); - --- 6. LOCATIONS (Optimized with PostGIS) --- ====================================================== -CREATE TABLE locations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id) ON DELETE CASCADE, - lat NUMERIC(10,7), - lng NUMERIC(10,7), - - -- AUTOMATIC GEOGRAPHY COLUMN (The Magic for 'Near Me' queries) - -- This automatically creates a spatial point whenever you save lat/lng - geog GEOGRAPHY(POINT, 4326) GENERATED ALWAYS AS ( - CASE WHEN lat IS NOT NULL AND lng IS NOT NULL - THEN ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography - ELSE NULL END - ) STORED, - - is_saved_address BOOLEAN NOT NULL DEFAULT FALSE, - location_type location_type_enum, - country VARCHAR(100), - state VARCHAR(100), - district VARCHAR(100), - city_village VARCHAR(150), - pincode VARCHAR(20), - - source_type source_type_enum DEFAULT 'unknown', - source_confidence source_confidence_enum DEFAULT 'unknown', - selected_location BOOLEAN NOT NULL DEFAULT FALSE, - deleted BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX idx_locations_geog ON locations USING GIST (geog); -- Spatial Index -CREATE TRIGGER trg_locations_updated_at BEFORE UPDATE ON locations FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - - - --- 7. ANIMALS (The Inventory) --- ====================================================== -CREATE TABLE animals ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - species_id UUID NOT NULL REFERENCES species(id) ON DELETE RESTRICT, - breed_id UUID REFERENCES breeds(id) ON DELETE SET NULL, - location_id UUID REFERENCES locations(id) ON DELETE RESTRICT, - - sex sex_enum, - age_months INT, - weight_kg NUMERIC(8,2), - color_markings VARCHAR(255), - quantity INT NOT NULL DEFAULT 1, - purpose purpose_enum, - health_status health_status_enum DEFAULT 'healthy', - vaccinated BOOLEAN DEFAULT FALSE, - dewormed BOOLEAN DEFAULT FALSE, - pregnancy_status pregnancy_status_enum NOT NULL DEFAULT 'unknown', - calving_number INT, - milk_yield_litre_per_day NUMERIC(8,3), - ear_tag_no VARCHAR(100), - description TEXT, - - deleted BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE TRIGGER trg_animals_updated_at BEFORE UPDATE ON animals FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- 8. LISTINGS (Denormalized for Read Performance) --- ====================================================== -CREATE TABLE listings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - seller_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - animal_id UUID NOT NULL UNIQUE REFERENCES animals(id) ON DELETE CASCADE, - - title VARCHAR(255) NOT NULL, - price NUMERIC(12,2), - currency VARCHAR(10) DEFAULT 'INR', - is_negotiable BOOLEAN DEFAULT FALSE, - listing_type listing_type_enum DEFAULT 'sale', - status listing_status_enum DEFAULT 'active', - - -- Counters (Updated via Batch Jobs) - views_count BIGINT DEFAULT 0, - bookmarks_count BIGINT DEFAULT 0, - enquiries_call_count BIGINT DEFAULT 0, - enquiries_whatsapp_count BIGINT DEFAULT 0, - - -- DENORMALIZED COLUMNS (Auto-filled by Trigger) - -- These exist so the Home Feed doesn't need to join 4 tables - filter_species_id UUID, - filter_breed_id UUID, - filter_sex sex_enum, - filter_age_months INT, - filter_pregnancy_status pregnancy_status_enum, - filter_weight_kg NUMERIC(8,2), - filter_calving_number INT, - filter_milking_capacity NUMERIC(8,3), - filter_location_geog GEOGRAPHY(POINT, 4326), - - -- AI Score - listing_score INT, - listing_score_status listing_score_status_enum DEFAULT 'pending', - - thumbnail_url TEXT, - - -- FULL TEXT SEARCH (Auto-generated) - search_vector tsvector GENERATED ALWAYS AS ( - to_tsvector('english', title) - ) STORED, - - deleted_reason TEXT, - deleted BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE TRIGGER trg_listings_updated_at BEFORE UPDATE ON listings FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - - - --- OPTIMIZED INDEXES --- 1. Main Feed: Status + Species + Price (ignoring deleted) -CREATE INDEX idx_listings_feed_optimized ON listings (status, filter_species_id, price) WHERE deleted = FALSE; --- 2. Near Me: Spatial Search -CREATE INDEX idx_listings_spatial ON listings USING GIST (filter_location_geog) WHERE deleted = FALSE; --- 3. Search Bar: Text Search -CREATE INDEX idx_listings_search_gin ON listings USING GIN (search_vector); --- 4. My Listings: Seller + Status -CREATE INDEX idx_listings_seller_status ON listings (seller_id, status) WHERE deleted = FALSE; - -CREATE TABLE listing_media ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - listing_id UUID NOT NULL REFERENCES listings(id) ON DELETE CASCADE, - media_url TEXT NOT NULL, - media_type media_type_enum NOT NULL, - is_primary BOOLEAN NOT NULL DEFAULT FALSE, - sort_order INT NOT NULL DEFAULT 0, - deleted BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE TABLE sold_information ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - listing_id UUID NOT NULL UNIQUE REFERENCES listings(id) ON DELETE CASCADE, - sold_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL, - sale_price NUMERIC(12, 2), - sale_location_id UUID REFERENCES locations(id) ON DELETE SET NULL, - sale_date TIMESTAMPTZ, - notes TEXT, - attachment_urls TEXT[], - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE TRIGGER trg_sold_information_updated_at BEFORE UPDATE ON sold_information FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- 9. ENGAGEMENT (Reviews, Analytics, Retention) --- ====================================================== -CREATE TABLE custom_requirements ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - animal_id UUID REFERENCES animals(id) ON DELETE SET NULL, - requirement_text TEXT NOT NULL, - status requirement_status_enum NOT NULL DEFAULT 'open', - deleted BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE TRIGGER trg_custom_requirements_updated_at BEFORE UPDATE ON custom_requirements FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- New: High-speed analytics buffer -CREATE TABLE listing_analytics_events ( - id BIGSERIAL PRIMARY KEY, - listing_id UUID NOT NULL REFERENCES listings(id) ON DELETE CASCADE, - event_type analytics_event_type_enum NOT NULL, - user_id UUID REFERENCES users(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ DEFAULT NOW() -); - --- Table for favorites -CREATE TABLE favorites ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, - listing_id UUID NOT NULL REFERENCES listings(id) ON DELETE CASCADE, - deleted BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(user_id, listing_id) -); -CREATE TRIGGER trg_favorites_updated_at BEFORE UPDATE ON favorites FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- New: Reviews & Ratings -CREATE TABLE reviews ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - listing_id UUID REFERENCES listings(id) ON DELETE SET NULL, - reviewer_id UUID REFERENCES users(id), - reviewee_id UUID REFERENCES users(id), - listing_rating INT CHECK (listing_rating >= 1 AND listing_rating <= 5), - seller_rating INT CHECK (seller_rating >= 1 AND seller_rating <= 5), - comment TEXT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - UNIQUE(listing_id, reviewer_id) -); -CREATE TRIGGER trg_reviews_updated_at BEFORE UPDATE ON reviews FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- 10. CHAT & COMMUNICATIONS --- ====================================================== -CREATE TABLE conversations ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - buyer_id UUID NOT NULL REFERENCES users(id), - seller_id UUID NOT NULL REFERENCES users(id), - - -- Denormalized Fields for Listing - last_message_content TEXT, - last_message_at TIMESTAMPTZ, - last_call_at TIMESTAMPTZ, - - deleted BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(buyer_id, seller_id) -); - -CREATE TABLE messages ( - id BIGSERIAL PRIMARY KEY, -- Changed to BIGINT for cursor pagination - conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, - sender_id UUID NOT NULL REFERENCES users(id), - receiver_id UUID NOT NULL REFERENCES users(id), - - message_type message_type_enum NOT NULL DEFAULT 'text', - content TEXT, - - -- Embedded Media - message_media TEXT, - media_type media_type_enum, - - is_read BOOLEAN NOT NULL DEFAULT FALSE, - read_at TIMESTAMPTZ, - deleted BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE TRIGGER trg_messages_updated_at BEFORE UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- Efficient Index for Cursor Pagination: (conversation as filter, id as cursor) -CREATE INDEX idx_messages_pagination ON messages(conversation_id, id DESC) WHERE deleted = FALSE; - -CREATE TABLE communication_records ( - communication_id BIGSERIAL PRIMARY KEY, - conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL, - buyer_id UUID NOT NULL REFERENCES users(id), - seller_id UUID NOT NULL REFERENCES users(id), - communication_type communication_type_enum NOT NULL, - call_status call_status_enum NOT NULL, - duration_seconds INT DEFAULT 0, - call_recording_url TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE TRIGGER trg_communication_records_updated_at BEFORE UPDATE ON communication_records FOR EACH ROW EXECUTE FUNCTION set_updated_at(); - --- 11. AUTOMATION TRIGGERS --- ====================================================== - --- Trigger 1: Sync Animal/Location Data to Listing (The "Speed" Trigger) -CREATE OR REPLACE FUNCTION sync_listing_search_data() -RETURNS TRIGGER AS $$ -DECLARE - loc_geog GEOGRAPHY(POINT, 4326); -BEGIN - -- 1. Sync Animal Info - SELECT - species_id, breed_id, sex, age_months, - pregnancy_status, weight_kg, calving_number, milk_yield_litre_per_day - INTO - NEW.filter_species_id, NEW.filter_breed_id, NEW.filter_sex, NEW.filter_age_months, - NEW.filter_pregnancy_status, NEW.filter_weight_kg, NEW.filter_calving_number, NEW.filter_milking_capacity - FROM animals WHERE id = NEW.animal_id; - - -- 2. Sync Location Info - SELECT geog INTO loc_geog - FROM locations - WHERE id = (SELECT location_id FROM animals WHERE id = NEW.animal_id); - - NEW.filter_location_geog := loc_geog; - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_hydrate_listing -BEFORE INSERT OR UPDATE ON listings -FOR EACH ROW EXECUTE FUNCTION sync_listing_search_data(); - - --- Trigger 2: Auto-Update User Rating when Review is added -CREATE OR REPLACE FUNCTION update_seller_rating() -RETURNS TRIGGER AS $$ -BEGIN - UPDATE users - SET - rating_count = (SELECT COUNT(*) FROM reviews WHERE reviewee_id = NEW.reviewee_id), - rating_average = (SELECT AVG(seller_rating) FROM reviews WHERE reviewee_id = NEW.reviewee_id) - WHERE id = NEW.reviewee_id; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_update_rating -AFTER INSERT OR UPDATE ON reviews -FOR EACH ROW EXECUTE FUNCTION update_seller_rating(); - - --- Trigger 3: Auto-Update Conversation Last Message & Call -CREATE OR REPLACE FUNCTION update_conversation_latest() -RETURNS TRIGGER AS $$ -BEGIN - IF TG_TABLE_NAME = 'messages' THEN - UPDATE conversations - SET - last_message_content = NEW.content, - last_message_at = NEW.created_at, - updated_at = NOW() - WHERE id = NEW.conversation_id; - ELSIF TG_TABLE_NAME = 'communication_records' THEN - UPDATE conversations - SET - last_call_at = NEW.created_at, - updated_at = NOW() - WHERE id = NEW.conversation_id; - END IF; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trg_update_conversation_msg -AFTER INSERT ON messages -FOR EACH ROW EXECUTE FUNCTION update_conversation_latest(); - -CREATE TRIGGER trg_update_conversation_call -AFTER INSERT ON communication_records -FOR EACH ROW EXECUTE FUNCTION update_conversation_latest(); - --- ====================================================== --- END OF SCRIPT +-- ====================================================== +-- LIVESTOCK MARKETPLACE - COMPLETE PRODUCTION SCHEMA +-- ====================================================== + +-- 1. EXTENSIONS +-- ====================================================== +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "postgis"; -- Crucial for "Near Me" filters + +SET timezone TO 'UTC'; + +-- 2. GLOBAL FUNCTIONS (Timestamps) +-- ====================================================== +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 3. ENUM TYPES +-- ====================================================== +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'sex_enum') THEN + CREATE TYPE sex_enum AS ENUM ('M','F','Neutered'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'purpose_enum') THEN + CREATE TYPE purpose_enum AS ENUM ('dairy','meat','breeding','pet','work','other'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'health_status_enum') THEN + CREATE TYPE health_status_enum AS ENUM ('healthy','minor_issues','serious_issues'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'location_type_enum') THEN + CREATE TYPE location_type_enum AS ENUM ('farm','home','office','temporary_gps','other_saved','other'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'source_type_enum') THEN + CREATE TYPE source_type_enum AS ENUM ('gps','device_gps','manual','imported','unknown'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'listing_type_enum') THEN + CREATE TYPE listing_type_enum AS ENUM ('sale','stud_service','adoption','other'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'listing_status_enum') THEN + CREATE TYPE listing_status_enum AS ENUM ('active','sold','expired','hidden','deleted'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'seller_type_enum') THEN + CREATE TYPE seller_type_enum AS ENUM ('owner','farmer','broker','agent','other'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'listing_role_enum') THEN + CREATE TYPE listing_role_enum AS ENUM ('seller_buyer','service_provider'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'user_type_enum') THEN + CREATE TYPE user_type_enum AS ENUM ('user','admin','moderator'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'message_type_enum') THEN + CREATE TYPE message_type_enum AS ENUM ('text','media','both'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'media_type_enum') THEN + CREATE TYPE media_type_enum AS ENUM ('image','video','audio','document'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'communication_type_enum') THEN + CREATE TYPE communication_type_enum AS ENUM ('call','missed_call','voicemail'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'call_status_enum') THEN + CREATE TYPE call_status_enum AS ENUM ('initiated','ringing','answered','completed','failed','busy','no_answer'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'pregnancy_status_enum') THEN + CREATE TYPE pregnancy_status_enum AS ENUM ('none', 'pregnant', 'unknown'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'requirement_status_enum') THEN + CREATE TYPE requirement_status_enum AS ENUM ('open', 'fulfilled', 'cancelled'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'analytics_event_type_enum') THEN + CREATE TYPE analytics_event_type_enum AS ENUM ('views_count','bookmarks_count','enquiries_call_count','enquiries_whatsapp_count'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'listing_score_status_enum') THEN + CREATE TYPE listing_score_status_enum AS ENUM ('pending', 'processing', 'completed', 'failed'); + END IF; + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'source_confidence_enum') THEN + CREATE TYPE source_confidence_enum AS ENUM ('high', 'medium', 'low', 'unknown'); + END IF; +END $$; + +-- 4. USERS & AUTHENTICATION +-- ====================================================== +CREATE TABLE subscription_plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + price NUMERIC(10, 2), + currency VARCHAR(10) DEFAULT 'INR', + duration_days INT, -- Validity in days + features JSONB, -- Flexible JSON for features like {"max_listings": 10} + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TRIGGER trg_subscription_plans_updated_at BEFORE UPDATE ON subscription_plans FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + phone_number VARCHAR(20) UNIQUE, + name VARCHAR(255), + avatar_url TEXT, + language VARCHAR(10), + timezone VARCHAR(50), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + roles listing_role_enum[] NOT NULL DEFAULT '{seller_buyer}', + active_role listing_role_enum NOT NULL DEFAULT 'seller_buyer', + user_type user_type_enum NOT NULL DEFAULT 'user', + country_code VARCHAR(10) NOT NULL DEFAULT '+91', + + -- Legacy role field (for backward compatibility with auth code) + role listing_role_enum, -- Deprecated: use user_type for system roles, roles[] for marketplace roles + + -- Token version for global logout + token_version INT NOT NULL DEFAULT 1, + + -- Seller Ratings (Cached for speed) + rating_average NUMERIC(3, 2) DEFAULT 0.00, + rating_count INT DEFAULT 0, + + -- Subscription Info + subscription_plan_id UUID REFERENCES subscription_plans(id) ON DELETE SET NULL, + subscription_expires_at TIMESTAMPTZ, + + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +CREATE TABLE otp_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + phone_number VARCHAR(20) NOT NULL, + country_code VARCHAR(10) NOT NULL DEFAULT '+91', + otp_hash VARCHAR(255) NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + attempt_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_otp_phone_unconsumed ON otp_requests (phone_number) WHERE consumed_at IS NULL; + +CREATE TABLE user_devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_identifier TEXT, + device_platform TEXT NOT NULL, + device_model TEXT, + os_version TEXT, + app_version TEXT, + language_code TEXT, + timezone TEXT, + fcm_token TEXT, -- For Push Notifications + lat NUMERIC(10,7), + lng NUMERIC(10,7), + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, device_identifier) +); +CREATE INDEX idx_user_devices_user ON user_devices (user_id); +CREATE INDEX idx_user_devices_device_identifier ON user_devices (device_identifier); +CREATE INDEX idx_user_devices_platform ON user_devices (device_platform); +CREATE INDEX idx_user_devices_last_seen ON user_devices (last_seen_at); + +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_id VARCHAR(255) NOT NULL, + token_id UUID NOT NULL UNIQUE, + token_hash VARCHAR(255) NOT NULL, + user_agent TEXT, + ip_address VARCHAR(45), + expires_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked_at TIMESTAMPTZ, + reuse_detected_at TIMESTAMPTZ, + rotated_from_id UUID REFERENCES refresh_tokens(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_refresh_tokens_user_device ON refresh_tokens(user_id, device_id); +CREATE INDEX idx_refresh_tokens_expires ON refresh_tokens(expires_at); +CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); +CREATE INDEX idx_refresh_tokens_revoked ON refresh_tokens(revoked_at); + +-- Authentication Audit Logging (for security monitoring) +CREATE TABLE auth_audit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action VARCHAR(100) NOT NULL, + status VARCHAR(50) NOT NULL, + risk_level VARCHAR(20) DEFAULT 'INFO', + device_id VARCHAR(255), + ip_address VARCHAR(45), + user_agent TEXT, + meta JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_auth_audit_user ON auth_audit(user_id); +CREATE INDEX idx_auth_audit_created ON auth_audit(created_at); +CREATE INDEX idx_auth_audit_action ON auth_audit(action); +CREATE INDEX idx_auth_audit_status ON auth_audit(status); +CREATE INDEX idx_auth_audit_risk_level ON auth_audit(risk_level); + +-- 5. MASTER DATA (Species & Breeds) +-- ====================================================== +CREATE TABLE species ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(120) NOT NULL UNIQUE, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE breeds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + species_id UUID NOT NULL REFERENCES species(id) ON DELETE RESTRICT, + name VARCHAR(150) NOT NULL, + description TEXT, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(species_id, name) +); + +-- 6. LOCATIONS (Optimized with PostGIS) +-- ====================================================== +CREATE TABLE locations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + lat NUMERIC(10,7), + lng NUMERIC(10,7), + + -- AUTOMATIC GEOGRAPHY COLUMN (The Magic for 'Near Me' queries) + -- This automatically creates a spatial point whenever you save lat/lng + geog GEOGRAPHY(POINT, 4326) GENERATED ALWAYS AS ( + CASE WHEN lat IS NOT NULL AND lng IS NOT NULL + THEN ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography + ELSE NULL END + ) STORED, + + is_saved_address BOOLEAN NOT NULL DEFAULT FALSE, + location_type location_type_enum, + country VARCHAR(100), + state VARCHAR(100), + district VARCHAR(100), + city_village VARCHAR(150), + pincode VARCHAR(20), + + source_type source_type_enum DEFAULT 'unknown', + source_confidence source_confidence_enum DEFAULT 'unknown', + selected_location BOOLEAN NOT NULL DEFAULT FALSE, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_locations_geog ON locations USING GIST (geog); -- Spatial Index +CREATE INDEX idx_locations_user ON locations (user_id); +CREATE INDEX idx_locations_lat_lng ON locations (lat, lng); +CREATE TRIGGER trg_locations_updated_at BEFORE UPDATE ON locations FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + + +-- 7. ANIMALS (The Inventory) +-- ====================================================== +CREATE TABLE animals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + species_id UUID NOT NULL REFERENCES species(id) ON DELETE RESTRICT, + breed_id UUID REFERENCES breeds(id) ON DELETE SET NULL, + location_id UUID REFERENCES locations(id) ON DELETE RESTRICT, + + sex sex_enum, + age_months INT, + weight_kg NUMERIC(8,2), + color_markings VARCHAR(255), + quantity INT NOT NULL DEFAULT 1, + purpose purpose_enum, + health_status health_status_enum DEFAULT 'healthy', + vaccinated BOOLEAN DEFAULT FALSE, + dewormed BOOLEAN DEFAULT FALSE, + pregnancy_status pregnancy_status_enum NOT NULL DEFAULT 'unknown', + calving_number INT, + milk_yield_litre_per_day NUMERIC(8,3), + ear_tag_no VARCHAR(100), + description TEXT, + + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TRIGGER trg_animals_updated_at BEFORE UPDATE ON animals FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- 8. LISTINGS (Denormalized for Read Performance) +-- ====================================================== +CREATE TABLE listings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + seller_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + animal_id UUID NOT NULL UNIQUE REFERENCES animals(id) ON DELETE CASCADE, + + title VARCHAR(255) NOT NULL, + price NUMERIC(12,2), + currency VARCHAR(10) DEFAULT 'INR', + is_negotiable BOOLEAN DEFAULT FALSE, + listing_type listing_type_enum DEFAULT 'sale', + status listing_status_enum DEFAULT 'active', + + -- Counters (Updated via Batch Jobs) + views_count BIGINT DEFAULT 0, + bookmarks_count BIGINT DEFAULT 0, + enquiries_call_count BIGINT DEFAULT 0, + enquiries_whatsapp_count BIGINT DEFAULT 0, + + -- DENORMALIZED COLUMNS (Auto-filled by Trigger) + -- These exist so the Home Feed doesn't need to join 4 tables + filter_species_id UUID, + filter_breed_id UUID, + filter_sex sex_enum, + filter_age_months INT, + filter_pregnancy_status pregnancy_status_enum, + filter_weight_kg NUMERIC(8,2), + filter_calving_number INT, + filter_milking_capacity NUMERIC(8,3), + filter_location_geog GEOGRAPHY(POINT, 4326), + + -- AI Score + listing_score INT, + listing_score_status listing_score_status_enum DEFAULT 'pending', + + thumbnail_url TEXT, + + -- FULL TEXT SEARCH (Auto-generated) + search_vector tsvector GENERATED ALWAYS AS ( + to_tsvector('english', title) + ) STORED, + + deleted_reason TEXT, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TRIGGER trg_listings_updated_at BEFORE UPDATE ON listings FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + + +-- OPTIMIZED INDEXES +-- 1. Main Feed: Status + Species + Price (ignoring deleted) +CREATE INDEX idx_listings_feed_optimized ON listings (status, filter_species_id, price) WHERE deleted = FALSE; +-- 2. Near Me: Spatial Search +CREATE INDEX idx_listings_spatial ON listings USING GIST (filter_location_geog) WHERE deleted = FALSE; +-- 3. Search Bar: Text Search +CREATE INDEX idx_listings_search_gin ON listings USING GIN (search_vector); +-- 4. My Listings: Seller + Status +CREATE INDEX idx_listings_seller_status ON listings (seller_id, status) WHERE deleted = FALSE; + +CREATE TABLE listing_media ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + listing_id UUID NOT NULL REFERENCES listings(id) ON DELETE CASCADE, + media_url TEXT NOT NULL, + media_type media_type_enum NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INT NOT NULL DEFAULT 0, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE sold_information ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + listing_id UUID NOT NULL UNIQUE REFERENCES listings(id) ON DELETE CASCADE, + sold_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + sale_price NUMERIC(12, 2), + sale_location_id UUID REFERENCES locations(id) ON DELETE SET NULL, + sale_date TIMESTAMPTZ, + notes TEXT, + attachment_urls TEXT[], + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TRIGGER trg_sold_information_updated_at BEFORE UPDATE ON sold_information FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- 9. ENGAGEMENT (Reviews, Analytics, Retention) +-- ====================================================== +CREATE TABLE custom_requirements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + animal_id UUID REFERENCES animals(id) ON DELETE SET NULL, + requirement_text TEXT NOT NULL, + status requirement_status_enum NOT NULL DEFAULT 'open', + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TRIGGER trg_custom_requirements_updated_at BEFORE UPDATE ON custom_requirements FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- New: High-speed analytics buffer +CREATE TABLE listing_analytics_events ( + id BIGSERIAL PRIMARY KEY, + listing_id UUID NOT NULL REFERENCES listings(id) ON DELETE CASCADE, + event_type analytics_event_type_enum NOT NULL, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Table for favorites +CREATE TABLE favorites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + listing_id UUID NOT NULL REFERENCES listings(id) ON DELETE CASCADE, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, listing_id) +); +CREATE TRIGGER trg_favorites_updated_at BEFORE UPDATE ON favorites FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- New: Reviews & Ratings +CREATE TABLE reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + listing_id UUID REFERENCES listings(id) ON DELETE SET NULL, + reviewer_id UUID REFERENCES users(id), + reviewee_id UUID REFERENCES users(id), + listing_rating INT CHECK (listing_rating >= 1 AND listing_rating <= 5), + seller_rating INT CHECK (seller_rating >= 1 AND seller_rating <= 5), + comment TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(listing_id, reviewer_id) +); +CREATE TRIGGER trg_reviews_updated_at BEFORE UPDATE ON reviews FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- 10. CHAT & COMMUNICATIONS +-- ====================================================== +CREATE TABLE conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + buyer_id UUID NOT NULL REFERENCES users(id), + seller_id UUID NOT NULL REFERENCES users(id), + + -- Denormalized Fields for Listing + last_message_content TEXT, + last_message_at TIMESTAMPTZ, + last_call_at TIMESTAMPTZ, + + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(buyer_id, seller_id) +); + +CREATE TABLE messages ( + id BIGSERIAL PRIMARY KEY, -- Changed to BIGINT for cursor pagination + conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES users(id), + receiver_id UUID NOT NULL REFERENCES users(id), + + message_type message_type_enum NOT NULL DEFAULT 'text', + content TEXT, + + -- Embedded Media + message_media TEXT, + media_type media_type_enum, + + is_read BOOLEAN NOT NULL DEFAULT FALSE, + read_at TIMESTAMPTZ, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TRIGGER trg_messages_updated_at BEFORE UPDATE ON messages FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- Efficient Index for Cursor Pagination: (conversation as filter, id as cursor) +CREATE INDEX idx_messages_pagination ON messages(conversation_id, id DESC) WHERE deleted = FALSE; + +CREATE TABLE communication_records ( + communication_id BIGSERIAL PRIMARY KEY, + conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL, + buyer_id UUID NOT NULL REFERENCES users(id), + seller_id UUID NOT NULL REFERENCES users(id), + communication_type communication_type_enum NOT NULL, + call_status call_status_enum NOT NULL, + duration_seconds INT DEFAULT 0, + call_recording_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE TRIGGER trg_communication_records_updated_at BEFORE UPDATE ON communication_records FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + +-- 11. AUTOMATION TRIGGERS +-- ====================================================== + +-- Trigger 1: Sync Animal/Location Data to Listing (The "Speed" Trigger) +CREATE OR REPLACE FUNCTION sync_listing_search_data() +RETURNS TRIGGER AS $$ +DECLARE + loc_geog GEOGRAPHY(POINT, 4326); +BEGIN + -- 1. Sync Animal Info + SELECT + species_id, breed_id, sex, age_months, + pregnancy_status, weight_kg, calving_number, milk_yield_litre_per_day + INTO + NEW.filter_species_id, NEW.filter_breed_id, NEW.filter_sex, NEW.filter_age_months, + NEW.filter_pregnancy_status, NEW.filter_weight_kg, NEW.filter_calving_number, NEW.filter_milking_capacity + FROM animals WHERE id = NEW.animal_id; + + -- 2. Sync Location Info + SELECT geog INTO loc_geog + FROM locations + WHERE id = (SELECT location_id FROM animals WHERE id = NEW.animal_id); + + NEW.filter_location_geog := loc_geog; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_hydrate_listing +BEFORE INSERT OR UPDATE ON listings +FOR EACH ROW EXECUTE FUNCTION sync_listing_search_data(); + + +-- Trigger 2: Auto-Update User Rating when Review is added +CREATE OR REPLACE FUNCTION update_seller_rating() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE users + SET + rating_count = (SELECT COUNT(*) FROM reviews WHERE reviewee_id = NEW.reviewee_id), + rating_average = (SELECT AVG(seller_rating) FROM reviews WHERE reviewee_id = NEW.reviewee_id) + WHERE id = NEW.reviewee_id; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_update_rating +AFTER INSERT OR UPDATE ON reviews +FOR EACH ROW EXECUTE FUNCTION update_seller_rating(); + + +-- Trigger 3: Auto-Update Conversation Last Message & Call +CREATE OR REPLACE FUNCTION update_conversation_latest() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_TABLE_NAME = 'messages' THEN + UPDATE conversations + SET + last_message_content = NEW.content, + last_message_at = NEW.created_at, + updated_at = NOW() + WHERE id = NEW.conversation_id; + ELSIF TG_TABLE_NAME = 'communication_records' THEN + UPDATE conversations + SET + last_call_at = NEW.created_at, + updated_at = NOW() + WHERE id = NEW.conversation_id; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_update_conversation_msg +AFTER INSERT ON messages +FOR EACH ROW EXECUTE FUNCTION update_conversation_latest(); + +CREATE TRIGGER trg_update_conversation_call +AFTER INSERT ON communication_records +FOR EACH ROW EXECUTE FUNCTION update_conversation_latest(); + +-- ====================================================== +-- END OF SCRIPT -- ====================================================== \ No newline at end of file diff --git a/routes/listingRoutes.js b/routes/listingRoutes.js index 4d2204c..bcb61e2 100644 --- a/routes/listingRoutes.js +++ b/routes/listingRoutes.js @@ -8,6 +8,8 @@ const createNewLocation = async (client, userId, locationData) => { lat, lng, source_type, + source_confidence, + selected_location, // Location Details is_saved_address, location_type, @@ -18,37 +20,42 @@ const createNewLocation = async (client, userId, locationData) => { pincode, } = locationData; - // 1a. Insert into locations + // 1. Insert into locations (Merged Table) const insertLocationQuery = ` - INSERT INTO locations (user_id, lat, lng, source_type) - VALUES ($1, $2, $3, $4) - RETURNING id - `; - const locationValues = [userId, lat, lng, source_type || "manual"]; - const locationResult = await client.query(insertLocationQuery, locationValues); - const locationId = locationResult.rows[0].id; - - // 1b. Insert into location_details - const insertLocationDetailsQuery = ` - INSERT INTO location_details ( - location_id, is_saved_address, location_type, + INSERT INTO locations ( + user_id, lat, lng, source_type, source_confidence, selected_location, + is_saved_address, location_type, country, state, district, city_village, pincode ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING id `; - const detailsValues = [ - locationId, - is_saved_address || false, + const locationValues = [ + userId, + lat, + lng, + source_type || "manual", + source_confidence || "unknown", + selected_location || false, + is_saved_address || false, location_type || "other", country, state, district, city_village, - pincode, - ]; - await client.query(insertLocationDetailsQuery, detailsValues); - - return locationId; + pincode + ]; + + try { + const locationResult = await client.query(insertLocationQuery, locationValues); + if (locationResult.rows.length === 0) { + throw new Error("Failed to insert location record"); + } + return locationResult.rows[0].id; + } catch (error) { + console.error("Error creating new location:", error); + throw error; // Propagate error to trigger transaction rollback + } }; // 1. GET / (Main Feed) - Optimized with idx_listings_feed_optimized @@ -64,12 +71,76 @@ router.get("/", async (req, res) => { const queryParams = [status]; let paramCount = 1; - if (species_id) { + if (species_id) { paramCount++; queryText += ` AND filter_species_id = $${paramCount}`; queryParams.push(species_id); } + // New Filters + if (req.query.breed_id) { + paramCount++; + queryText += ` AND filter_breed_id = $${paramCount}`; + queryParams.push(req.query.breed_id); + } + + if (req.query.sex) { + paramCount++; + queryText += ` AND filter_sex = $${paramCount}`; + queryParams.push(req.query.sex); + } + + if (req.query.pregnancy_status) { + paramCount++; + queryText += ` AND filter_pregnancy_status = $${paramCount}`; + queryParams.push(req.query.pregnancy_status); + } + + // Range Filters + if (req.query.age_min) { + paramCount++; + queryText += ` AND filter_age_months >= $${paramCount}`; + queryParams.push(req.query.age_min); + } + if (req.query.age_max) { + paramCount++; + queryText += ` AND filter_age_months <= $${paramCount}`; + queryParams.push(req.query.age_max); + } + + if (req.query.weight_min) { + paramCount++; + queryText += ` AND filter_weight_kg >= $${paramCount}`; + queryParams.push(req.query.weight_min); + } + if (req.query.weight_max) { + paramCount++; + queryText += ` AND filter_weight_kg <= $${paramCount}`; + queryParams.push(req.query.weight_max); + } + + if (req.query.calving_number_min) { + paramCount++; + queryText += ` AND filter_calving_number >= $${paramCount}`; + queryParams.push(req.query.calving_number_min); + } + if (req.query.calving_number_max) { + paramCount++; + queryText += ` AND filter_calving_number <= $${paramCount}`; + queryParams.push(req.query.calving_number_max); + } + + if (req.query.milking_capacity_min) { + paramCount++; + queryText += ` AND filter_milking_capacity >= $${paramCount}`; + queryParams.push(req.query.milking_capacity_min); + } + if (req.query.milking_capacity_max) { + paramCount++; + queryText += ` AND filter_milking_capacity <= $${paramCount}`; + queryParams.push(req.query.milking_capacity_max); + } + if (price_min) { paramCount++; queryText += ` AND price >= $${paramCount}`; @@ -190,33 +261,15 @@ router.post("/", async (req, res) => { currency, is_negotiable, listing_type, - status, - // Animal details - species_id, - breed_id, - location_id, - sex, - age_months, - weight_kg, - color_markings, - quantity, - purpose, - health_status, - vaccinated, - dewormed, - pregnancy_status, - milk_yield_litre_per_day, - ear_tag_no, - description, - // New Location details - new_location, + animal, + media // Array of { media_url, media_type, is_primary, sort_order } } = req.body; - let final_location_id = location_id; + let final_location_id = animal?.location_id; // 1. Create Location (if needed) - if (!final_location_id && new_location) { - final_location_id = await createNewLocation(client, seller_id, new_location); + if (!final_location_id && animal?.new_location) { + final_location_id = await createNewLocation(client, seller_id, animal.new_location); } // 2. Create Animal @@ -224,28 +277,29 @@ router.post("/", async (req, res) => { INSERT INTO animals ( species_id, breed_id, location_id, sex, age_months, weight_kg, color_markings, quantity, purpose, health_status, vaccinated, - dewormed, pregnancy_status, milk_yield_litre_per_day, ear_tag_no, description + dewormed, pregnancy_status, calving_number, milk_yield_litre_per_day, ear_tag_no, description ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING id `; const animalValues = [ - species_id, - breed_id, + animal.species_id, + animal.breed_id, final_location_id, - sex, - age_months, - weight_kg, - color_markings, - quantity || 1, // Default to 1 - purpose, - health_status || "healthy", // Default - vaccinated || false, - dewormed || false, - pregnancy_status || "unknown", // Default - milk_yield_litre_per_day, - ear_tag_no, - description, + animal.sex, + animal.age_months, + animal.weight_kg, + animal.color_markings, + animal.quantity || 1, // Default to 1 + animal.purpose, + animal.health_status || "healthy", // Default + animal.vaccinated || false, + animal.dewormed || false, + animal.pregnancy_status || "unknown", // Default + animal.calving_number, // Added + animal.milk_yield_litre_per_day, + animal.ear_tag_no, + animal.description, ]; const animalResult = await client.query(insertAnimalQuery, animalValues); @@ -253,8 +307,8 @@ router.post("/", async (req, res) => { // 3. Create Listing const insertListingQuery = ` - INSERT INTO listings (seller_id, animal_id, title, price, currency, is_negotiable, listing_type, status) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO listings (seller_id, animal_id, title, price, currency, is_negotiable, listing_type) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * `; const listingValues = [ @@ -264,11 +318,29 @@ router.post("/", async (req, res) => { price, currency, is_negotiable, - listing_type, - status, + listing_type ]; const listingResult = await client.query(insertListingQuery, listingValues); + const listing_id = listingResult.rows[0].id; + + // 4. Create Listing Media + if (media && media.length > 0) { + const mediaInsertQuery = ` + INSERT INTO listing_media (listing_id, media_url, media_type, is_primary, sort_order) + VALUES ($1, $2, $3, $4, $5) + `; + + for (const item of media) { + await client.query(mediaInsertQuery, [ + listing_id, + item.media_url, + item.media_type, // 'image', 'video' + item.is_primary || false, + item.sort_order || 0 + ]); + } + } await client.query("COMMIT"); @@ -397,4 +469,51 @@ router.get("/user/:userId/favorites", async (req, res) => { } }); +// Get listings created by user +router.get("/user/:userId", async (req, res) => { + try { + const { userId } = req.params; + const queryText = ` + SELECT l.*, row_to_json(a) as animal + FROM listings l + JOIN animals a ON l.animal_id = a.id + WHERE l.deleted = FALSE AND l.seller_id = $1 + ORDER BY l.created_at DESC + `; + const result = await pool.query(queryText, [userId]); + res.json(result.rows); + } catch (err) { + console.error("Error fetching user listings:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); + +// Update listing score & status +router.patch("/:id/score", async (req, res) => { + try { + const { id } = req.params; + const { listing_score, listing_score_status } = req.body; + + const queryText = ` + UPDATE listings + SET listing_score = COALESCE($1, listing_score), + listing_score_status = COALESCE($2, listing_score_status) + WHERE id = $3 AND deleted = FALSE + RETURNING * + `; + const queryParams = [listing_score, listing_score_status, id]; + + const result = await pool.query(queryText, queryParams); + + if (result.rows.length === 0) { + return res.status(404).json({ error: "Listing not found" }); + } + + res.json(result.rows[0]); + } catch (err) { + console.error("Error updating listing:", err); + res.status(500).json({ error: "Internal server error" }); + } +}); + export default router; diff --git a/routes/locationRoutes.js b/routes/locationRoutes.js index 7fc64ce..3d5b35c 100644 --- a/routes/locationRoutes.js +++ b/routes/locationRoutes.js @@ -14,7 +14,8 @@ router.post("/", async (req, res) => { lat, lng, source_type, - // Location Details + source_confidence, + selected_location, is_saved_address, location_type, country, @@ -26,25 +27,20 @@ router.post("/", async (req, res) => { // 1. Insert into locations const insertLocationQuery = ` - INSERT INTO locations (user_id, lat, lng, source_type) - VALUES ($1, $2, $3, $4) - RETURNING id, user_id, lat, lng, source_type, created_at - `; - const locationValues = [user_id, lat, lng, source_type || "manual"]; - const locationResult = await client.query(insertLocationQuery, locationValues); - const location = locationResult.rows[0]; - - // 2. Insert into location_details - const insertDetailsQuery = ` - INSERT INTO location_details ( - location_id, is_saved_address, location_type, - country, state, district, city_village, pincode + INSERT INTO locations ( + user_id, lat, lng, source_type, source_confidence, selected_location, + is_saved_address, location_type, country, state, district, city_village, pincode ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING * `; - const detailsValues = [ - location.id, + const locationValues = [ + user_id, + lat, + lng, + source_type || "manual", + source_confidence || "low", + selected_location || false, is_saved_address || false, location_type || "other", country, @@ -53,13 +49,12 @@ router.post("/", async (req, res) => { city_village, pincode, ]; - const detailsResult = await client.query(insertDetailsQuery, detailsValues); + const locationResult = await client.query(insertLocationQuery, locationValues); await client.query("COMMIT"); res.status(201).json({ - ...location, - details: detailsResult.rows[0], + ...locationResult.rows[0], }); } catch (err) { await client.query("ROLLBACK"); @@ -75,11 +70,9 @@ router.get("/user/:userId", async (req, res) => { try { const { userId } = req.params; const queryText = ` - SELECT l.*, row_to_json(ld) as details - FROM locations l - LEFT JOIN location_details ld ON l.id = ld.location_id - WHERE l.user_id = $1 AND l.deleted = FALSE - ORDER BY l.created_at DESC + SELECT * FROM locations + WHERE user_id = $1 AND deleted = FALSE + ORDER BY created_at DESC `; const result = await pool.query(queryText, [userId]); res.json(result.rows); @@ -94,10 +87,8 @@ router.get("/:id", async (req, res) => { try { const { id } = req.params; const queryText = ` - SELECT l.*, row_to_json(ld) as details - FROM locations l - LEFT JOIN location_details ld ON l.id = ld.location_id - WHERE l.id = $1 AND l.deleted = FALSE + SELECT * FROM locations + WHERE id = $1 AND deleted = FALSE `; const result = await pool.query(queryText, [id]); @@ -114,14 +105,14 @@ router.get("/:id", async (req, res) => { // 4. UPDATE Location router.put("/:id", async (req, res) => { - const client = await pool.connect(); try { const { id } = req.params; const { lat, lng, source_type, - // Location Details + source_confidence, + selected_location, is_saved_address, location_type, country, @@ -131,62 +122,40 @@ router.put("/:id", async (req, res) => { pincode, } = req.body; - await client.query("BEGIN"); - - // 1. Update locations - const updateLocationQuery = ` + const updateQuery = ` UPDATE locations SET lat = COALESCE($1, lat), lng = COALESCE($2, lng), - source_type = COALESCE($3, source_type) - WHERE id = $4 AND deleted = FALSE + source_type = COALESCE($3, source_type), + source_confidence = COALESCE($4, source_confidence), + selected_location = COALESCE($5, selected_location), + is_saved_address = COALESCE($6, is_saved_address), + location_type = COALESCE($7, location_type), + country = COALESCE($8, country), + state = COALESCE($9, state), + district = COALESCE($10, district), + city_village = COALESCE($11, city_village), + pincode = COALESCE($12, pincode) + WHERE id = $13 AND deleted = FALSE RETURNING * `; - const locationResult = await client.query(updateLocationQuery, [lat, lng, source_type, id]); + + const values = [ + lat, lng, source_type, source_confidence, selected_location, + is_saved_address, location_type, country, state, district, city_village, pincode, + id + ]; - if (locationResult.rows.length === 0) { - await client.query("ROLLBACK"); + const result = await pool.query(updateQuery, values); + + if (result.rows.length === 0) { return res.status(404).json({ error: "Location not found" }); } - // 2. Update location_details - // Note: location_details might not exist depending on legacy data, so we use ON CONFLICT or just UPDATE/INSERT logic. - // For simplicity and assuming details always exist creation: - const updateDetailsQuery = ` - UPDATE location_details - SET is_saved_address = COALESCE($1, is_saved_address), - location_type = COALESCE($2, location_type), - country = COALESCE($3, country), - state = COALESCE($4, state), - district = COALESCE($5, district), - city_village = COALESCE($6, city_village), - pincode = COALESCE($7, pincode) - WHERE location_id = $8 - RETURNING * - `; - const detailsResult = await client.query(updateDetailsQuery, [ - is_saved_address, - location_type, - country, - state, - district, - city_village, - pincode, - id, - ]); - - await client.query("COMMIT"); - - res.json({ - ...locationResult.rows[0], - details: detailsResult.rows[0] || null, - }); + res.json(result.rows[0]); } catch (err) { - await client.query("ROLLBACK"); console.error("Error updating location:", err); res.status(500).json({ error: "Internal server error" }); - } finally { - client.release(); } }); @@ -206,11 +175,6 @@ router.delete("/:id", async (req, res) => { return res.status(404).json({ error: "Location not found" }); } - // Optionally mark details as deleted if they had a deleted column, but they don't seem to based on schema view earlier? - // Checking schema: location_details has 'deleted'. - - await pool.query("UPDATE location_details SET deleted = TRUE WHERE location_id = $1", [id]); - res.json({ message: "Location deleted successfully" }); } catch (err) { console.error("Error deleting location:", err);