diff --git a/docs/database/patbond_postgresql.sql b/docs/database/patbond_postgresql.sql new file mode 100644 index 0000000..8f0c49b --- /dev/null +++ b/docs/database/patbond_postgresql.sql @@ -0,0 +1,1950 @@ +/* + Patbond PostgreSQL bootstrap schema + Version: 1.0 + Target: PostgreSQL 16+ + + Execute on an empty database: + psql -v ON_ERROR_STOP=1 -d patbond -f docs/database/patbond_postgresql.sql + + Design source: the current Flutter home, creation, pet-health, marketplace, + post-detail and profile pages. This is a one-time bootstrap file, not a + repeatable migration. After backend development starts, split it into owned + Flyway migrations and never edit an already released migration. + + This file is the reviewed target schema. Backend delivery should still be + phased: identity/media -> pet health -> community -> creation -> marketplace. + A table appearing here does not require generating every repository in V1. + + Logical ownership: + identity account, credentials, refresh sessions, profile preferences + media object-storage metadata; binary content is never stored here + pet_health pets, weights, vaccinations, reminders, health timeline + community posts, media, flat comments, likes, bookmarks, follows, topics + creation asynchronous AI generation jobs and configurable catalog + marketplace providers, offerings, reservable resources and bookings + platform regions, notifications and transactional outbox + + Important projections which are intentionally NOT stored as source facts: + - "2小时前/刚刚": format timestamptz in the API/client. + - provider distance: calculate from coordinates; never store "1.2km". + - weather and pet advice: external weather API + Redis TTL cache. + - vaccine progress/next dose: aggregate vaccination fact rows. + - profile post/follower/like counts: aggregate relation tables/read model. + - age and monthly spend: derive from birth_date and completed expenses. + + JSONB is restricted to variable AI parameters and outbox event payloads. + Core account, pet, community and booking facts remain relational. + + UUID note: gen_random_uuid() is a safe database fallback. At production write + volume, applications should generate time-ordered UUIDv7 values to reduce + B-tree page churn while retaining globally unique public IDs. + + Nearby search note: this portable bootstrap uses region + latitude/longitude + bounding-box indexes and a Haversine function. If nearby search becomes a core + large-scale workload (roughly >1,000 providers per region or radius paging), + migrate the coordinates to PostGIS geography(Point,4326) with a GiST index. +*/ + +BEGIN; + +CREATE EXTENSION IF NOT EXISTS pgcrypto; +CREATE EXTENSION IF NOT EXISTS citext; +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS btree_gist; + +CREATE SCHEMA platform; +CREATE SCHEMA identity; +CREATE SCHEMA media; +CREATE SCHEMA pet_health; +CREATE SCHEMA community; +CREATE SCHEMA creation; +CREATE SCHEMA marketplace; + +COMMENT ON SCHEMA platform IS 'Shared reference data, notifications and reliable outbox'; +COMMENT ON SCHEMA identity IS 'Accounts, credentials, sessions and user preferences'; +COMMENT ON SCHEMA media IS 'Metadata for objects stored in S3/OSS/MinIO or external fixtures'; +COMMENT ON SCHEMA pet_health IS 'Pet profile, health facts, vaccines and reminders'; +COMMENT ON SCHEMA community IS 'Posts and social interaction facts'; +COMMENT ON SCHEMA creation IS 'Asynchronous AI image/video generation'; +COMMENT ON SCHEMA marketplace IS 'Provider catalog, scheduling resources and bookings'; + +CREATE FUNCTION platform.set_updated_at() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at := clock_timestamp(); + RETURN NEW; +END; +$$; + +CREATE FUNCTION platform.distance_km( + latitude_a numeric, + longitude_a numeric, + latitude_b numeric, + longitude_b numeric +) +RETURNS double precision +LANGUAGE sql +IMMUTABLE +PARALLEL SAFE +RETURNS NULL ON NULL INPUT +AS $$ + SELECT 6371.0088 * 2 * asin( + sqrt( + least(1.0, greatest(0.0, + power(sin(radians(($3 - $1)::double precision) / 2), 2) + + cos(radians($1::double precision)) * + cos(radians($3::double precision)) * + power(sin(radians(($4 - $2)::double precision) / 2), 2) + )) + ) + ) +$$; + +-- Flat selectable region rows avoid recursive province/city/district queries. +CREATE TABLE platform.regions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(32) NOT NULL UNIQUE, + country_code char(2) NOT NULL DEFAULT 'CN', + province_name varchar(64) NOT NULL, + city_name varchar(64) NOT NULL, + district_name varchar(64) NOT NULL, + latitude numeric(9,6) NOT NULL, + longitude numeric(9,6) NOT NULL, + timezone varchar(64) NOT NULL DEFAULT 'Asia/Shanghai', + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_regions_code CHECK (code = btrim(code) AND char_length(code) BETWEEN 2 AND 32), + CONSTRAINT ck_regions_latitude CHECK (latitude BETWEEN -90 AND 90), + CONSTRAINT ck_regions_longitude CHECK (longitude BETWEEN -180 AND 180) +); + +CREATE INDEX ix_regions_city_district + ON platform.regions (city_name, district_name) + WHERE enabled; + +CREATE TABLE identity.users ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + username citext NOT NULL UNIQUE, + nickname varchar(32), + phone_e164 varchar(16), + email citext, + bio varchar(300), + avatar_asset_id uuid, + status varchar(16) NOT NULL DEFAULT 'active', + phone_verified_at timestamptz, + email_verified_at timestamptz, + last_login_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + version integer NOT NULL DEFAULT 0, + CONSTRAINT ck_users_username CHECK ( + username::text = btrim(username::text) + AND char_length(username::text) BETWEEN 3 AND 32 + ), + CONSTRAINT ck_users_nickname CHECK ( + nickname IS NULL OR (nickname = btrim(nickname) AND char_length(nickname) BETWEEN 1 AND 32) + ), + CONSTRAINT ck_users_phone CHECK ( + phone_e164 IS NULL OR phone_e164 ~ '^\+[1-9][0-9]{7,14}$' + ), + CONSTRAINT ck_users_status CHECK (status IN ('active', 'locked', 'disabled', 'deleted')), + CONSTRAINT ck_users_version CHECK (version >= 0), + CONSTRAINT ck_users_deleted_state CHECK ((status = 'deleted') = (deleted_at IS NOT NULL)) +); + +CREATE UNIQUE INDEX uq_users_phone + ON identity.users (phone_e164) + WHERE phone_e164 IS NOT NULL AND status <> 'deleted'; + +CREATE UNIQUE INDEX uq_users_email + ON identity.users (email) + WHERE email IS NOT NULL AND status <> 'deleted'; + +CREATE INDEX ix_users_avatar_asset ON identity.users (avatar_asset_id); + +CREATE TABLE identity.user_credentials ( + user_id uuid PRIMARY KEY REFERENCES identity.users(id) ON DELETE RESTRICT, + password_hash varchar(255) NOT NULL, + hash_algorithm varchar(16) NOT NULL DEFAULT 'bcrypt', + password_changed_at timestamptz NOT NULL DEFAULT now(), + failed_login_count integer NOT NULL DEFAULT 0, + failure_window_started_at timestamptz, + last_failed_at timestamptz, + locked_until timestamptz, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_credentials_algorithm CHECK (hash_algorithm IN ('bcrypt', 'argon2id')), + CONSTRAINT ck_credentials_failed_count CHECK (failed_login_count >= 0), + CONSTRAINT ck_credentials_hash CHECK (char_length(password_hash) BETWEEN 20 AND 255) +); + +CREATE TABLE identity.auth_sessions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, + token_family_id uuid NOT NULL, + refresh_token_hash bytea NOT NULL UNIQUE, + access_token_jti varchar(64), + device_id varchar(128), + device_name varchar(128), + user_agent varchar(512), + ip_address inet, + created_at timestamptz NOT NULL DEFAULT now(), + last_seen_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + revoke_reason varchar(128), + rotated_at timestamptz, + replaced_by_session_id uuid REFERENCES identity.auth_sessions(id) ON DELETE SET NULL, + UNIQUE (replaced_by_session_id), + CONSTRAINT ck_sessions_refresh_hash CHECK (octet_length(refresh_token_hash) = 32), + CONSTRAINT ck_sessions_expiry CHECK (expires_at > created_at), + CONSTRAINT ck_sessions_revoked_at CHECK (revoked_at IS NULL OR revoked_at >= created_at), + CONSTRAINT ck_sessions_rotation CHECK ( + replaced_by_session_id IS NULL + OR (replaced_by_session_id <> id AND revoked_at IS NOT NULL AND rotated_at IS NOT NULL) + ) +); + +CREATE INDEX ix_auth_sessions_user_created + ON identity.auth_sessions (user_id, created_at DESC); +CREATE INDEX ix_auth_sessions_active_expiry + ON identity.auth_sessions (expires_at) + WHERE revoked_at IS NULL; +CREATE INDEX ix_auth_sessions_family + ON identity.auth_sessions (token_family_id, created_at DESC); +CREATE UNIQUE INDEX uq_auth_sessions_access_jti + ON identity.auth_sessions (access_token_jti) + WHERE access_token_jti IS NOT NULL; + +CREATE TABLE identity.user_addresses ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, + region_id uuid NOT NULL REFERENCES platform.regions(id) ON DELETE RESTRICT, + label varchar(32) NOT NULL, + recipient_name varchar(64), + recipient_phone_e164 varchar(16), + address_line varchar(300) NOT NULL, + latitude numeric(9,6), + longitude numeric(9,6), + is_default boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT ck_user_addresses_label CHECK (label = btrim(label) AND char_length(label) BETWEEN 1 AND 32), + CONSTRAINT ck_user_addresses_line CHECK (address_line = btrim(address_line) AND char_length(address_line) BETWEEN 3 AND 300), + CONSTRAINT ck_user_addresses_phone CHECK ( + recipient_phone_e164 IS NULL OR recipient_phone_e164 ~ '^\+[1-9][0-9]{7,14}$' + ), + CONSTRAINT ck_user_addresses_coordinates CHECK ( + (latitude IS NULL AND longitude IS NULL) + OR ( + latitude IS NOT NULL AND longitude IS NOT NULL + AND latitude BETWEEN -90 AND 90 + AND longitude BETWEEN -180 AND 180 + ) + ) +); + +CREATE INDEX ix_user_addresses_user ON identity.user_addresses (user_id); +CREATE INDEX ix_user_addresses_region ON identity.user_addresses (region_id); +CREATE UNIQUE INDEX uq_user_addresses_default + ON identity.user_addresses (user_id) + WHERE is_default AND deleted_at IS NULL; + +CREATE TABLE identity.user_preferences ( + user_id uuid PRIMARY KEY REFERENCES identity.users(id) ON DELETE CASCADE, + default_region_id uuid REFERENCES platform.regions(id) ON DELETE SET NULL, + locale varchar(16) NOT NULL DEFAULT 'zh-CN', + timezone varchar(64) NOT NULL DEFAULT 'Asia/Shanghai', + allow_precise_location boolean NOT NULL DEFAULT false, + marketing_notifications boolean NOT NULL DEFAULT false, + community_notifications boolean NOT NULL DEFAULT true, + health_reminders boolean NOT NULL DEFAULT true, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_user_preferences_locale CHECK (char_length(locale) BETWEEN 2 AND 16), + CONSTRAINT ck_user_preferences_timezone CHECK (char_length(timezone) BETWEEN 3 AND 64) +); + +CREATE INDEX ix_user_preferences_region ON identity.user_preferences (default_region_id); + +CREATE TABLE media.assets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id uuid REFERENCES identity.users(id) ON DELETE RESTRICT, + kind varchar(16) NOT NULL, + purpose varchar(32) NOT NULL, + storage_type varchar(16) NOT NULL DEFAULT 'object', + bucket varchar(63), + object_key varchar(1024), + external_url varchar(1024), + mime_type varchar(127) NOT NULL, + byte_size bigint, + sha256 bytea, + width_px integer, + height_px integer, + duration_ms bigint, + status varchar(16) NOT NULL DEFAULT 'uploading', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + ready_at timestamptz, + deleted_at timestamptz, + CONSTRAINT ck_media_kind CHECK (kind IN ('image', 'video', 'document')), + CONSTRAINT ck_media_storage_type CHECK (storage_type IN ('object', 'external')), + CONSTRAINT ck_media_location CHECK ( + ( + storage_type = 'object' AND bucket IS NOT NULL + AND object_key IS NOT NULL AND char_length(btrim(object_key)) > 0 + AND external_url IS NULL + ) + OR ( + storage_type = 'external' + AND external_url IS NOT NULL AND char_length(btrim(external_url)) > 0 + AND bucket IS NULL AND object_key IS NULL + ) + ), + CONSTRAINT ck_media_size CHECK (byte_size IS NULL OR byte_size >= 0), + CONSTRAINT ck_media_hash CHECK (sha256 IS NULL OR octet_length(sha256) = 32), + CONSTRAINT ck_media_dimensions CHECK ( + (width_px IS NULL OR width_px > 0) + AND (height_px IS NULL OR height_px > 0) + AND (duration_ms IS NULL OR duration_ms >= 0) + ), + CONSTRAINT ck_media_status CHECK (status IN ('uploading', 'ready', 'failed', 'deleted')), + CONSTRAINT ck_media_ready CHECK (status <> 'ready' OR ready_at IS NOT NULL), + CONSTRAINT ck_media_deleted CHECK (status <> 'deleted' OR deleted_at IS NOT NULL) +); + +CREATE UNIQUE INDEX uq_media_object + ON media.assets (bucket, object_key) + WHERE storage_type = 'object'; +CREATE UNIQUE INDEX uq_media_external_url + ON media.assets (external_url) + WHERE storage_type = 'external'; +CREATE INDEX ix_media_owner_created ON media.assets (owner_user_id, created_at DESC); +CREATE INDEX ix_media_uploading_created + ON media.assets (created_at) + WHERE status = 'uploading'; + +ALTER TABLE identity.users + ADD CONSTRAINT fk_users_avatar_asset + FOREIGN KEY (avatar_asset_id) REFERENCES media.assets(id) ON DELETE SET NULL; + +CREATE TABLE pet_health.breeds ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + species varchar(16) NOT NULL, + code varchar(64) NOT NULL UNIQUE, + display_name varchar(64) NOT NULL, + enabled boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, species), + CONSTRAINT ck_breeds_species CHECK (species IN ('dog', 'cat', 'other')), + CONSTRAINT ck_breeds_names CHECK ( + code = btrim(code) AND display_name = btrim(display_name) + AND char_length(code) BETWEEN 2 AND 64 + AND char_length(display_name) BETWEEN 1 AND 64 + ) +); + +CREATE INDEX ix_breeds_species_order ON pet_health.breeds (species, sort_order, id) WHERE enabled; + +CREATE TABLE pet_health.pets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name varchar(64) NOT NULL, + species varchar(16) NOT NULL, + breed_id uuid, + custom_breed_name varchar(64), + sex varchar(8) NOT NULL DEFAULT 'unknown', + birth_date date, + birth_date_estimated boolean NOT NULL DEFAULT false, + personality varchar(64), + avatar_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL, + microchip_no citext, + sterilized_on date, + status varchar(16) NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + version integer NOT NULL DEFAULT 0, + CONSTRAINT ck_pets_name CHECK (name = btrim(name) AND char_length(name) BETWEEN 1 AND 64), + CONSTRAINT ck_pets_species CHECK (species IN ('dog', 'cat', 'other')), + CONSTRAINT ck_pets_sex CHECK (sex IN ('male', 'female', 'unknown')), + FOREIGN KEY (breed_id, species) + REFERENCES pet_health.breeds(id, species) ON DELETE RESTRICT, + CONSTRAINT ck_pets_breed CHECK ( + (breed_id IS NOT NULL AND custom_breed_name IS NULL) + OR ( + breed_id IS NULL AND custom_breed_name IS NOT NULL + AND char_length(btrim(custom_breed_name)) BETWEEN 1 AND 64 + ) + ), + CONSTRAINT ck_pets_birth_date CHECK (birth_date IS NULL OR birth_date >= DATE '1990-01-01'), + CONSTRAINT ck_pets_status CHECK (status IN ('active', 'lost', 'deceased', 'archived', 'deleted')), + CONSTRAINT ck_pets_version CHECK (version >= 0), + CONSTRAINT ck_pets_deleted CHECK ((status = 'deleted') = (deleted_at IS NOT NULL)) +); + +CREATE UNIQUE INDEX uq_pets_microchip + ON pet_health.pets (microchip_no) + WHERE microchip_no IS NOT NULL; +CREATE INDEX ix_pets_breed_species ON pet_health.pets (breed_id, species); +CREATE INDEX ix_pets_avatar ON pet_health.pets (avatar_asset_id); +CREATE INDEX ix_pets_active_updated ON pet_health.pets (updated_at DESC, id) WHERE status = 'active'; + +CREATE TABLE pet_health.pet_owners ( + pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT, + role varchar(16) NOT NULL DEFAULT 'owner', + is_primary boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (pet_id, user_id), + CONSTRAINT ck_pet_owners_role CHECK (role IN ('owner', 'caregiver', 'viewer')) +); + +CREATE INDEX ix_pet_owners_user ON pet_health.pet_owners (user_id, pet_id); +CREATE UNIQUE INDEX uq_pet_primary_owner + ON pet_health.pet_owners (pet_id) + WHERE is_primary; + +CREATE TABLE pet_health.pet_weight_records ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE, + weight_kg numeric(6,2) NOT NULL, + measured_at timestamptz NOT NULL, + source varchar(16) NOT NULL DEFAULT 'manual', + note varchar(500), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_pet_weight CHECK (weight_kg > 0 AND weight_kg <= 500), + CONSTRAINT ck_pet_weight_source CHECK (source IN ('manual', 'clinic', 'device')) +); + +CREATE INDEX ix_pet_weight_pet_measured + ON pet_health.pet_weight_records (pet_id, measured_at DESC, id DESC); + +CREATE TABLE pet_health.vaccine_catalog ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(64) NOT NULL UNIQUE, + name varchar(128) NOT NULL, + species varchar(16) NOT NULL, + description varchar(500), + enabled boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_vaccine_species CHECK (species IN ('dog', 'cat', 'other')), + CONSTRAINT ck_vaccine_names CHECK ( + code = btrim(code) AND name = btrim(name) + AND char_length(code) BETWEEN 2 AND 64 + AND char_length(name) BETWEEN 1 AND 128 + ) +); + +CREATE INDEX ix_vaccine_catalog_species ON pet_health.vaccine_catalog (species, name) WHERE enabled; + +CREATE TABLE pet_health.pet_vaccinations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE, + vaccine_id uuid NOT NULL REFERENCES pet_health.vaccine_catalog(id) ON DELETE RESTRICT, + series_key varchar(64) NOT NULL, + dose_no smallint NOT NULL, + dose_label varchar(64), + status varchar(16) NOT NULL DEFAULT 'scheduled', + planned_on date, + administered_on date, + next_due_on date, + provider_id uuid, + provider_name_snapshot varchar(128), + manufacturer varchar(128), + batch_no varchar(64), + certificate_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL, + booking_id uuid, + notes varchar(1000), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 0, + CONSTRAINT ck_vaccination_series CHECK (series_key = btrim(series_key) AND char_length(series_key) BETWEEN 1 AND 64), + CONSTRAINT ck_vaccination_dose CHECK (dose_no > 0), + CONSTRAINT ck_vaccination_status CHECK (status IN ('scheduled', 'completed', 'cancelled')), + CONSTRAINT ck_vaccination_dates CHECK ( + (status = 'completed' AND administered_on IS NOT NULL) + OR (status = 'scheduled' AND administered_on IS NULL AND planned_on IS NOT NULL) + OR (status = 'cancelled' AND administered_on IS NULL) + ), + CONSTRAINT ck_vaccination_next_due CHECK ( + next_due_on IS NULL OR administered_on IS NULL OR next_due_on >= administered_on + ), + CONSTRAINT ck_vaccination_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uq_pet_vaccination_dose + ON pet_health.pet_vaccinations (pet_id, vaccine_id, series_key, dose_no) + WHERE status <> 'cancelled'; +CREATE INDEX ix_vaccinations_pet ON pet_health.pet_vaccinations (pet_id); +CREATE INDEX ix_vaccinations_vaccine ON pet_health.pet_vaccinations (vaccine_id); +CREATE INDEX ix_vaccinations_certificate ON pet_health.pet_vaccinations (certificate_asset_id); +CREATE INDEX ix_vaccinations_provider ON pet_health.pet_vaccinations (provider_id); +CREATE INDEX ix_vaccinations_booking ON pet_health.pet_vaccinations (booking_id); +CREATE INDEX ix_vaccinations_pet_completed + ON pet_health.pet_vaccinations (pet_id, administered_on DESC, id DESC) + WHERE status = 'completed'; +CREATE INDEX ix_vaccinations_due + ON pet_health.pet_vaccinations (planned_on, pet_id) + WHERE status = 'scheduled'; + +CREATE TABLE pet_health.health_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE, + event_type varchar(24) NOT NULL, + occurred_at timestamptz NOT NULL, + title varchar(160) NOT NULL, + notes text, + amount_cents bigint, + provider_id uuid, + provider_name_snapshot varchar(128), + booking_id uuid, + created_by_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 0, + CONSTRAINT ck_health_event_type CHECK ( + event_type IN ('medical', 'feeding', 'deworming', 'grooming', 'measurement', 'note') + ), + CONSTRAINT ck_health_event_title CHECK (title = btrim(title) AND char_length(title) BETWEEN 1 AND 160), + CONSTRAINT ck_health_event_amount CHECK (amount_cents IS NULL OR amount_cents >= 0), + CONSTRAINT ck_health_event_version CHECK (version >= 0) +); + +CREATE INDEX ix_health_events_pet_time ON pet_health.health_events (pet_id, occurred_at DESC, id DESC); +CREATE INDEX ix_health_events_creator ON pet_health.health_events (created_by_user_id, created_at DESC); +CREATE INDEX ix_health_events_provider ON pet_health.health_events (provider_id); +CREATE INDEX ix_health_events_booking ON pet_health.health_events (booking_id); + +CREATE TABLE pet_health.health_event_media ( + health_event_id uuid NOT NULL REFERENCES pet_health.health_events(id) ON DELETE CASCADE, + position smallint NOT NULL, + asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT, + document_type varchar(32), + issued_on date, + PRIMARY KEY (health_event_id, position), + UNIQUE (health_event_id, asset_id), + CONSTRAINT ck_health_event_media_position CHECK (position >= 0) +); + +CREATE INDEX ix_health_event_media_asset ON pet_health.health_event_media (asset_id); + +CREATE TABLE pet_health.care_reminders ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + pet_id uuid NOT NULL REFERENCES pet_health.pets(id) ON DELETE CASCADE, + reminder_type varchar(24) NOT NULL, + title varchar(160) NOT NULL, + due_at timestamptz NOT NULL, + status varchar(16) NOT NULL DEFAULT 'pending', + completed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_care_reminder_type CHECK ( + reminder_type IN ('deworming', 'checkup', 'medication', 'other') + ), + CONSTRAINT ck_care_reminder_status CHECK (status IN ('pending', 'completed', 'dismissed')), + CONSTRAINT ck_care_reminder_completed CHECK ((status = 'completed') = (completed_at IS NOT NULL)) +); + +CREATE INDEX ix_care_reminders_pet ON pet_health.care_reminders (pet_id); +CREATE INDEX ix_care_reminders_due + ON pet_health.care_reminders (due_at, pet_id) + WHERE status = 'pending'; + +CREATE TABLE creation.generation_models ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(64) NOT NULL, + display_name varchar(128) NOT NULL, + provider_code varchar(64) NOT NULL, + provider_model_name varchar(128) NOT NULL, + media_kind varchar(16) NOT NULL, + enabled boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (code, media_kind), + UNIQUE (id, media_kind), + CONSTRAINT ck_generation_models_kind CHECK (media_kind IN ('image', 'video')), + CONSTRAINT ck_generation_models_names CHECK ( + code = btrim(code) AND char_length(code) BETWEEN 2 AND 64 + AND char_length(btrim(display_name)) BETWEEN 1 AND 128 + ) +); + +CREATE INDEX ix_generation_models_kind_order + ON creation.generation_models (media_kind, sort_order, id) + WHERE enabled; + +CREATE TABLE creation.generation_styles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(64) NOT NULL, + title varchar(64) NOT NULL, + subtitle varchar(128), + media_kind varchar(16) NOT NULL, + preview_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL, + enabled boolean NOT NULL DEFAULT true, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (code, media_kind), + UNIQUE (id, media_kind), + CONSTRAINT ck_generation_styles_kind CHECK (media_kind IN ('image', 'video')), + CONSTRAINT ck_generation_styles_names CHECK ( + code = btrim(code) AND char_length(code) BETWEEN 2 AND 64 + AND char_length(btrim(title)) BETWEEN 1 AND 64 + ) +); + +CREATE INDEX ix_generation_styles_preview ON creation.generation_styles (preview_asset_id); +CREATE INDEX ix_generation_styles_kind_order + ON creation.generation_styles (media_kind, sort_order, id) + WHERE enabled; + +CREATE TABLE creation.generation_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT, + pet_id uuid REFERENCES pet_health.pets(id) ON DELETE SET NULL, + media_kind varchar(16) NOT NULL, + model_id uuid NOT NULL, + style_id uuid, + input_asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT, + output_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL, + prompt varchar(10000), + negative_prompt varchar(3000), + width_px integer, + height_px integer, + duration_ms bigint, + upscale boolean NOT NULL DEFAULT false, + parameters jsonb NOT NULL DEFAULT '{}'::jsonb, + provider_code_snapshot varchar(64) NOT NULL, + provider_model_snapshot varchar(128) NOT NULL, + model_version_snapshot varchar(64) NOT NULL, + style_code_snapshot varchar(64), + status varchar(16) NOT NULL DEFAULT 'queued', + progress smallint NOT NULL DEFAULT 0, + priority smallint NOT NULL DEFAULT 0, + attempt_count smallint NOT NULL DEFAULT 0, + max_attempts smallint NOT NULL DEFAULT 3, + provider_request_id varchar(128), + error_code varchar(64), + error_message varchar(1000), + idempotency_key varchar(128) NOT NULL, + request_hash bytea NOT NULL, + next_attempt_at timestamptz NOT NULL DEFAULT now(), + lease_owner varchar(128), + lease_expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, + completed_at timestamptz, + version integer NOT NULL DEFAULT 0, + UNIQUE (user_id, idempotency_key), + FOREIGN KEY (model_id, media_kind) + REFERENCES creation.generation_models(id, media_kind) ON DELETE RESTRICT, + FOREIGN KEY (style_id, media_kind) + REFERENCES creation.generation_styles(id, media_kind) ON DELETE RESTRICT, + CONSTRAINT ck_generation_jobs_kind CHECK (media_kind IN ('image', 'video')), + CONSTRAINT ck_generation_jobs_dimensions CHECK ( + (width_px IS NULL OR width_px BETWEEN 64 AND 8192) + AND (height_px IS NULL OR height_px BETWEEN 64 AND 8192) + AND (duration_ms IS NULL OR duration_ms > 0) + ), + CONSTRAINT ck_generation_jobs_parameters CHECK (jsonb_typeof(parameters) = 'object'), + CONSTRAINT ck_generation_jobs_idempotency CHECK ( + idempotency_key = btrim(idempotency_key) + AND char_length(idempotency_key) BETWEEN 1 AND 128 + AND octet_length(request_hash) = 32 + ), + CONSTRAINT ck_generation_jobs_status CHECK ( + status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled') + ), + CONSTRAINT ck_generation_jobs_progress CHECK (progress BETWEEN 0 AND 100), + CONSTRAINT ck_generation_jobs_attempts CHECK ( + attempt_count >= 0 AND max_attempts > 0 AND attempt_count <= max_attempts + ), + CONSTRAINT ck_generation_jobs_state CHECK ( + ( + status = 'queued' AND progress = 0 AND started_at IS NULL AND completed_at IS NULL + AND output_asset_id IS NULL AND error_code IS NULL + AND lease_owner IS NULL AND lease_expires_at IS NULL + ) + OR ( + status = 'running' AND started_at IS NOT NULL AND completed_at IS NULL + AND output_asset_id IS NULL AND error_code IS NULL + AND lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL + ) + OR ( + status = 'succeeded' AND progress = 100 AND started_at IS NOT NULL + AND completed_at IS NOT NULL AND output_asset_id IS NOT NULL AND error_code IS NULL + AND lease_owner IS NULL AND lease_expires_at IS NULL + ) + OR ( + status = 'failed' AND completed_at IS NOT NULL AND error_code IS NOT NULL + AND output_asset_id IS NULL AND lease_owner IS NULL AND lease_expires_at IS NULL + ) + OR ( + status = 'cancelled' AND completed_at IS NOT NULL + AND lease_owner IS NULL AND lease_expires_at IS NULL + ) + ), + CONSTRAINT ck_generation_jobs_lease CHECK ( + (lease_owner IS NULL) = (lease_expires_at IS NULL) + AND (lease_expires_at IS NULL OR started_at IS NULL OR lease_expires_at > started_at) + ), + CONSTRAINT ck_generation_jobs_version CHECK (version >= 0) +); + +CREATE UNIQUE INDEX uq_generation_jobs_provider_request + ON creation.generation_jobs (provider_code_snapshot, provider_request_id) + WHERE provider_request_id IS NOT NULL; +CREATE INDEX ix_generation_jobs_user_created + ON creation.generation_jobs (user_id, created_at DESC, id DESC); +CREATE INDEX ix_generation_jobs_pet ON creation.generation_jobs (pet_id); +CREATE INDEX ix_generation_jobs_model ON creation.generation_jobs (model_id); +CREATE INDEX ix_generation_jobs_style ON creation.generation_jobs (style_id); +CREATE INDEX ix_generation_jobs_model_kind ON creation.generation_jobs (model_id, media_kind); +CREATE INDEX ix_generation_jobs_style_kind ON creation.generation_jobs (style_id, media_kind); +CREATE INDEX ix_generation_jobs_input ON creation.generation_jobs (input_asset_id); +CREATE INDEX ix_generation_jobs_output ON creation.generation_jobs (output_asset_id); +CREATE INDEX ix_generation_jobs_queue + ON creation.generation_jobs (priority DESC, next_attempt_at, created_at, id) + WHERE status = 'queued'; +CREATE INDEX ix_generation_jobs_running + ON creation.generation_jobs (lease_expires_at, id) + WHERE status = 'running'; + +CREATE TABLE community.posts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + author_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT, + pet_id uuid REFERENCES pet_health.pets(id) ON DELETE SET NULL, + generation_job_id uuid REFERENCES creation.generation_jobs(id) ON DELETE SET NULL, + category varchar(24) NOT NULL DEFAULT 'general', + title varchar(120), + content text NOT NULL, + status varchar(16) NOT NULL DEFAULT 'draft', + visibility varchar(16) NOT NULL DEFAULT 'public', + region_id uuid REFERENCES platform.regions(id) ON DELETE SET NULL, + location_text_snapshot varchar(128), + like_count bigint NOT NULL DEFAULT 0, + comment_count bigint NOT NULL DEFAULT 0, + bookmark_count bigint NOT NULL DEFAULT 0, + idempotency_key varchar(128), + request_hash bytea, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + published_at timestamptz, + deleted_at timestamptz, + version integer NOT NULL DEFAULT 0, + CONSTRAINT uq_posts_author_idempotency UNIQUE (author_user_id, idempotency_key), + CONSTRAINT ck_posts_category CHECK (category IN ('general', 'help', 'ai_creation')), + CONSTRAINT ck_posts_title CHECK (title IS NULL OR char_length(btrim(title)) BETWEEN 1 AND 120), + CONSTRAINT ck_posts_content CHECK (char_length(btrim(content)) BETWEEN 1 AND 10000), + CONSTRAINT ck_posts_status CHECK (status IN ('draft', 'published', 'hidden', 'archived')), + CONSTRAINT ck_posts_visibility CHECK (visibility IN ('public', 'followers', 'private')), + CONSTRAINT ck_posts_publish_state CHECK ( + (status = 'published' AND published_at IS NOT NULL AND deleted_at IS NULL) + OR status <> 'published' + ), + CONSTRAINT ck_posts_counts CHECK (like_count >= 0 AND comment_count >= 0 AND bookmark_count >= 0), + CONSTRAINT ck_posts_idempotency CHECK ( + (idempotency_key IS NULL AND request_hash IS NULL) + OR ( + idempotency_key = btrim(idempotency_key) + AND char_length(idempotency_key) BETWEEN 1 AND 128 + AND octet_length(request_hash) = 32 + ) + ), + CONSTRAINT ck_posts_version CHECK (version >= 0) +); + +CREATE INDEX ix_posts_feed + ON community.posts (published_at DESC, id DESC) + WHERE status = 'published' AND visibility = 'public'; +CREATE INDEX ix_posts_author_created + ON community.posts (author_user_id, created_at DESC, id DESC); +CREATE INDEX ix_posts_pet_created ON community.posts (pet_id, created_at DESC); +CREATE INDEX ix_posts_generation_job ON community.posts (generation_job_id); +CREATE INDEX ix_posts_region_feed + ON community.posts (region_id, published_at DESC, id DESC) + WHERE status = 'published' AND visibility = 'public'; +CREATE INDEX ix_posts_region ON community.posts (region_id); +CREATE INDEX ix_posts_content_trgm + ON community.posts USING gin (content gin_trgm_ops) + WHERE status = 'published'; + +CREATE TABLE community.post_media ( + post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE, + position smallint NOT NULL, + asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT, + is_cover boolean NOT NULL DEFAULT false, + caption varchar(300), + PRIMARY KEY (post_id, position), + UNIQUE (post_id, asset_id), + CONSTRAINT ck_post_media_position CHECK (position >= 0) +); + +CREATE INDEX ix_post_media_asset ON community.post_media (asset_id); +CREATE UNIQUE INDEX uq_post_media_cover + ON community.post_media (post_id) + WHERE is_cover; + +-- Comments are deliberately one flat level. reply_to_user_id supports @ replies +-- without parent_comment_id, recursive queries, or unbounded comment nesting. +CREATE TABLE community.comments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE, + author_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT, + reply_to_user_id uuid REFERENCES identity.users(id) ON DELETE SET NULL, + content varchar(2000) NOT NULL, + status varchar(16) NOT NULL DEFAULT 'visible', + client_request_id varchar(128), + request_hash bytea, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + UNIQUE (author_user_id, client_request_id), + CONSTRAINT ck_comments_content CHECK (char_length(btrim(content)) BETWEEN 1 AND 2000), + CONSTRAINT ck_comments_status CHECK (status IN ('visible', 'hidden', 'deleted')), + CONSTRAINT ck_comments_idempotency CHECK ( + (client_request_id IS NULL AND request_hash IS NULL) + OR ( + client_request_id = btrim(client_request_id) + AND char_length(client_request_id) BETWEEN 1 AND 128 + AND octet_length(request_hash) = 32 + ) + ), + CONSTRAINT ck_comments_deleted CHECK ((status = 'deleted') = (deleted_at IS NOT NULL)) +); + +CREATE INDEX ix_comments_post_created + ON community.comments (post_id, created_at DESC, id DESC); +CREATE INDEX ix_comments_author_created + ON community.comments (author_user_id, created_at DESC, id DESC); +CREATE INDEX ix_comments_reply_user + ON community.comments (reply_to_user_id, created_at DESC); + +CREATE TABLE community.post_likes ( + post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (post_id, user_id) +); + +CREATE INDEX ix_post_likes_user_created + ON community.post_likes (user_id, created_at DESC, post_id); + +CREATE TABLE community.post_bookmarks ( + post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (post_id, user_id) +); + +CREATE INDEX ix_post_bookmarks_user_created + ON community.post_bookmarks (user_id, created_at DESC, post_id); + +CREATE TABLE community.user_follows ( + follower_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, + followee_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (follower_user_id, followee_user_id), + CONSTRAINT ck_user_follows_self CHECK (follower_user_id <> followee_user_id) +); + +CREATE INDEX ix_user_follows_followee + ON community.user_follows (followee_user_id, created_at DESC, follower_user_id); + +CREATE TABLE community.topics ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name citext NOT NULL UNIQUE, + description varchar(300), + status varchar(16) NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_topics_name CHECK (name::text = btrim(name::text) AND char_length(name::text) BETWEEN 1 AND 32), + CONSTRAINT ck_topics_status CHECK (status IN ('active', 'hidden')) +); + +CREATE TABLE community.post_topics ( + post_id uuid NOT NULL REFERENCES community.posts(id) ON DELETE CASCADE, + topic_id uuid NOT NULL REFERENCES community.topics(id) ON DELETE CASCADE, + PRIMARY KEY (post_id, topic_id) +); + +CREATE INDEX ix_post_topics_topic ON community.post_topics (topic_id, post_id); + +CREATE TABLE marketplace.providers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id uuid REFERENCES identity.users(id) ON DELETE SET NULL, + kind varchar(16) NOT NULL, + slug varchar(128) NOT NULL UNIQUE, + name varchar(128) NOT NULL, + description text NOT NULL, + phone_e164 varchar(16), + region_id uuid NOT NULL REFERENCES platform.regions(id) ON DELETE RESTRICT, + address_line varchar(300) NOT NULL, + latitude numeric(9,6) NOT NULL, + longitude numeric(9,6) NOT NULL, + cover_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL, + verification_status varchar(16) NOT NULL DEFAULT 'unverified', + verified_at timestamptz, + is_24h boolean NOT NULL DEFAULT false, + rating_avg numeric(3,2) NOT NULL DEFAULT 0, + rating_count integer NOT NULL DEFAULT 0, + status varchar(16) NOT NULL DEFAULT 'pending', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 0, + CONSTRAINT ck_providers_kind CHECK (kind IN ('hospital', 'grooming', 'personal')), + CONSTRAINT ck_providers_slug CHECK (slug = lower(btrim(slug)) AND slug ~ '^[a-z0-9][a-z0-9-]{1,127}$'), + CONSTRAINT ck_providers_name CHECK (name = btrim(name) AND char_length(name) BETWEEN 1 AND 128), + CONSTRAINT ck_providers_description CHECK (char_length(btrim(description)) BETWEEN 1 AND 5000), + CONSTRAINT ck_providers_phone CHECK (phone_e164 IS NULL OR phone_e164 ~ '^\+[1-9][0-9]{7,14}$'), + CONSTRAINT ck_providers_coordinates CHECK (latitude BETWEEN -90 AND 90 AND longitude BETWEEN -180 AND 180), + CONSTRAINT ck_providers_verification CHECK ( + verification_status IN ('unverified', 'pending', 'verified', 'rejected', 'expired') + AND (verification_status <> 'verified' OR verified_at IS NOT NULL) + ), + CONSTRAINT ck_providers_rating CHECK (rating_avg BETWEEN 0 AND 5 AND rating_count >= 0), + CONSTRAINT ck_providers_status CHECK (status IN ('pending', 'active', 'suspended', 'closed')), + CONSTRAINT ck_providers_version CHECK (version >= 0) +); + +CREATE INDEX ix_providers_owner ON marketplace.providers (owner_user_id); +CREATE INDEX ix_providers_region_location + ON marketplace.providers (region_id, latitude, longitude) + WHERE status = 'active'; +CREATE INDEX ix_providers_region ON marketplace.providers (region_id); +CREATE INDEX ix_providers_kind_rating + ON marketplace.providers (kind, rating_avg DESC, id) + WHERE status = 'active'; +CREATE INDEX ix_providers_cover ON marketplace.providers (cover_asset_id); +CREATE INDEX ix_providers_name_trgm + ON marketplace.providers USING gin (name gin_trgm_ops) + WHERE status = 'active'; +CREATE INDEX ix_providers_description_trgm + ON marketplace.providers USING gin (description gin_trgm_ops) + WHERE status = 'active'; + +CREATE TABLE marketplace.provider_tags ( + provider_id uuid NOT NULL REFERENCES marketplace.providers(id) ON DELETE CASCADE, + tag citext NOT NULL, + PRIMARY KEY (provider_id, tag), + CONSTRAINT ck_provider_tags_value CHECK ( + tag::text = btrim(tag::text) AND char_length(tag::text) BETWEEN 1 AND 32 + ) +); + +CREATE INDEX ix_provider_tags_search + ON marketplace.provider_tags USING gin ((tag::text) gin_trgm_ops); + +CREATE TABLE marketplace.service_offerings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + provider_id uuid NOT NULL REFERENCES marketplace.providers(id) ON DELETE RESTRICT, + category varchar(24) NOT NULL, + name varchar(128) NOT NULL, + description text, + base_price_cents bigint NOT NULL, + currency char(3) NOT NULL DEFAULT 'CNY', + pricing_unit varchar(24) NOT NULL DEFAULT 'service', + duration_minutes smallint, + status varchar(16) NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + version integer NOT NULL DEFAULT 0, + UNIQUE (id, provider_id), + UNIQUE (provider_id, name), + CONSTRAINT ck_service_offerings_category CHECK ( + category IN ('feeding', 'walking', 'bathing', 'grooming', 'boarding', 'transport', 'hospital', 'adoption', 'other') + ), + CONSTRAINT ck_service_offerings_name CHECK (name = btrim(name) AND char_length(name) BETWEEN 1 AND 128), + CONSTRAINT ck_service_offerings_price CHECK (base_price_cents >= 0), + CONSTRAINT ck_service_offerings_currency CHECK (currency ~ '^[A-Z]{3}$'), + CONSTRAINT ck_service_offerings_duration CHECK (duration_minutes IS NULL OR duration_minutes BETWEEN 5 AND 1440), + CONSTRAINT ck_service_offerings_status CHECK (status IN ('active', 'inactive')), + CONSTRAINT ck_service_offerings_version CHECK (version >= 0) +); + +CREATE INDEX ix_service_offerings_provider + ON marketplace.service_offerings (provider_id, category, id); +CREATE INDEX ix_service_offerings_category_price + ON marketplace.service_offerings (category, base_price_cents, id) + WHERE status = 'active'; +CREATE INDEX ix_service_offerings_name_trgm + ON marketplace.service_offerings USING gin (name gin_trgm_ops) + WHERE status = 'active'; + +CREATE TABLE marketplace.service_resources ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + provider_id uuid NOT NULL REFERENCES marketplace.providers(id) ON DELETE RESTRICT, + name varchar(128) NOT NULL, + resource_type varchar(24) NOT NULL, + status varchar(16) NOT NULL DEFAULT 'active', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (id, provider_id), + UNIQUE (provider_id, name), + CONSTRAINT ck_service_resources_name CHECK (name = btrim(name) AND char_length(name) BETWEEN 1 AND 128), + CONSTRAINT ck_service_resources_type CHECK (resource_type IN ('person', 'room', 'vehicle', 'capacity_unit')), + CONSTRAINT ck_service_resources_status CHECK (status IN ('active', 'inactive')) +); + +CREATE INDEX ix_service_resources_provider ON marketplace.service_resources (provider_id, id); + +CREATE TABLE marketplace.offering_resources ( + provider_id uuid NOT NULL, + offering_id uuid NOT NULL, + resource_id uuid NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (offering_id, resource_id), + UNIQUE (offering_id, resource_id, provider_id), + FOREIGN KEY (offering_id, provider_id) + REFERENCES marketplace.service_offerings(id, provider_id) ON DELETE CASCADE, + FOREIGN KEY (resource_id, provider_id) + REFERENCES marketplace.service_resources(id, provider_id) ON DELETE CASCADE +); + +CREATE INDEX ix_offering_resources_resource ON marketplace.offering_resources (resource_id, offering_id); +CREATE INDEX ix_offering_resources_provider ON marketplace.offering_resources (provider_id, offering_id); +CREATE INDEX ix_offering_resources_offering_provider + ON marketplace.offering_resources (offering_id, provider_id); +CREATE INDEX ix_offering_resources_resource_provider + ON marketplace.offering_resources (resource_id, provider_id); + +CREATE TABLE marketplace.bookings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + customer_user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT, + pet_id uuid REFERENCES pet_health.pets(id) ON DELETE RESTRICT, + provider_id uuid NOT NULL REFERENCES marketplace.providers(id) ON DELETE RESTRICT, + offering_id uuid NOT NULL, + resource_id uuid, + scheduled_start_at timestamptz, + scheduled_end_at timestamptz, + service_window tstzrange GENERATED ALWAYS AS ( + CASE + WHEN scheduled_start_at IS NULL OR scheduled_end_at IS NULL THEN NULL + ELSE tstzrange(scheduled_start_at, scheduled_end_at, '[)') + END + ) STORED, + status varchar(16) NOT NULL DEFAULT 'pending', + hold_expires_at timestamptz, + provider_name_snapshot varchar(128) NOT NULL, + service_name_snapshot varchar(128) NOT NULL, + pet_name_snapshot varchar(64), + address_snapshot varchar(300) NOT NULL, + base_amount_cents bigint NOT NULL, + discount_amount_cents bigint NOT NULL DEFAULT 0, + payable_amount_cents bigint NOT NULL, + currency char(3) NOT NULL DEFAULT 'CNY', + customer_note varchar(500), + idempotency_key varchar(128) NOT NULL, + request_hash bytea NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + cancelled_at timestamptz, + completed_at timestamptz, + version integer NOT NULL DEFAULT 0, + UNIQUE (customer_user_id, idempotency_key), + FOREIGN KEY (offering_id, provider_id) + REFERENCES marketplace.service_offerings(id, provider_id) ON DELETE RESTRICT, + FOREIGN KEY (offering_id, resource_id, provider_id) + REFERENCES marketplace.offering_resources(offering_id, resource_id, provider_id) ON DELETE RESTRICT, + CONSTRAINT ck_bookings_status CHECK ( + status IN ('pending', 'held', 'confirmed', 'in_service', 'completed', 'cancelled', 'rejected', 'expired') + ), + CONSTRAINT ck_bookings_schedule CHECK ( + (scheduled_start_at IS NULL AND scheduled_end_at IS NULL) + OR (scheduled_start_at IS NOT NULL AND scheduled_end_at IS NOT NULL AND scheduled_start_at < scheduled_end_at) + ), + CONSTRAINT ck_bookings_active_schedule CHECK ( + status NOT IN ('held', 'confirmed', 'in_service') + OR (resource_id IS NOT NULL AND scheduled_start_at IS NOT NULL AND scheduled_end_at IS NOT NULL) + ), + CONSTRAINT ck_bookings_amount CHECK ( + base_amount_cents >= 0 + AND discount_amount_cents >= 0 + AND discount_amount_cents <= base_amount_cents + AND payable_amount_cents = base_amount_cents - discount_amount_cents + ), + CONSTRAINT ck_bookings_currency CHECK (currency ~ '^[A-Z]{3}$'), + CONSTRAINT ck_bookings_idempotency CHECK ( + idempotency_key = btrim(idempotency_key) + AND char_length(idempotency_key) BETWEEN 1 AND 128 + AND octet_length(request_hash) = 32 + ), + CONSTRAINT ck_bookings_completion CHECK ((status = 'completed') = (completed_at IS NOT NULL)), + CONSTRAINT ck_bookings_cancellation CHECK ((status = 'cancelled') = (cancelled_at IS NOT NULL)), + CONSTRAINT ck_bookings_hold_expiry CHECK ( + (status = 'held') = (hold_expires_at IS NOT NULL) + AND (hold_expires_at IS NULL OR hold_expires_at > created_at) + ), + CONSTRAINT ck_bookings_version CHECK (version >= 0) +); + +ALTER TABLE marketplace.bookings + ADD CONSTRAINT ex_bookings_resource_overlap + EXCLUDE USING gist ( + resource_id WITH =, + service_window WITH && + ) + WHERE (status IN ('held', 'confirmed', 'in_service')) + DEFERRABLE INITIALLY IMMEDIATE; + +CREATE INDEX ix_bookings_customer_created + ON marketplace.bookings (customer_user_id, created_at DESC, id DESC); +CREATE INDEX ix_bookings_pet ON marketplace.bookings (pet_id); +CREATE INDEX ix_bookings_provider_schedule + ON marketplace.bookings (provider_id, scheduled_start_at, id); +CREATE INDEX ix_bookings_offering_schedule + ON marketplace.bookings (offering_id, scheduled_start_at, id); +CREATE INDEX ix_bookings_resource ON marketplace.bookings (resource_id); +CREATE INDEX ix_bookings_offering_provider + ON marketplace.bookings (offering_id, provider_id); +CREATE INDEX ix_bookings_offering_resource_provider + ON marketplace.bookings (offering_id, resource_id, provider_id); +CREATE INDEX ix_bookings_held_expiry + ON marketplace.bookings (hold_expires_at, id) + WHERE status = 'held'; + +CREATE TABLE marketplace.booking_status_history ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + booking_id uuid NOT NULL REFERENCES marketplace.bookings(id) ON DELETE CASCADE, + from_status varchar(16), + to_status varchar(16) NOT NULL, + actor_user_id uuid REFERENCES identity.users(id) ON DELETE SET NULL, + reason varchar(500), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT ck_booking_history_status CHECK ( + (from_status IS NULL OR from_status IN ('pending', 'held', 'confirmed', 'in_service', 'completed', 'cancelled', 'rejected', 'expired')) + AND to_status IN ('pending', 'held', 'confirmed', 'in_service', 'completed', 'cancelled', 'rejected', 'expired') + AND from_status IS DISTINCT FROM to_status + ) +); + +CREATE INDEX ix_booking_history_booking + ON marketplace.booking_status_history (booking_id, created_at, id); +CREATE INDEX ix_booking_history_actor ON marketplace.booking_status_history (actor_user_id); + +CREATE FUNCTION marketplace.validate_booking_transition() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.status IS NOT DISTINCT FROM OLD.status THEN + RETURN NEW; + END IF; + + IF NOT ( + (OLD.status = 'pending' AND NEW.status IN ('held', 'confirmed', 'rejected', 'cancelled', 'expired')) + OR (OLD.status = 'held' AND NEW.status IN ('confirmed', 'cancelled', 'expired')) + OR (OLD.status = 'confirmed' AND NEW.status IN ('in_service', 'cancelled')) + OR (OLD.status = 'in_service' AND NEW.status IN ('completed', 'cancelled')) + ) THEN + RAISE EXCEPTION 'illegal booking status transition: % -> %', OLD.status, NEW.status + USING ERRCODE = 'check_violation'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_bookings_validate_transition + BEFORE UPDATE OF status ON marketplace.bookings + FOR EACH ROW EXECUTE FUNCTION marketplace.validate_booking_transition(); + +ALTER TABLE pet_health.pet_vaccinations + ADD CONSTRAINT fk_vaccinations_provider + FOREIGN KEY (provider_id) REFERENCES marketplace.providers(id) ON DELETE SET NULL, + ADD CONSTRAINT fk_vaccinations_booking + FOREIGN KEY (booking_id) REFERENCES marketplace.bookings(id) ON DELETE SET NULL; + +ALTER TABLE pet_health.health_events + ADD CONSTRAINT fk_health_events_provider + FOREIGN KEY (provider_id) REFERENCES marketplace.providers(id) ON DELETE SET NULL, + ADD CONSTRAINT fk_health_events_booking + FOREIGN KEY (booking_id) REFERENCES marketplace.bookings(id) ON DELETE SET NULL; + +CREATE TABLE platform.notifications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE CASCADE, + notification_type varchar(64) NOT NULL, + title varchar(160) NOT NULL, + body varchar(1000) NOT NULL, + target_type varchar(64), + target_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + read_at timestamptz, + archived_at timestamptz, + CONSTRAINT ck_notifications_type CHECK (notification_type = btrim(notification_type) AND char_length(notification_type) BETWEEN 2 AND 64), + CONSTRAINT ck_notifications_title CHECK (char_length(btrim(title)) BETWEEN 1 AND 160), + CONSTRAINT ck_notifications_body CHECK (char_length(btrim(body)) BETWEEN 1 AND 1000) +); + +CREATE INDEX ix_notifications_user_created + ON platform.notifications (user_id, created_at DESC, id DESC); +CREATE INDEX ix_notifications_user_unread + ON platform.notifications (user_id, created_at DESC, id DESC) + WHERE read_at IS NULL AND archived_at IS NULL; + +CREATE TABLE platform.outbox_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + aggregate_type varchar(64) NOT NULL, + aggregate_id uuid NOT NULL, + aggregate_version bigint NOT NULL, + event_type varchar(128) NOT NULL, + schema_version smallint NOT NULL DEFAULT 1, + payload jsonb NOT NULL, + occurred_at timestamptz NOT NULL DEFAULT now(), + available_at timestamptz NOT NULL DEFAULT now(), + published_at timestamptz, + attempt_count integer NOT NULL DEFAULT 0, + max_attempts integer NOT NULL DEFAULT 12, + last_error varchar(1000), + locked_at timestamptz, + locked_by varchar(128), + dead_lettered_at timestamptz, + UNIQUE (aggregate_type, aggregate_id, aggregate_version, event_type), + CONSTRAINT ck_outbox_names CHECK ( + char_length(btrim(aggregate_type)) BETWEEN 2 AND 64 + AND char_length(btrim(event_type)) BETWEEN 2 AND 128 + ), + CONSTRAINT ck_outbox_payload CHECK (jsonb_typeof(payload) = 'object'), + CONSTRAINT ck_outbox_versions CHECK (aggregate_version >= 0 AND schema_version > 0), + CONSTRAINT ck_outbox_attempts CHECK (attempt_count >= 0 AND max_attempts > 0), + CONSTRAINT ck_outbox_lock CHECK ((locked_at IS NULL) = (locked_by IS NULL)), + CONSTRAINT ck_outbox_terminal CHECK (published_at IS NULL OR dead_lettered_at IS NULL) +); + +CREATE INDEX ix_outbox_unpublished + ON platform.outbox_events (available_at, occurred_at, id) + WHERE published_at IS NULL AND dead_lettered_at IS NULL; +CREATE INDEX ix_outbox_aggregate + ON platform.outbox_events (aggregate_type, aggregate_id, occurred_at); + +-- Automatic updated_at maintenance. Business version increments remain explicit +-- so optimistic locking stays visible in repository update statements. +CREATE TRIGGER trg_users_updated_at BEFORE UPDATE ON identity.users + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_credentials_updated_at BEFORE UPDATE ON identity.user_credentials + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_addresses_updated_at BEFORE UPDATE ON identity.user_addresses + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_preferences_updated_at BEFORE UPDATE ON identity.user_preferences + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_media_updated_at BEFORE UPDATE ON media.assets + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_pets_updated_at BEFORE UPDATE ON pet_health.pets + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_vaccinations_updated_at BEFORE UPDATE ON pet_health.pet_vaccinations + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_health_events_updated_at BEFORE UPDATE ON pet_health.health_events + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_reminders_updated_at BEFORE UPDATE ON pet_health.care_reminders + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_generation_models_updated_at BEFORE UPDATE ON creation.generation_models + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_generation_styles_updated_at BEFORE UPDATE ON creation.generation_styles + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_generation_jobs_updated_at BEFORE UPDATE ON creation.generation_jobs + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_posts_updated_at BEFORE UPDATE ON community.posts + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_comments_updated_at BEFORE UPDATE ON community.comments + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_providers_updated_at BEFORE UPDATE ON marketplace.providers + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_offerings_updated_at BEFORE UPDATE ON marketplace.service_offerings + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_resources_updated_at BEFORE UPDATE ON marketplace.service_resources + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); +CREATE TRIGGER trg_bookings_updated_at BEFORE UPDATE ON marketplace.bookings + FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); + +/* ------------------------------------------------------------------------- + Development fixture data + + The following rows mirror the Flutter demo enough to exercise all page data + paths. Remove this section from the production Flyway baseline. All fixture + accounts use the password: Patbond@123 +--------------------------------------------------------------------------- */ + +INSERT INTO platform.regions + (id, code, province_name, city_name, district_name, latitude, longitude) +VALUES + ('10000000-0000-7000-8000-000000000001', 'CN-BJ-CY', '北京市', '北京', '朝阳区', 39.921900, 116.443550), + ('10000000-0000-7000-8000-000000000002', 'CN-SH-PD', '上海市', '上海', '浦东新区', 31.221140, 121.544090), + ('10000000-0000-7000-8000-000000000003', 'CN-GD-SZ-NS', '广东省', '深圳', '南山区', 22.533320, 113.930410), + ('10000000-0000-7000-8000-000000000004', 'CN-SC-CD-GX', '四川省', '成都', '高新区', 30.544730, 104.069760), + ('10000000-0000-7000-8000-000000000005', 'CN-ZJ-HZ-XH', '浙江省', '杭州', '西湖区', 30.259610, 120.130260), + ('10000000-0000-7000-8000-000000000006', 'CN-HLJ-HEB-DL', '黑龙江省', '哈尔滨', '道里区', 45.755020, 126.616990); + +INSERT INTO identity.users + (id, username, nickname, phone_e164, bio, status, created_at) +VALUES + ('11000000-0000-7000-8000-000000000001', 'demo_user', '豆豆的妈妈', '+8613800000001', 'Patbond 社区创作达人', 'active', now() - interval '180 days'), + ('11000000-0000-7000-8000-000000000002', 'corgi_lover', 'CorgiLover99', '+8613800000002', NULL, 'active', now() - interval '120 days'), + ('11000000-0000-7000-8000-000000000003', 'milo_cat', 'MiloDaCat', '+8613800000003', NULL, 'active', now() - interval '90 days'); + +INSERT INTO identity.user_credentials (user_id, password_hash, hash_algorithm) +VALUES + ('11000000-0000-7000-8000-000000000001', '$2y$12$UYCn5ZmWnouv7BFyuvsTD.NvcA/A/VhHdCePbeIrvcsI4JoRBbmYq', 'bcrypt'), + ('11000000-0000-7000-8000-000000000002', '$2y$12$UYCn5ZmWnouv7BFyuvsTD.NvcA/A/VhHdCePbeIrvcsI4JoRBbmYq', 'bcrypt'), + ('11000000-0000-7000-8000-000000000003', '$2y$12$UYCn5ZmWnouv7BFyuvsTD.NvcA/A/VhHdCePbeIrvcsI4JoRBbmYq', 'bcrypt'); + +INSERT INTO identity.auth_sessions + (id, user_id, token_family_id, refresh_token_hash, access_token_jti, device_id, device_name, expires_at) +VALUES + ( + '11100000-0000-7000-8000-000000000001', + '11000000-0000-7000-8000-000000000001', + '11100000-0000-7000-9000-000000000001', + decode(repeat('ab', 32), 'hex'), + 'fixture-access-token-jti', + 'fixture-device-1', + 'Flutter test device', + now() + interval '30 days' + ); + +INSERT INTO identity.user_preferences + (user_id, default_region_id, allow_precise_location) +VALUES + ('11000000-0000-7000-8000-000000000001', '10000000-0000-7000-8000-000000000001', false), + ('11000000-0000-7000-8000-000000000002', '10000000-0000-7000-8000-000000000001', false), + ('11000000-0000-7000-8000-000000000003', '10000000-0000-7000-8000-000000000002', false); + +INSERT INTO identity.user_addresses + (id, user_id, region_id, label, recipient_name, recipient_phone_e164, address_line, latitude, longitude, is_default) +VALUES + ( + '11200000-0000-7000-8000-000000000001', + '11000000-0000-7000-8000-000000000001', + '10000000-0000-7000-8000-000000000001', + '家', '豆豆家长', '+8613800000001', '示例路 88 号', 39.922100, 116.443900, true + ); + +INSERT INTO media.assets + (id, owner_user_id, kind, purpose, storage_type, external_url, mime_type, width_px, height_px, duration_ms, status, ready_at) +VALUES + ('12000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000001', 'image', 'user_avatar', 'external', 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=300&q=80', 'image/jpeg', 300, 300, NULL, 'ready', now()), + ('12000000-0000-7000-8000-000000000002', '11000000-0000-7000-8000-000000000002', 'image', 'user_avatar', 'external', 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=200&q=80', 'image/jpeg', 200, 200, NULL, 'ready', now()), + ('12000000-0000-7000-8000-000000000003', '11000000-0000-7000-8000-000000000003', 'image', 'user_avatar', 'external', 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=200&q=80', 'image/jpeg', 200, 200, NULL, 'ready', now()), + ('12000000-0000-7000-8000-000000000004', '11000000-0000-7000-8000-000000000001', 'image', 'pet_avatar', 'external', 'https://images.unsplash.com/photo-1552053831-71594a27632d?auto=format&fit=crop&w=600&q=85', 'image/jpeg', 600, 600, NULL, 'ready', now()), + ('12000000-0000-7000-8000-000000000005', '11000000-0000-7000-8000-000000000001', 'image', 'generation_output', 'external', 'https://images.unsplash.com/photo-1605568427561-40dd23c2acea?auto=format&fit=crop&w=1200&q=85', 'image/jpeg', 1200, 900, NULL, 'ready', now()), + ('12000000-0000-7000-8000-000000000006', '11000000-0000-7000-8000-000000000003', 'image', 'post', 'external', 'https://images.unsplash.com/photo-1574158622682-e40e69881006?auto=format&fit=crop&w=1200&q=85', 'image/jpeg', 1200, 900, NULL, 'ready', now()), + ('12000000-0000-7000-8000-000000000007', '11000000-0000-7000-8000-000000000001', 'image', 'provider_cover', 'external', 'https://images.unsplash.com/photo-1628009368231-7bb7cfcb0def?auto=format&fit=crop&w=900&q=85', 'image/jpeg', 900, 500, NULL, 'ready', now()), + ('12000000-0000-7000-8000-000000000008', '11000000-0000-7000-8000-000000000002', 'image', 'provider_cover', 'external', 'https://images.unsplash.com/photo-1516734212186-a967f81ad0d7?auto=format&fit=crop&w=900&q=85', 'image/jpeg', 900, 500, NULL, 'ready', now()), + ('12000000-0000-7000-8000-000000000009', '11000000-0000-7000-8000-000000000003', 'image', 'provider_cover', 'external', 'https://images.unsplash.com/photo-1601758228041-f3b2795255f1?auto=format&fit=crop&w=900&q=85', 'image/jpeg', 900, 500, NULL, 'ready', now()), + ('12000000-0000-7000-8000-000000000010', '11000000-0000-7000-8000-000000000001', 'document', 'health_certificate', 'external', 'https://example.invalid/fixtures/doudou-vaccine-certificate.pdf', 'application/pdf', NULL, NULL, NULL, 'ready', now()); + +UPDATE identity.users SET avatar_asset_id = '12000000-0000-7000-8000-000000000001' +WHERE id = '11000000-0000-7000-8000-000000000001'; +UPDATE identity.users SET avatar_asset_id = '12000000-0000-7000-8000-000000000002' +WHERE id = '11000000-0000-7000-8000-000000000002'; +UPDATE identity.users SET avatar_asset_id = '12000000-0000-7000-8000-000000000003' +WHERE id = '11000000-0000-7000-8000-000000000003'; + +INSERT INTO pet_health.breeds (id, species, code, display_name, sort_order) +VALUES + ('13000000-0000-7000-8000-000000000001', 'dog', 'shiba-inu', '柴犬', 10), + ('13000000-0000-7000-8000-000000000002', 'dog', 'golden-retriever', '金毛寻回犬', 20), + ('13000000-0000-7000-8000-000000000003', 'dog', 'corgi', '柯基', 30), + ('13000000-0000-7000-8000-000000000004', 'cat', 'british-shorthair', '英国短毛猫', 10); + +INSERT INTO pet_health.pets + (id, name, species, breed_id, sex, birth_date, personality, avatar_asset_id, microchip_no, sterilized_on) +VALUES + ( + '14000000-0000-7000-8000-000000000001', '豆豆', 'dog', + '13000000-0000-7000-8000-000000000001', 'male', DATE '2024-05-15', '活泼', + '12000000-0000-7000-8000-000000000004', 'CHIP-DEMO-0001', DATE '2025-01-10' + ); + +INSERT INTO pet_health.pet_owners (pet_id, user_id, role, is_primary) +VALUES + ('14000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000001', 'owner', true); + +INSERT INTO pet_health.pet_weight_records + (id, pet_id, weight_kg, measured_at, source, note) +VALUES + ('14100000-0000-7000-8000-000000000001', '14000000-0000-7000-8000-000000000001', 4.80, now() - interval '90 days', 'manual', '成长记录'), + ('14100000-0000-7000-8000-000000000002', '14000000-0000-7000-8000-000000000001', 5.20, now() - interval '1 day', 'manual', '当前体重'); + +INSERT INTO marketplace.providers + (id, owner_user_id, kind, slug, name, description, region_id, address_line, latitude, longitude, + cover_asset_id, verification_status, verified_at, is_24h, rating_avg, rating_count, status) +VALUES + ( + '16000000-0000-7000-8000-000000000001', NULL, 'hospital', 'anchong-animal-hospital', + '安宠动物医院(朝阳旗舰店)', '猫科内科 · 骨外科 · 预防医学 · 24H急诊', + '10000000-0000-7000-8000-000000000001', '示例医疗街 10 号', 39.927800, 116.450900, + '12000000-0000-7000-8000-000000000007', + 'verified', now() - interval '1 year', true, 4.90, 138, 'active' + ), + ( + '16000000-0000-7000-8000-000000000002', NULL, 'grooming', 'pawspa-chaoyang', + 'PawSpa 高级宠物沙龙', '资深美容师主理 · 环境静音 · 猫咪免应激', + '10000000-0000-7000-8000-000000000001', '示例商业街 28 号', 39.915900, 116.430800, + '12000000-0000-7000-8000-000000000008', + 'verified', now() - interval '200 days', false, 4.90, 86, 'active' + ), + ( + '16000000-0000-7000-8000-000000000003', '11000000-0000-7000-8000-000000000003', 'personal', 'sunny-dog-walker', + '阳光遛狗员-阿健', '资深铲屎官,提供耐心、安全的上门遛狗服务。', + '10000000-0000-7000-8000-000000000001', '朝阳区上门服务', 39.919500, 116.439200, + '12000000-0000-7000-8000-000000000009', + 'verified', now() - interval '100 days', false, 4.90, 42, 'active' + ); + +INSERT INTO marketplace.provider_tags (provider_id, tag) +VALUES + ('16000000-0000-7000-8000-000000000001', '猫专科认证'), + ('16000000-0000-7000-8000-000000000001', '24H急诊'), + ('16000000-0000-7000-8000-000000000002', '日系精修'), + ('16000000-0000-7000-8000-000000000002', '猫咪免应激'), + ('16000000-0000-7000-8000-000000000003', '实时定位'), + ('16000000-0000-7000-8000-000000000003', '自带清洁包'), + ('16000000-0000-7000-8000-000000000003', '只接中小型犬'); + +INSERT INTO marketplace.service_offerings + (id, provider_id, category, name, description, base_price_cents, pricing_unit, duration_minutes) +VALUES + ('16100000-0000-7000-8000-000000000001', '16000000-0000-7000-8000-000000000001', 'hospital', '宠物基础问诊', '基础体格检查与问诊', 29900, 'visit', 30), + ('16100000-0000-7000-8000-000000000002', '16000000-0000-7000-8000-000000000002', 'grooming', '宠物精洗美容', '洗护及基础造型', 15800, 'service', 90), + ('16100000-0000-7000-8000-000000000003', '16000000-0000-7000-8000-000000000003', 'walking', '上门遛狗 60 分钟', '实时定位并反馈照片', 4500, 'hour', 60); + +INSERT INTO marketplace.service_resources + (id, provider_id, name, resource_type) +VALUES + ('16200000-0000-7000-8000-000000000001', '16000000-0000-7000-8000-000000000001', '问诊室 A', 'room'), + ('16200000-0000-7000-8000-000000000002', '16000000-0000-7000-8000-000000000002', '美容师小林', 'person'), + ('16200000-0000-7000-8000-000000000003', '16000000-0000-7000-8000-000000000003', '阿健', 'person'); + +INSERT INTO marketplace.offering_resources (provider_id, offering_id, resource_id) +VALUES + ('16000000-0000-7000-8000-000000000001', '16100000-0000-7000-8000-000000000001', '16200000-0000-7000-8000-000000000001'), + ('16000000-0000-7000-8000-000000000002', '16100000-0000-7000-8000-000000000002', '16200000-0000-7000-8000-000000000002'), + ('16000000-0000-7000-8000-000000000003', '16100000-0000-7000-8000-000000000003', '16200000-0000-7000-8000-000000000003'); + +INSERT INTO marketplace.bookings + (id, customer_user_id, pet_id, provider_id, offering_id, resource_id, + scheduled_start_at, scheduled_end_at, status, + provider_name_snapshot, service_name_snapshot, pet_name_snapshot, address_snapshot, + base_amount_cents, discount_amount_cents, payable_amount_cents, idempotency_key, request_hash) +VALUES + ( + '16300000-0000-7000-8000-000000000001', + '11000000-0000-7000-8000-000000000001', '14000000-0000-7000-8000-000000000001', + '16000000-0000-7000-8000-000000000003', '16100000-0000-7000-8000-000000000003', + '16200000-0000-7000-8000-000000000003', + timestamptz '2030-08-20 09:00:00+08', timestamptz '2030-08-20 10:00:00+08', 'confirmed', + '阳光遛狗员-阿健', '上门遛狗 60 分钟', '豆豆', '北京市朝阳区上门服务', + 4500, 2000, 2500, 'fixture-booking-1', decode(repeat('31', 32), 'hex') + ); + +INSERT INTO marketplace.booking_status_history + (booking_id, from_status, to_status, actor_user_id, reason) +VALUES + ('16300000-0000-7000-8000-000000000001', NULL, 'confirmed', '11000000-0000-7000-8000-000000000001', '开发测试预约'); + +INSERT INTO pet_health.vaccine_catalog (id, code, name, species) +VALUES + ('15000000-0000-7000-8000-000000000001', 'dog-combo-5', '犬五联', 'dog'), + ('15000000-0000-7000-8000-000000000002', 'rabies', '狂犬疫苗', 'dog'); + +INSERT INTO pet_health.pet_vaccinations + (id, pet_id, vaccine_id, series_key, dose_no, dose_label, status, planned_on, administered_on, + next_due_on, provider_id, provider_name_snapshot, certificate_asset_id) +VALUES + ( + '15100000-0000-7000-8000-000000000001', '14000000-0000-7000-8000-000000000001', + '15000000-0000-7000-8000-000000000001', '2025-primary', 1, '第1针', 'completed', + DATE '2025-05-12', DATE '2025-05-12', DATE '2025-07-15', + '16000000-0000-7000-8000-000000000001', '安宠动物医院(朝阳旗舰店)', + '12000000-0000-7000-8000-000000000010' + ), + ( + '15100000-0000-7000-8000-000000000002', '14000000-0000-7000-8000-000000000001', + '15000000-0000-7000-8000-000000000002', '2025-rabies', 1, '年度接种', 'completed', + DATE '2025-06-15', DATE '2025-06-15', DATE '2026-06-15', + '16000000-0000-7000-8000-000000000001', '安宠动物医院(朝阳旗舰店)', NULL + ), + ( + '15100000-0000-7000-8000-000000000003', '14000000-0000-7000-8000-000000000001', + '15000000-0000-7000-8000-000000000001', '2025-primary', 2, '加强针', 'scheduled', + DATE '2026-08-15', NULL, NULL, NULL, NULL, NULL + ); + +INSERT INTO pet_health.health_events + (id, pet_id, event_type, occurred_at, title, notes, amount_cents, provider_id, provider_name_snapshot, created_by_user_id) +VALUES + ( + '15200000-0000-7000-8000-000000000002', '14000000-0000-7000-8000-000000000001', + 'feeding', timestamptz '2025-05-20 18:00:00+08', '更换幼犬粮', '体重增长稳定', 20000, + NULL, NULL, '11000000-0000-7000-8000-000000000001' + ); + +INSERT INTO pet_health.care_reminders + (id, pet_id, reminder_type, title, due_at) +VALUES + ( + '15300000-0000-7000-8000-000000000002', '14000000-0000-7000-8000-000000000001', + 'deworming', '安排体内外驱虫', timestamptz '2026-08-16 09:00:00+08' + ); + +INSERT INTO creation.generation_models + (id, code, display_name, provider_code, provider_model_name, media_kind, sort_order) +VALUES + ('17000000-0000-7000-8000-000000000001', 'patbond-v1', 'Patbond-V1', 'fixture', 'patbond-image-v1', 'image', 10), + ('17000000-0000-7000-8000-000000000002', 'patbond-v1', 'Patbond-V1', 'fixture', 'patbond-video-v1', 'video', 10); + +INSERT INTO creation.generation_styles + (id, code, title, subtitle, media_kind, preview_asset_id, sort_order) +VALUES + ('17100000-0000-7000-8000-000000000001', 'healing', '治愈动画', '梦幻动漫感', 'image', '12000000-0000-7000-8000-000000000005', 10), + ('17100000-0000-7000-8000-000000000002', '3d-cartoon', '3D 卡通', '经典渲染', 'image', '12000000-0000-7000-8000-000000000005', 20), + ('17100000-0000-7000-8000-000000000003', 'comic', '漫画风', '帅气网格', 'image', '12000000-0000-7000-8000-000000000005', 30), + ('17100000-0000-7000-8000-000000000004', 'watercolor', '水彩', '柔和手绘质感', 'image', '12000000-0000-7000-8000-000000000005', 40), + ('17100000-0000-7000-8000-000000000005', 'healing', '治愈短片', '梦幻动态效果', 'video', '12000000-0000-7000-8000-000000000005', 10); + +INSERT INTO creation.generation_jobs + (id, user_id, pet_id, media_kind, model_id, style_id, input_asset_id, output_asset_id, + prompt, width_px, height_px, upscale, parameters, status, progress, attempt_count, + provider_code_snapshot, provider_model_snapshot, model_version_snapshot, style_code_snapshot, + provider_request_id, idempotency_key, request_hash, created_at, started_at, completed_at) +VALUES + ( + '17200000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000001', + '14000000-0000-7000-8000-000000000001', 'image', + '17000000-0000-7000-8000-000000000001', '17100000-0000-7000-8000-000000000001', + '12000000-0000-7000-8000-000000000004', '12000000-0000-7000-8000-000000000005', + '把豆豆生成治愈系插画', 1920, 1080, true, '{}'::jsonb, + 'succeeded', 100, 1, 'fixture', 'patbond-image-v1', '1.0', 'healing', + 'fixture-provider-job-1', 'fixture-generation-1', decode(repeat('41', 32), 'hex'), + now() - interval '3 hours', now() - interval '2 hours 59 minutes', now() - interval '2 hours 55 minutes' + ); + +INSERT INTO community.posts + (id, author_user_id, pet_id, generation_job_id, category, title, content, status, visibility, + region_id, location_text_snapshot, like_count, comment_count, bookmark_count, + idempotency_key, request_hash, created_at, published_at) +VALUES + ( + '18000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000001', + '14000000-0000-7000-8000-000000000001', '17200000-0000-7000-8000-000000000001', + 'ai_creation', '豆豆的治愈动画冒险', + '用 AI 把豆豆生成了治愈系插画,太可爱了吧!大家也快去试试创作功能!✨', + 'published', 'public', '10000000-0000-7000-8000-000000000001', '北京市 · 朝阳区', + 2, 2, 1, 'fixture-post-1', decode(repeat('51', 32), 'hex'), + now() - interval '2 hours', now() - interval '2 hours' + ), + ( + '18000000-0000-7000-8000-000000000002', '11000000-0000-7000-8000-000000000003', + NULL, NULL, 'help', NULL, '猫咪最近总是挠耳朵,有懂的朋友吗?', + 'published', 'public', '10000000-0000-7000-8000-000000000002', '上海市 · 浦东新区', + 1, 1, 0, 'fixture-post-2', decode(repeat('52', 32), 'hex'), + now() - interval '5 hours', now() - interval '5 hours' + ); + +INSERT INTO community.post_media (post_id, position, asset_id, is_cover) +VALUES + ('18000000-0000-7000-8000-000000000001', 0, '12000000-0000-7000-8000-000000000005', true), + ('18000000-0000-7000-8000-000000000002', 0, '12000000-0000-7000-8000-000000000006', true); + +INSERT INTO community.comments + (id, post_id, author_user_id, content, client_request_id, request_hash, created_at) +VALUES + ( + '18100000-0000-7000-8000-000000000001', '18000000-0000-7000-8000-000000000001', + '11000000-0000-7000-8000-000000000002', '这也太像了吧!求教程怎么生成的?', 'fixture-comment-1', decode(repeat('61', 32), 'hex'), + now() - interval '1 hour' + ), + ( + '18100000-0000-7000-8000-000000000002', '18000000-0000-7000-8000-000000000001', + '11000000-0000-7000-8000-000000000003', '好可爱的画风,豆豆真上镜!', 'fixture-comment-2', decode(repeat('62', 32), 'hex'), + now() - interval '45 minutes' + ), + ( + '18100000-0000-7000-8000-000000000003', '18000000-0000-7000-8000-000000000002', + '11000000-0000-7000-8000-000000000002', '建议先去医院检查耳道,不要自行用药哦。', 'fixture-comment-3', decode(repeat('63', 32), 'hex'), + now() - interval '4 hours' + ); + +INSERT INTO community.post_likes (post_id, user_id, created_at) +VALUES + ('18000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000002', now() - interval '1 hour'), + ('18000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000003', now() - interval '30 minutes'), + ('18000000-0000-7000-8000-000000000002', '11000000-0000-7000-8000-000000000001', now() - interval '3 hours'); + +INSERT INTO community.post_bookmarks (post_id, user_id) +VALUES + ('18000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000002'); + +INSERT INTO community.user_follows (follower_user_id, followee_user_id) +VALUES + ('11000000-0000-7000-8000-000000000002', '11000000-0000-7000-8000-000000000001'), + ('11000000-0000-7000-8000-000000000003', '11000000-0000-7000-8000-000000000001'); + +INSERT INTO community.topics (id, name) +VALUES + ('18200000-0000-7000-8000-000000000001', 'AI创作'), + ('18200000-0000-7000-8000-000000000002', '柴犬'), + ('18200000-0000-7000-8000-000000000003', 'Patbond'), + ('18200000-0000-7000-8000-000000000004', '猫咪健康'), + ('18200000-0000-7000-8000-000000000005', '求助'); + +INSERT INTO community.post_topics (post_id, topic_id) +VALUES + ('18000000-0000-7000-8000-000000000001', '18200000-0000-7000-8000-000000000001'), + ('18000000-0000-7000-8000-000000000001', '18200000-0000-7000-8000-000000000002'), + ('18000000-0000-7000-8000-000000000001', '18200000-0000-7000-8000-000000000003'), + ('18000000-0000-7000-8000-000000000002', '18200000-0000-7000-8000-000000000004'), + ('18000000-0000-7000-8000-000000000002', '18200000-0000-7000-8000-000000000005'); + +INSERT INTO platform.notifications + (id, user_id, notification_type, title, body, target_type, target_id) +VALUES + ( + '19000000-0000-7000-8000-000000000001', '11000000-0000-7000-8000-000000000001', + 'health.reminder', '豆豆的健康提醒', '犬五联加强针将在 2026-08-15 到期。', + 'vaccination', '15100000-0000-7000-8000-000000000003' + ); + +INSERT INTO platform.outbox_events + (id, aggregate_type, aggregate_id, aggregate_version, event_type, payload) +VALUES + ( + '19100000-0000-7000-8000-000000000001', 'generation_job', + '17200000-0000-7000-8000-000000000001', 1, 'creation.generation_succeeded', + '{"fixture":true,"outputAssetId":"12000000-0000-7000-8000-000000000005"}'::jsonb + ); + +ANALYZE; + +/* ------------------------------------------------------------------------- + Executable integrity checks. Any unexpected condition aborts the transaction. +--------------------------------------------------------------------------- */ + +DO $$ +DECLARE + rejected boolean := false; +BEGIN + BEGIN + INSERT INTO identity.users (username, nickname) VALUES ('DEMO_USER', '重复用户名'); + EXCEPTION WHEN unique_violation THEN + rejected := true; + END; + IF NOT rejected THEN + RAISE EXCEPTION 'validation failed: username uniqueness is not case-insensitive'; + END IF; +END; +$$; + +DO $$ +DECLARE + rejected boolean := false; +BEGIN + BEGIN + INSERT INTO pet_health.pet_weight_records (pet_id, weight_kg, measured_at) + VALUES ('14000000-0000-7000-8000-000000000001', -1, now()); + EXCEPTION WHEN check_violation THEN + rejected := true; + END; + IF NOT rejected THEN + RAISE EXCEPTION 'validation failed: negative pet weight was accepted'; + END IF; +END; +$$; + +DO $$ +DECLARE + rejected boolean := false; +BEGIN + BEGIN + INSERT INTO identity.user_addresses + (user_id, region_id, label, address_line, latitude, longitude) + VALUES + ( + '11000000-0000-7000-8000-000000000001', + '10000000-0000-7000-8000-000000000001', + '错误坐标', '只填写经度的地址', NULL, 116.443550 + ); + EXCEPTION WHEN check_violation THEN + rejected := true; + END; + IF NOT rejected THEN + RAISE EXCEPTION 'validation failed: one-sided coordinates were accepted'; + END IF; +END; +$$; + +DO $$ +DECLARE + rejected boolean := false; +BEGIN + BEGIN + INSERT INTO creation.generation_jobs + (user_id, media_kind, model_id, style_id, input_asset_id, + provider_code_snapshot, provider_model_snapshot, model_version_snapshot, style_code_snapshot, + idempotency_key, request_hash) + VALUES + ( + '11000000-0000-7000-8000-000000000001', 'video', + '17000000-0000-7000-8000-000000000001', + '17100000-0000-7000-8000-000000000005', + '12000000-0000-7000-8000-000000000004', + 'fixture', 'wrong-kind-test', '1.0', 'healing', + 'fixture-wrong-kind-must-fail', decode(repeat('71', 32), 'hex') + ); + EXCEPTION WHEN foreign_key_violation THEN + rejected := true; + END; + IF NOT rejected THEN + RAISE EXCEPTION 'validation failed: AI job accepted a model of the wrong media kind'; + END IF; +END; +$$; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM community.posts p + LEFT JOIN LATERAL ( + SELECT count(*) AS value FROM community.post_likes l WHERE l.post_id = p.id + ) likes ON true + LEFT JOIN LATERAL ( + SELECT count(*) AS value FROM community.comments c + WHERE c.post_id = p.id AND c.status = 'visible' + ) comments ON true + LEFT JOIN LATERAL ( + SELECT count(*) AS value FROM community.post_bookmarks b WHERE b.post_id = p.id + ) bookmarks ON true + WHERE p.like_count <> likes.value + OR p.comment_count <> comments.value + OR p.bookmark_count <> bookmarks.value + ) THEN + RAISE EXCEPTION 'validation failed: post counter cache does not match relation facts'; + END IF; +END; +$$; + +DO $$ +DECLARE + rejected boolean := false; +BEGIN + BEGIN + INSERT INTO marketplace.bookings + (customer_user_id, pet_id, provider_id, offering_id, resource_id, + scheduled_start_at, scheduled_end_at, status, + provider_name_snapshot, service_name_snapshot, pet_name_snapshot, address_snapshot, + base_amount_cents, discount_amount_cents, payable_amount_cents, idempotency_key, request_hash) + VALUES + ( + '11000000-0000-7000-8000-000000000001', '14000000-0000-7000-8000-000000000001', + '16000000-0000-7000-8000-000000000003', '16100000-0000-7000-8000-000000000003', + '16200000-0000-7000-8000-000000000003', + timestamptz '2030-08-20 09:30:00+08', timestamptz '2030-08-20 10:30:00+08', 'confirmed', + '阳光遛狗员-阿健', '上门遛狗 60 分钟', '豆豆', '北京市朝阳区上门服务', + 4500, 0, 4500, 'fixture-overlap-must-fail', decode(repeat('72', 32), 'hex') + ); + EXCEPTION WHEN exclusion_violation THEN + rejected := true; + END; + IF NOT rejected THEN + RAISE EXCEPTION 'validation failed: overlapping booking was accepted'; + END IF; +END; +$$; + +DO $$ +DECLARE + inserted_id uuid; +BEGIN + INSERT INTO marketplace.bookings + (customer_user_id, pet_id, provider_id, offering_id, resource_id, + scheduled_start_at, scheduled_end_at, status, + provider_name_snapshot, service_name_snapshot, pet_name_snapshot, address_snapshot, + base_amount_cents, discount_amount_cents, payable_amount_cents, idempotency_key, request_hash) + VALUES + ( + '11000000-0000-7000-8000-000000000001', '14000000-0000-7000-8000-000000000001', + '16000000-0000-7000-8000-000000000003', '16100000-0000-7000-8000-000000000003', + '16200000-0000-7000-8000-000000000003', + timestamptz '2030-08-20 10:00:00+08', timestamptz '2030-08-20 11:00:00+08', 'confirmed', + '阳光遛狗员-阿健', '上门遛狗 60 分钟', '豆豆', '北京市朝阳区上门服务', + 4500, 0, 4500, 'fixture-adjacent-booking', decode(repeat('73', 32), 'hex') + ) + RETURNING id INTO inserted_id; + + DELETE FROM marketplace.bookings WHERE id = inserted_id; +END; +$$; + +DO $$ +DECLARE + missing_indexes text; +BEGIN + WITH foreign_keys AS ( + SELECT c.oid, c.conname, c.conrelid, c.conkey + FROM pg_constraint c + JOIN pg_namespace n ON n.oid = c.connamespace + WHERE c.contype = 'f' + AND n.nspname IN ('platform', 'identity', 'media', 'pet_health', 'community', 'creation', 'marketplace') + ), missing AS ( + SELECT fk.conname + FROM foreign_keys fk + WHERE NOT EXISTS ( + SELECT 1 + FROM pg_index i + WHERE i.indrelid = fk.conrelid + AND i.indisvalid + AND i.indpred IS NULL + AND fk.conkey = ( + SELECT array_agg(k.attnum ORDER BY k.ordinality)::smallint[] + FROM unnest(i.indkey) WITH ORDINALITY AS k(attnum, ordinality) + WHERE k.ordinality <= cardinality(fk.conkey) + ) + ) + ) + SELECT string_agg(conname, ', ' ORDER BY conname) INTO missing_indexes FROM missing; + + IF missing_indexes IS NOT NULL THEN + RAISE EXCEPTION 'validation failed: foreign keys without leading indexes: %', missing_indexes; + END IF; +END; +$$; + +COMMIT; + +/* ------------------------------------------------------------------------- + Reference queries for the future API repositories +--------------------------------------------------------------------------- */ + +-- Pet health summary: latest weight + vaccine progress + next scheduled dose. +SELECT + p.id, + p.name, + latest_weight.weight_kg, + vaccine_summary.completed_doses, + vaccine_summary.total_doses, + vaccine_summary.next_planned_on +FROM pet_health.pets p +LEFT JOIN LATERAL ( + SELECT w.weight_kg + FROM pet_health.pet_weight_records w + WHERE w.pet_id = p.id + ORDER BY w.measured_at DESC, w.id DESC + LIMIT 1 +) latest_weight ON true +LEFT JOIN LATERAL ( + SELECT + count(*) FILTER (WHERE v.status = 'completed') AS completed_doses, + count(*) FILTER (WHERE v.status <> 'cancelled') AS total_doses, + min(v.planned_on) FILTER (WHERE v.status = 'scheduled') AS next_planned_on + FROM pet_health.pet_vaccinations v + WHERE v.pet_id = p.id +) vaccine_summary ON true +WHERE p.status = 'active'; + +-- Nearby providers: first use the indexed bounding box, then exact Haversine. +-- The +/- 0.10 degree fixture box is illustrative; the API must calculate a +-- latitude-aware box from the requested radius before binding these values. +WITH candidates AS ( + SELECT + p.id, + p.name, + p.kind, + p.rating_avg, + platform.distance_km(39.921900, 116.443550, p.latitude, p.longitude) AS distance_km + FROM marketplace.providers p + WHERE p.status = 'active' + AND p.region_id = '10000000-0000-7000-8000-000000000001' + AND p.latitude BETWEEN 39.821900 AND 40.021900 + AND p.longitude BETWEEN 116.343550 AND 116.543550 +) +SELECT id, name, kind, rating_avg, distance_km +FROM candidates +WHERE distance_km <= 10.0 +ORDER BY distance_km, rating_avg DESC, id +LIMIT 20; + +-- Cursor feed query; never use deep OFFSET pagination. +SELECT p.* +FROM community.posts p +WHERE p.status = 'published' + AND p.visibility = 'public' + AND (p.published_at, p.id) < (now(), 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid) +ORDER BY p.published_at DESC, p.id DESC +LIMIT 20; + +/* + Atomic like write used by the backend (placeholders intentionally commented): + + WITH inserted AS ( + INSERT INTO community.post_likes(post_id, user_id) + VALUES (:post_id, :user_id) + ON CONFLICT DO NOTHING + RETURNING post_id + ) + UPDATE community.posts p + SET like_count = p.like_count + 1 + FROM inserted i + WHERE p.id = i.post_id + RETURNING p.like_count; + + AI worker claim pattern for multiple concurrent workers: + + WITH picked AS ( + SELECT id + FROM creation.generation_jobs + WHERE status = 'queued' + AND attempt_count < max_attempts + AND next_attempt_at <= now() + ORDER BY priority DESC, next_attempt_at, created_at, id + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + UPDATE creation.generation_jobs j + SET status = 'running', started_at = now(), progress = 1, + attempt_count = attempt_count + 1, + lease_owner = :worker_id, lease_expires_at = now() + interval '2 minutes', + version = version + 1 + FROM picked + WHERE j.id = picked.id + RETURNING j.*; + + Held-booking sweeper. This worker is mandatory because CHECK constraints must + not use volatile now() in a partial index or exclusion predicate: + + WITH expired AS ( + SELECT id + FROM marketplace.bookings + WHERE status = 'held' AND hold_expires_at <= now() + ORDER BY hold_expires_at, id + FOR UPDATE SKIP LOCKED + LIMIT 100 + ) + UPDATE marketplace.bookings b + SET status = 'expired', hold_expires_at = NULL, version = version + 1 + FROM expired e + WHERE b.id = e.id + RETURNING b.id; + + Pet authorization is a domain rule, not implied by a pet foreign key. Before + creating a post, AI job, or booking, the service must require an allowed row: + + SELECT 1 + FROM pet_health.pet_owners po + WHERE po.pet_id = :pet_id + AND po.user_id = :authenticated_user_id + AND po.role IN ('owner', 'caregiver'); + + Business aggregate updates use explicit optimistic compare-and-swap. High + frequency counters do not increment the content version: + + UPDATE pet_health.pets + SET name = :name, version = version + 1 + WHERE id = :pet_id AND version = :expected_version + RETURNING version; +*/