diff --git a/database/migrations/functions/001_load_functions.sql b/database/migrations/functions/001_load_functions.sql index ed9afe3a2..0aa72d638 100644 --- a/database/migrations/functions/001_load_functions.sql +++ b/database/migrations/functions/001_load_functions.sql @@ -1,4 +1,8 @@ +{{ template "common/jsonb_geography_point.sql" }} -- Dependency for payload location mappings and distance search filters +{{ template "common/jsonb_text_array.sql" }} -- Dependency for payload text-array mappings + {{ template "auth/get_user_by_id.sql" }} -- Do not sort alphabetically, has dependency +{{ template "auth/resolve_unique_username.sql" }} -- Dependency for signup and pre-registration activation {{ template "auth/activate_pre_registered_user_email_password.sql" }} {{ template "auth/activate_pre_registered_user_external_provider.sql" }} {{ template "auth/get_user_by_email.sql" }} @@ -27,6 +31,8 @@ {{ template "common/stats_label_count_series_by_name.sql" }} {{ template "common/stats_running_total_series.sql" }} {{ template "common/stats_running_total_series_by_name.sql" }} +{{ template "common/validate_cfs_submission_label_ids.sql" }} -- Dependency for CFS submission label sync +{{ template "common/sync_cfs_submission_labels.sql" }} -- Dependency for add/update_cfs_submission {{ template "common/validate_questionnaire_questions_payload.sql" }} -- Do not sort alphabetically, dependency for add/update_event and validate_questionnaire_answers_payload {{ template "common/validate_questionnaire_answers_payload.sql" }} -- Do not sort alphabetically, dependency for attend_event, submit_event_registration_answers and prepare_event_checkout_purchase {{ template "common/get_event_full.sql" }} @@ -87,9 +93,15 @@ {{ template "dashboard-group/validate_event_series_action_event_ids.sql" }} -- Dependency for series actions {{ template "dashboard-group/validate_event_ticket_types_payload.sql" }} -- Dependency for validate_event_ticketing_payload {{ template "dashboard-group/validate_event_ticketing_payload.sql" }} -- Dependency for add/update_event +{{ template "dashboard-group/validate_add_event_dates.sql" }} -- Dependency for add_event {{ template "event/promote_event_waitlist.sql" }} -- Dependency for update_event and leave_event {{ template "dashboard-group/sync_event_discount_codes.sql" }} -- Dependency for add/update_event {{ template "dashboard-group/sync_event_ticket_types.sql" }} -- Dependency for add/update_event +{{ template "dashboard-group/is_event_meeting_in_sync.sql" }} -- Dependency for update_event +{{ template "dashboard-group/is_session_meeting_in_sync.sql" }} -- Dependency for sync_event_sessions +{{ template "dashboard-group/sync_event_cfs_labels.sql" }} -- Dependency for add/update_event +{{ template "dashboard-group/sync_event_hosts_speakers_sponsors.sql" }} -- Dependency for add/update_event +{{ template "dashboard-group/sync_event_sessions.sql" }} -- Dependency for add/update_event {{ template "dashboard-group/accept_event_invitation_request.sql" }} {{ template "dashboard-group/add_event.sql" }} {{ template "dashboard-group/add_event_series.sql" }} @@ -107,8 +119,6 @@ {{ template "dashboard-group/get_event_summary_dashboard.sql" }} -- Dependency for list_group_events {{ template "dashboard-group/get_group_sponsor.sql" }} {{ template "dashboard-group/get_group_stats.sql" }} -{{ template "dashboard-group/is_event_meeting_in_sync.sql" }} -{{ template "dashboard-group/is_session_meeting_in_sync.sql" }} {{ template "dashboard-group/invite_event_attendee.sql" }} {{ template "dashboard-group/list_cfs_submission_statuses_for_review.sql" }} {{ template "dashboard-group/list_event_approved_cfs_submissions.sql" }} @@ -136,8 +146,6 @@ {{ template "dashboard-group/search_event_attendees.sql" }} {{ template "dashboard-group/search_event_invitation_requests.sql" }} {{ template "dashboard-group/search_event_waitlist.sql" }} -{{ template "dashboard-group/sync_event_cfs_labels.sql" }} -- Dependency for update_event -{{ template "dashboard-group/sync_event_sessions.sql" }} -- Dependency for update_event {{ template "dashboard-group/unpublish_event.sql" }} {{ template "dashboard-group/unpublish_event_series_events.sql" }} {{ template "dashboard-group/update_cfs_submission.sql" }} diff --git a/database/migrations/functions/auth/activate_pre_registered_user_email_password.sql b/database/migrations/functions/auth/activate_pre_registered_user_email_password.sql index 96d2e327a..dbcac2f48 100644 --- a/database/migrations/functions/auth/activate_pre_registered_user_email_password.sql +++ b/database/migrations/functions/auth/activate_pre_registered_user_email_password.sql @@ -4,11 +4,8 @@ create or replace function activate_pre_registered_user_email_password( ) returns table("user" json, verification_code uuid) as $$ declare - v_base_username text; - v_suffix int; - v_username text; - v_username_exists boolean; v_user_id uuid; + v_username text; v_verification_code uuid; begin -- Lock the placeholder user if this email was pre-registered @@ -23,35 +20,8 @@ begin return; end if; - -- Generate a unique username using the user-provided username - v_base_username := p_user->>'username'; - v_username := v_base_username; - - select exists( - select 1 - from "user" - where username = v_username - and user_id <> v_user_id - ) into v_username_exists; - - if v_username_exists then - for v_suffix in 2..99 loop - v_username := v_base_username || v_suffix; - - select exists( - select 1 - from "user" - where username = v_username - and user_id <> v_user_id - ) into v_username_exists; - - exit when not v_username_exists; - end loop; - - if v_username_exists then - raise exception 'unable to generate unique username: all variants from % to %99 are taken', v_base_username, v_base_username; - end if; - end if; + -- Resolve the requested username while ignoring the placeholder row + v_username := resolve_unique_username(p_user->>'username', v_user_id); -- Promote the placeholder row while keeping email verification pending update "user" @@ -70,6 +40,7 @@ begin raise exception 'pre-registered user not found'; end if; + -- Create/refresh email verification code for the activated user insert into email_verification_code (user_id) values (v_user_id) on conflict (user_id) do update diff --git a/database/migrations/functions/auth/activate_pre_registered_user_external_provider.sql b/database/migrations/functions/auth/activate_pre_registered_user_external_provider.sql index ac1acaa68..c1357a0b8 100644 --- a/database/migrations/functions/auth/activate_pre_registered_user_external_provider.sql +++ b/database/migrations/functions/auth/activate_pre_registered_user_external_provider.sql @@ -5,40 +5,10 @@ create or replace function activate_pre_registered_user_external_provider( ) returns json as $$ declare - v_base_username text; - v_suffix int; v_username text; - v_username_exists boolean; begin - -- Generate a unique username using the provider-provided username - v_base_username := p_user->>'username'; - v_username := v_base_username; - - select exists( - select 1 - from "user" - where username = v_username - and user_id <> p_user_id - ) into v_username_exists; - - if v_username_exists then - for v_suffix in 2..99 loop - v_username := v_base_username || v_suffix; - - select exists( - select 1 - from "user" - where username = v_username - and user_id <> p_user_id - ) into v_username_exists; - - exit when not v_username_exists; - end loop; - - if v_username_exists then - raise exception 'unable to generate unique username: all variants from % to %99 are taken', v_base_username, v_base_username; - end if; - end if; + -- Resolve the requested username while ignoring the placeholder row + v_username := resolve_unique_username(p_user->>'username', p_user_id); -- Promote the placeholder record into a regular verified user update "user" diff --git a/database/migrations/functions/auth/resolve_unique_username.sql b/database/migrations/functions/auth/resolve_unique_username.sql new file mode 100644 index 000000000..69782291c --- /dev/null +++ b/database/migrations/functions/auth/resolve_unique_username.sql @@ -0,0 +1,50 @@ +-- Resolves a unique username by appending a numeric suffix when needed. +create or replace function resolve_unique_username( + p_base_username text, + p_excluded_user_id uuid default null +) +returns text as $$ +declare + v_suffix int; + v_username text := p_base_username; + v_username_exists boolean; +begin + -- Check whether the base username is available + select exists( + select 1 + from "user" + where username = v_username + and ( + p_excluded_user_id is null + or user_id <> p_excluded_user_id + ) + ) into v_username_exists; + + -- If username exists, try with numeric suffixes from 2 to 99 + if v_username_exists then + for v_suffix in 2..99 loop + v_username := p_base_username || v_suffix; + + select exists( + select 1 + from "user" + where username = v_username + and ( + p_excluded_user_id is null + or user_id <> p_excluded_user_id + ) + ) into v_username_exists; + + exit when not v_username_exists; + end loop; + + if v_username_exists then + raise exception 'unable to generate unique username: all variants from % to %99 are taken', + p_base_username, + p_base_username; + end if; + end if; + + return v_username; +end; +$$ language plpgsql; diff --git a/database/migrations/functions/auth/sign_up_user.sql b/database/migrations/functions/auth/sign_up_user.sql index b2a175f0c..4a197a90b 100644 --- a/database/migrations/functions/auth/sign_up_user.sql +++ b/database/migrations/functions/auth/sign_up_user.sql @@ -5,40 +5,12 @@ create or replace function sign_up_user( ) returns table("user" json, verification_code uuid) as $$ declare - v_username text; - v_base_username text; - v_suffix int; - v_username_exists boolean; v_user_id uuid; + v_username text; v_verification_code uuid; begin - -- Get the base username - v_base_username := p_user->>'username'; - v_username := v_base_username; - - -- Check if username exists - select exists( - select 1 from "user" - where username = v_username - ) into v_username_exists; - - -- If username exists, try with numeric suffixes from 2 to 99 - if v_username_exists then - for v_suffix in 2..99 loop - v_username := v_base_username || v_suffix; - select exists( - select 1 from "user" - where username = v_username - ) into v_username_exists; - - exit when not v_username_exists; - end loop; - - -- If still exists after trying all suffixes, raise error - if v_username_exists then - raise exception 'unable to generate unique username: all variants from % to %99 are taken', v_base_username, v_base_username; - end if; - end if; + -- Resolve the requested username before inserting the user + v_username := resolve_unique_username(p_user->>'username'); -- Insert the user with the available username insert into "user" ( diff --git a/database/migrations/functions/auth/update_user_details.sql b/database/migrations/functions/auth/update_user_details.sql index 190331198..5a00b5b8b 100644 --- a/database/migrations/functions/auth/update_user_details.sql +++ b/database/migrations/functions/auth/update_user_details.sql @@ -15,11 +15,7 @@ begin country = nullif(p_user->>'country', ''), facebook_url = nullif(p_user->>'facebook_url', ''), github_url = nullif(p_user->>'github_url', ''), - interests = case - when p_user ? 'interests' and jsonb_typeof(p_user->'interests') != 'null' then - array(select jsonb_array_elements_text(p_user->'interests')) - else null - end, + interests = jsonb_text_array(p_user->'interests'), linkedin_url = nullif(p_user->>'linkedin_url', ''), optional_notifications_enabled = coalesce( (p_user->>'optional_notifications_enabled')::boolean, diff --git a/database/migrations/functions/common/jsonb_geography_point.sql b/database/migrations/functions/common/jsonb_geography_point.sql new file mode 100644 index 000000000..b05349b20 --- /dev/null +++ b/database/migrations/functions/common/jsonb_geography_point.sql @@ -0,0 +1,17 @@ +-- Converts JSONB latitude and longitude fields into a geography point. +create or replace function jsonb_geography_point(p_value jsonb) +returns geography as $$ + select case + when p_value is null + or jsonb_typeof(p_value) = 'null' + or p_value->>'latitude' is null + or p_value->>'longitude' is null then null + else ST_SetSRID( + ST_MakePoint( + (p_value->>'longitude')::float, + (p_value->>'latitude')::float + ), + 4326 + )::geography + end; +$$ language sql immutable; diff --git a/database/migrations/functions/common/jsonb_text_array.sql b/database/migrations/functions/common/jsonb_text_array.sql new file mode 100644 index 000000000..214896ab5 --- /dev/null +++ b/database/migrations/functions/common/jsonb_text_array.sql @@ -0,0 +1,8 @@ +-- Converts a JSONB text array into a SQL text array. +create or replace function jsonb_text_array(p_value jsonb) +returns text[] as $$ + select case + when p_value is null or jsonb_typeof(p_value) = 'null' then null + else array(select jsonb_array_elements_text(p_value)) + end; +$$ language sql immutable; diff --git a/database/migrations/functions/common/search_events.sql b/database/migrations/functions/common/search_events.sql index b3582642f..135c093f8 100644 --- a/database/migrations/functions/common/search_events.sql +++ b/database/migrations/functions/common/search_events.sql @@ -57,7 +57,7 @@ begin from jsonb_array_elements_text(p_filters->'kind') e; end if; if p_filters ? 'latitude' and p_filters ? 'longitude' then - v_user_location := st_setsrid(st_makepoint((p_filters->>'longitude')::real, (p_filters->>'latitude')::real), 4326); + v_user_location := jsonb_geography_point(p_filters); if p_filters ? 'distance' then v_max_distance := (p_filters->>'distance')::real; end if; diff --git a/database/migrations/functions/common/search_groups.sql b/database/migrations/functions/common/search_groups.sql index 47b14ce14..896758bda 100644 --- a/database/migrations/functions/common/search_groups.sql +++ b/database/migrations/functions/common/search_groups.sql @@ -34,7 +34,7 @@ begin from jsonb_array_elements_text(p_filters->'group_category') e; end if; if p_filters ? 'latitude' and p_filters ? 'longitude' then - v_user_location := st_setsrid(st_makepoint((p_filters->>'longitude')::real, (p_filters->>'latitude')::real), 4326); + v_user_location := jsonb_geography_point(p_filters); if p_filters ? 'distance' then v_max_distance := (p_filters->>'distance')::real; end if; diff --git a/database/migrations/functions/common/sync_cfs_submission_labels.sql b/database/migrations/functions/common/sync_cfs_submission_labels.sql new file mode 100644 index 000000000..17fb79192 --- /dev/null +++ b/database/migrations/functions/common/sync_cfs_submission_labels.sql @@ -0,0 +1,34 @@ +-- Replaces the labels linked to a CFS submission. +create or replace function sync_cfs_submission_labels( + p_cfs_submission_id uuid, + p_event_id uuid, + p_label_ids uuid[] +) +returns void as $$ +begin + -- Ensure the submission belongs to the event before mutating labels + perform 1 + from cfs_submission cs + where cs.cfs_submission_id = p_cfs_submission_id + and cs.event_id = p_event_id; + + if not found then + raise exception 'submission not found'; + end if; + + -- Validate supplied labels before replacing existing links + perform validate_cfs_submission_label_ids(p_event_id, p_label_ids); + + -- Remove labels omitted from the payload + delete from cfs_submission_label + where cfs_submission_id = p_cfs_submission_id; + + -- Insert supplied labels, deduplicating repeated IDs + if p_label_ids is not null then + insert into cfs_submission_label (cfs_submission_id, event_cfs_label_id) + select p_cfs_submission_id, input_label.event_cfs_label_id + from unnest(p_label_ids) as input_label(event_cfs_label_id) + group by input_label.event_cfs_label_id; + end if; +end; +$$ language plpgsql; diff --git a/database/migrations/functions/common/validate_cfs_submission_label_ids.sql b/database/migrations/functions/common/validate_cfs_submission_label_ids.sql new file mode 100644 index 000000000..9c632aa6f --- /dev/null +++ b/database/migrations/functions/common/validate_cfs_submission_label_ids.sql @@ -0,0 +1,29 @@ +-- Validates CFS submission label IDs for an event. +create or replace function validate_cfs_submission_label_ids( + p_event_id uuid, + p_label_ids uuid[] +) +returns void as $$ +begin + -- Enforce the maximum number of labels per submission + if coalesce(array_length(p_label_ids, 1), 0) > 10 then + raise exception 'too many submission labels'; + end if; + + -- Ensure all supplied labels belong to the event + if p_label_ids is not null then + perform 1 + from unnest(p_label_ids) as input_label(event_cfs_label_id) + where not exists ( + select 1 + from event_cfs_label ecl + where ecl.event_cfs_label_id = input_label.event_cfs_label_id + and ecl.event_id = p_event_id + ); + + if found then + raise exception 'invalid event CFS labels'; + end if; + end if; +end; +$$ language plpgsql; diff --git a/database/migrations/functions/dashboard-common/update_group.sql b/database/migrations/functions/dashboard-common/update_group.sql index f20ef828a..0f119038b 100644 --- a/database/migrations/functions/dashboard-common/update_group.sql +++ b/database/migrations/functions/dashboard-common/update_group.sql @@ -75,31 +75,19 @@ begin github_url = nullif(p_group->>'github_url', ''), instagram_url = nullif(p_group->>'instagram_url', ''), linkedin_url = nullif(p_group->>'linkedin_url', ''), - location = case - when (p_group->>'latitude') is not null and (p_group->>'longitude') is not null - then ST_SetSRID(ST_MakePoint((p_group->>'longitude')::float, (p_group->>'latitude')::float), 4326)::geography - else null - end, + location = jsonb_geography_point(p_group), logo_url = nullif(p_group->>'logo_url', ''), og_image_url = nullif(p_group->>'og_image_url', ''), payment_recipient = case when p_group ? 'payment_recipient' then v_new_payment_recipient else payment_recipient end, - photos_urls = case - when p_group ? 'photos_urls' and jsonb_typeof(p_group->'photos_urls') != 'null' then - array(select jsonb_array_elements_text(p_group->'photos_urls')) - else null - end, + photos_urls = jsonb_text_array(p_group->'photos_urls'), region_id = case when p_group->>'region_id' <> '' then (p_group->>'region_id')::uuid else null end, slack_url = nullif(p_group->>'slack_url', ''), slug_pretty = nullif(btrim(p_group->>'slug_pretty'), ''), state = nullif(p_group->>'state', ''), - tags = case - when p_group ? 'tags' and jsonb_typeof(p_group->'tags') != 'null' then - array(select jsonb_array_elements_text(p_group->'tags')) - else null - end, + tags = jsonb_text_array(p_group->'tags'), twitter_url = nullif(p_group->>'twitter_url', ''), website_url = nullif(p_group->>'website_url', ''), wechat_url = nullif(p_group->>'wechat_url', ''), diff --git a/database/migrations/functions/dashboard-community/add_group.sql b/database/migrations/functions/dashboard-community/add_group.sql index 1917644b5..faa0889e5 100644 --- a/database/migrations/functions/dashboard-community/add_group.sql +++ b/database/migrations/functions/dashboard-community/add_group.sql @@ -86,18 +86,14 @@ begin nullif(p_group->>'github_url', ''), nullif(p_group->>'instagram_url', ''), nullif(p_group->>'linkedin_url', ''), - case - when (p_group->>'latitude') is not null and (p_group->>'longitude') is not null - then ST_SetSRID(ST_MakePoint((p_group->>'longitude')::float, (p_group->>'latitude')::float), 4326)::geography - else null - end, + jsonb_geography_point(p_group), nullif(p_group->>'logo_url', ''), nullif(p_group->>'og_image_url', ''), - case when p_group->'photos_urls' is not null then array(select jsonb_array_elements_text(p_group->'photos_urls')) else null end, + jsonb_text_array(p_group->'photos_urls'), case when p_group->>'region_id' <> '' then (p_group->>'region_id')::uuid else null end, nullif(p_group->>'slack_url', ''), nullif(p_group->>'state', ''), - case when p_group->'tags' is not null then array(select jsonb_array_elements_text(p_group->'tags')) else null end, + jsonb_text_array(p_group->'tags'), nullif(p_group->>'twitter_url', ''), nullif(p_group->>'website_url', ''), nullif(p_group->>'wechat_url', ''), diff --git a/database/migrations/functions/dashboard-community/update_community.sql b/database/migrations/functions/dashboard-community/update_community.sql index acb498519..f7d582ae2 100644 --- a/database/migrations/functions/dashboard-community/update_community.sql +++ b/database/migrations/functions/dashboard-community/update_community.sql @@ -29,11 +29,7 @@ begin linkedin_url = nullif(p_data->>'linkedin_url', ''), new_group_details = nullif(p_data->>'new_group_details', ''), og_image_url = nullif(p_data->>'og_image_url', ''), - photos_urls = case - when p_data ? 'photos_urls' and jsonb_typeof(p_data->'photos_urls') != 'null' then - array(select jsonb_array_elements_text(p_data->'photos_urls')) - else null - end, + photos_urls = jsonb_text_array(p_data->'photos_urls'), slack_url = nullif(p_data->>'slack_url', ''), twitter_url = nullif(p_data->>'twitter_url', ''), website_url = nullif(p_data->>'website_url', ''), diff --git a/database/migrations/functions/dashboard-group/add_event.sql b/database/migrations/functions/dashboard-group/add_event.sql index 91551c879..977061b76 100644 --- a/database/migrations/functions/dashboard-group/add_event.sql +++ b/database/migrations/functions/dashboard-group/add_event.sql @@ -7,29 +7,14 @@ create or replace function add_event( ) returns uuid as $$ declare - v_cfs_label jsonb; v_discount_codes jsonb := nullif(p_event->'discount_codes', 'null'::jsonb); v_effective_capacity int; v_event_attendee_approval_required boolean := coalesce((p_event->>'attendee_approval_required')::boolean, false); - v_ends_at timestamptz; v_event_id uuid; - v_event_speaker jsonb; - v_host_id uuid; v_max_retries int := 10; v_payment_currency_code text := nullif(p_event->>'payment_currency_code', ''); v_retries int := 0; - v_session jsonb; - v_session_ends_at timestamptz; - v_session_id uuid; - v_session_speaker jsonb; - v_session_starts_at timestamptz; v_slug text; - v_speaker_featured boolean; - v_speaker_id uuid; - v_sponsor jsonb; - v_sponsor_id uuid; - v_sponsor_level text; - v_starts_at timestamptz; v_ticket_types jsonb := nullif(p_event->'ticket_types', 'null'::jsonb); v_ticket_capacity int := get_event_ticket_capacity(nullif(p_event->'ticket_types', 'null'::jsonb)); begin @@ -53,38 +38,8 @@ begin coalesce((p_event->>'waitlist_enabled')::boolean, false) ); - -- Validate event dates are not in the past - if p_event->>'starts_at' is not null then - v_starts_at := (p_event->>'starts_at')::timestamp at time zone (p_event->>'timezone'); - if v_starts_at < current_timestamp then - raise exception 'event starts_at cannot be in the past'; - end if; - end if; - - if p_event->>'ends_at' is not null then - v_ends_at := (p_event->>'ends_at')::timestamp at time zone (p_event->>'timezone'); - if v_ends_at < current_timestamp then - raise exception 'event ends_at cannot be in the past'; - end if; - end if; - - -- Validate session dates are not in the past - if p_event->'sessions' is not null then - for v_session in select jsonb_array_elements(p_event->'sessions') - loop - v_session_starts_at := (v_session->>'starts_at')::timestamp at time zone (p_event->>'timezone'); - if v_session_starts_at < current_timestamp then - raise exception 'session starts_at cannot be in the past'; - end if; - - if v_session->>'ends_at' is not null then - v_session_ends_at := (v_session->>'ends_at')::timestamp at time zone (p_event->>'timezone'); - if v_session_ends_at < current_timestamp then - raise exception 'session ends_at cannot be in the past'; - end if; - end if; - end loop; - end if; + -- Validate add-specific event and session date rules + perform validate_add_event_dates(p_event); -- Validate capacity and CFS label rules perform validate_event_capacity( @@ -170,14 +125,10 @@ begin nullif(p_event->>'description_short', ''), (p_event->>'ends_at')::timestamp at time zone (p_event->>'timezone'), coalesce((p_event->>'event_reminder_enabled')::boolean, true), - case - when (p_event->>'latitude') is not null and (p_event->>'longitude') is not null - then ST_SetSRID(ST_MakePoint((p_event->>'longitude')::float, (p_event->>'latitude')::float), 4326)::geography - else null - end, + jsonb_geography_point(p_event), nullif(p_event->>'logo_url', ''), nullif(p_event->>'luma_url', ''), - case when p_event->'meeting_hosts' is not null then array(select jsonb_array_elements_text(p_event->'meeting_hosts')) else null end, + jsonb_text_array(p_event->'meeting_hosts'), case when (p_event->>'meeting_requested')::boolean = true then false else null @@ -191,11 +142,11 @@ begin (p_event->>'meeting_requested')::boolean, nullif(p_event->>'meetup_url', ''), v_payment_currency_code, - case when p_event->'photos_urls' is not null then array(select jsonb_array_elements_text(p_event->'photos_urls')) else null end, + jsonb_text_array(p_event->'photos_urls'), (p_event->>'registration_required')::boolean, coalesce(p_event->'registration_questions', '[]'::jsonb), (p_event->>'starts_at')::timestamp at time zone (p_event->>'timezone'), - case when p_event->'tags' is not null then array(select jsonb_array_elements_text(p_event->'tags')) else null end, + jsonb_text_array(p_event->'tags'), nullif(p_event->>'venue_address', ''), nullif(p_event->>'venue_city', ''), nullif(p_event->>'venue_country_code', ''), @@ -231,112 +182,13 @@ begin perform sync_event_ticket_types(v_event_id, v_ticket_types); -- Insert CFS labels - if p_event->'cfs_labels' is not null then - for v_cfs_label in select jsonb_array_elements(p_event->'cfs_labels') - loop - insert into event_cfs_label (event_id, name, color) - values ( - v_event_id, - nullif(v_cfs_label->>'name', ''), - v_cfs_label->>'color' - ); - end loop; - end if; + perform sync_event_cfs_labels(v_event_id, p_event->'cfs_labels'); - -- Insert event hosts - if p_event->'hosts' is not null then - for v_host_id in select (jsonb_array_elements_text(p_event->'hosts'))::uuid - loop - insert into event_host (event_id, user_id) - values (v_event_id, v_host_id); - end loop; - end if; - - -- Insert event speakers - if p_event->'speakers' is not null then - for v_event_speaker in select jsonb_array_elements(p_event->'speakers') - loop - -- Extract speaker details - v_speaker_id := (v_event_speaker->>'user_id')::uuid; - v_speaker_featured := (v_event_speaker->>'featured')::boolean; - - insert into event_speaker (event_id, user_id, featured) - values (v_event_id, v_speaker_id, v_speaker_featured); - end loop; - end if; - - -- Insert event sponsors with per-event level - if p_event->'sponsors' is not null then - for v_sponsor in select jsonb_array_elements(p_event->'sponsors') - loop - -- Extract sponsor details - v_sponsor_id := (v_sponsor->>'group_sponsor_id')::uuid; - v_sponsor_level := v_sponsor->>'level'; - - insert into event_sponsor (event_id, group_sponsor_id, level) - values (v_event_id, v_sponsor_id, v_sponsor_level); - end loop; - end if; + -- Insert event hosts, speakers, and sponsors + perform sync_event_hosts_speakers_sponsors(v_event_id, p_event); -- Insert sessions and speakers - if p_event->'sessions' is not null then - for v_session in select jsonb_array_elements(p_event->'sessions') - loop - -- Insert session - insert into session ( - event_id, - name, - description, - starts_at, - ends_at, - cfs_submission_id, - session_kind_id, - location, - meeting_hosts, - meeting_in_sync, - meeting_join_instructions, - meeting_join_url, - meeting_provider_id, - meeting_recording_published, - meeting_recording_url, - meeting_requested - ) values ( - v_event_id, - v_session->>'name', - nullif(v_session->>'description', ''), - (v_session->>'starts_at')::timestamp at time zone (p_event->>'timezone'), - (v_session->>'ends_at')::timestamp at time zone (p_event->>'timezone'), - nullif(v_session->>'cfs_submission_id', '')::uuid, - v_session->>'kind', - nullif(v_session->>'location', ''), - case when v_session->'meeting_hosts' is not null then array(select jsonb_array_elements_text(v_session->'meeting_hosts')) else null end, - case - when (v_session->>'meeting_requested')::boolean = true then false - else null - end, - nullif(v_session->>'meeting_join_instructions', ''), - nullif(v_session->>'meeting_join_url', ''), - nullif(v_session->>'meeting_provider_id', ''), - coalesce((v_session->>'meeting_recording_published')::boolean, false), - nullif(v_session->>'meeting_recording_url', ''), - (v_session->>'meeting_requested')::boolean - ) - returning session_id into v_session_id; - - -- Insert speakers for this session - if v_session->'speakers' is not null then - for v_session_speaker in select jsonb_array_elements(v_session->'speakers') - loop - -- Extract speaker details - v_speaker_id := (v_session_speaker->>'user_id')::uuid; - v_speaker_featured := (v_session_speaker->>'featured')::boolean; - - insert into session_speaker (session_id, user_id, featured) - values (v_session_id, v_speaker_id, v_speaker_featured); - end loop; - end if; - end loop; - end if; + perform sync_event_sessions(v_event_id, p_event, '{}'::jsonb); -- Track the created event perform insert_audit_log( diff --git a/database/migrations/functions/dashboard-group/is_event_meeting_in_sync.sql b/database/migrations/functions/dashboard-group/is_event_meeting_in_sync.sql index d5e6ab1e6..6d7ef9eed 100644 --- a/database/migrations/functions/dashboard-group/is_event_meeting_in_sync.sql +++ b/database/migrations/functions/dashboard-group/is_event_meeting_in_sync.sql @@ -7,7 +7,7 @@ returns boolean as $$ declare v_before_ends_at timestamptz := to_timestamp((p_before_event->>'ends_at')::double precision); v_before_host_ids uuid[]; - v_before_meeting_hosts text[] := case when p_before_event->'meeting_hosts' is not null then array(select jsonb_array_elements_text(p_before_event->'meeting_hosts')) else null end; + v_before_meeting_hosts text[] := jsonb_text_array(p_before_event->'meeting_hosts'); v_before_meeting_in_sync boolean := (p_before_event->>'meeting_in_sync')::boolean; v_before_meeting_provider_id text := p_before_event->>'meeting_provider_id'; v_before_meeting_recording_requested boolean := coalesce((p_before_event->>'meeting_recording_requested')::boolean, true); @@ -19,7 +19,7 @@ declare v_after_ends_at timestamptz; v_after_host_ids uuid[]; - v_after_meeting_hosts text[] := case when p_after_event->'meeting_hosts' is not null then array(select jsonb_array_elements_text(p_after_event->'meeting_hosts')) else null end; + v_after_meeting_hosts text[] := jsonb_text_array(p_after_event->'meeting_hosts'); v_after_meeting_provider_id text := p_after_event->>'meeting_provider_id'; v_after_meeting_recording_requested boolean := coalesce((p_after_event->>'meeting_recording_requested')::boolean, true); v_after_meeting_requested boolean := (p_after_event->>'meeting_requested')::boolean; diff --git a/database/migrations/functions/dashboard-group/is_session_meeting_in_sync.sql b/database/migrations/functions/dashboard-group/is_session_meeting_in_sync.sql index b9cb4d34d..fceb33503 100644 --- a/database/migrations/functions/dashboard-group/is_session_meeting_in_sync.sql +++ b/database/migrations/functions/dashboard-group/is_session_meeting_in_sync.sql @@ -10,7 +10,7 @@ declare v_after_ends_at timestamptz; v_after_event_host_ids uuid[]; v_after_event_meeting_recording_requested boolean := coalesce((p_after_event->>'meeting_recording_requested')::boolean, true); - v_after_meeting_hosts text[] := case when p_after_session->'meeting_hosts' is not null then array(select jsonb_array_elements_text(p_after_session->'meeting_hosts')) else null end; + v_after_meeting_hosts text[] := jsonb_text_array(p_after_session->'meeting_hosts'); v_after_meeting_provider_id text := p_after_session->>'meeting_provider_id'; v_after_meeting_requested boolean := (p_after_session->>'meeting_requested')::boolean; v_after_name text := p_after_session->>'name'; @@ -22,7 +22,7 @@ declare v_before_ends_at timestamptz := to_timestamp((p_before_session->>'ends_at')::double precision); v_before_event_host_ids uuid[]; v_before_event_meeting_recording_requested boolean := coalesce((p_before_event->>'meeting_recording_requested')::boolean, true); - v_before_meeting_hosts text[] := case when p_before_session->'meeting_hosts' is not null then array(select jsonb_array_elements_text(p_before_session->'meeting_hosts')) else null end; + v_before_meeting_hosts text[] := jsonb_text_array(p_before_session->'meeting_hosts'); v_before_meeting_in_sync boolean := (p_before_session->>'meeting_in_sync')::boolean; v_before_meeting_provider_id text := p_before_session->>'meeting_provider_id'; v_before_meeting_requested boolean := coalesce((p_before_session->>'meeting_requested')::boolean, false); diff --git a/database/migrations/functions/dashboard-group/sync_event_hosts_speakers_sponsors.sql b/database/migrations/functions/dashboard-group/sync_event_hosts_speakers_sponsors.sql new file mode 100644 index 000000000..31424d2b6 --- /dev/null +++ b/database/migrations/functions/dashboard-group/sync_event_hosts_speakers_sponsors.sql @@ -0,0 +1,31 @@ +-- sync_event_hosts_speakers_sponsors synchronizes an event's hosts, speakers, and sponsors. +create or replace function sync_event_hosts_speakers_sponsors( + p_event_id uuid, + p_event jsonb +) +returns void as $$ +begin + -- Replace associations from the payload + delete from event_host where event_id = p_event_id; + delete from event_speaker where event_id = p_event_id; + delete from event_sponsor where event_id = p_event_id; + + if p_event->'hosts' is not null then + insert into event_host (event_id, user_id) + select p_event_id, host.user_id::uuid + from jsonb_array_elements_text(p_event->'hosts') as host(user_id); + end if; + + if p_event->'speakers' is not null then + insert into event_speaker (event_id, user_id, featured) + select p_event_id, speaker.user_id, speaker.featured + from jsonb_to_recordset(p_event->'speakers') as speaker(featured boolean, user_id uuid); + end if; + + if p_event->'sponsors' is not null then + insert into event_sponsor (event_id, group_sponsor_id, level) + select p_event_id, sponsor.group_sponsor_id, sponsor.level + from jsonb_to_recordset(p_event->'sponsors') as sponsor(group_sponsor_id uuid, level text); + end if; +end; +$$ language plpgsql; diff --git a/database/migrations/functions/dashboard-group/sync_event_sessions.sql b/database/migrations/functions/dashboard-group/sync_event_sessions.sql index 4e5ae1b6a..2271102ee 100644 --- a/database/migrations/functions/dashboard-group/sync_event_sessions.sql +++ b/database/migrations/functions/dashboard-group/sync_event_sessions.sql @@ -23,11 +23,7 @@ begin for v_session in select jsonb_array_elements(p_event->'sessions') loop v_session_ends_at := (v_session->>'ends_at')::timestamp at time zone v_timezone; - v_session_meeting_hosts := case - when v_session->'meeting_hosts' is not null - then array(select jsonb_array_elements_text(v_session->'meeting_hosts')) - else null - end; + v_session_meeting_hosts := jsonb_text_array(v_session->'meeting_hosts'); v_session_starts_at := (v_session->>'starts_at')::timestamp at time zone v_timezone; if v_session->>'session_id' is not null then diff --git a/database/migrations/functions/dashboard-group/update_cfs_submission.sql b/database/migrations/functions/dashboard-group/update_cfs_submission.sql index 50811a5cf..c94c1b050 100644 --- a/database/migrations/functions/dashboard-group/update_cfs_submission.sql +++ b/database/migrations/functions/dashboard-group/update_cfs_submission.sql @@ -7,6 +7,7 @@ create or replace function update_cfs_submission( ) returns boolean as $$ declare + v_label_ids uuid[]; v_notify boolean; v_previous_action_required_message text; v_previous_status_id text; @@ -30,19 +31,13 @@ begin end if; if p_submission->'label_ids' is not null then - perform 1 - from jsonb_array_elements_text(p_submission->'label_ids') as input_label_id - where not exists ( - select 1 - from event_cfs_label ecl - where ecl.event_cfs_label_id = input_label_id::uuid - and ecl.event_id = p_event_id + v_label_ids := array( + select input_label_id::uuid + from jsonb_array_elements_text(p_submission->'label_ids') as input_label_id ); - - if found then - raise exception 'invalid event CFS labels'; - end if; end if; + + perform validate_cfs_submission_label_ids(p_event_id, v_label_ids); end if; -- Validate rating payload (`0` clears; `1`-`5` sets rating) @@ -89,15 +84,7 @@ begin -- Replace submission labels if p_submission ? 'label_ids' then - delete from cfs_submission_label - where cfs_submission_id = p_cfs_submission_id; - - if p_submission->'label_ids' is not null then - insert into cfs_submission_label (cfs_submission_id, event_cfs_label_id) - select p_cfs_submission_id, input_label_id::uuid - from jsonb_array_elements_text(p_submission->'label_ids') as input_label_id - group by input_label_id::uuid; - end if; + perform sync_cfs_submission_labels(p_cfs_submission_id, p_event_id, v_label_ids); end if; -- Upsert or remove the reviewer rating diff --git a/database/migrations/functions/dashboard-group/update_event.sql b/database/migrations/functions/dashboard-group/update_event.sql index c533a2c18..e9298be96 100644 --- a/database/migrations/functions/dashboard-group/update_event.sql +++ b/database/migrations/functions/dashboard-group/update_event.sql @@ -54,33 +54,10 @@ begin -- Parse payload values used across the update flow v_event_capacity_before := (v_event_before->>'capacity')::int; - v_event_location := case - when (p_event->>'latitude') is not null - and (p_event->>'longitude') is not null - then ST_SetSRID( - ST_MakePoint( - (p_event->>'longitude')::float, - (p_event->>'latitude')::float - ), - 4326 - )::geography - else null - end; - v_event_meeting_hosts := case - when p_event->'meeting_hosts' is not null - then array(select jsonb_array_elements_text(p_event->'meeting_hosts')) - else null - end; - v_event_photos_urls := case - when p_event->'photos_urls' is not null - then array(select jsonb_array_elements_text(p_event->'photos_urls')) - else null - end; - v_event_tags := case - when p_event->'tags' is not null - then array(select jsonb_array_elements_text(p_event->'tags')) - else null - end; + v_event_location := jsonb_geography_point(p_event); + v_event_meeting_hosts := jsonb_text_array(p_event->'meeting_hosts'); + v_event_photos_urls := jsonb_text_array(p_event->'photos_urls'); + v_event_tags := jsonb_text_array(p_event->'tags'); -- Resolve ticketing values and the effective event capacity v_discount_codes := case @@ -314,31 +291,8 @@ begin -- Synchronize event CFS labels perform sync_event_cfs_labels(p_event_id, p_event->'cfs_labels'); - -- Delete existing hosts, sponsors, sessions and speakers - delete from event_host where event_id = p_event_id; - delete from event_speaker where event_id = p_event_id; - delete from event_sponsor where event_id = p_event_id; - - -- Insert event hosts - if p_event->'hosts' is not null then - insert into event_host (event_id, user_id) - select p_event_id, host.user_id::uuid - from jsonb_array_elements_text(p_event->'hosts') as host(user_id); - end if; - - -- Insert event speakers - if p_event->'speakers' is not null then - insert into event_speaker (event_id, user_id, featured) - select p_event_id, speaker.user_id, speaker.featured - from jsonb_to_recordset(p_event->'speakers') as speaker(featured boolean, user_id uuid); - end if; - - -- Insert event sponsors with per-event level - if p_event->'sponsors' is not null then - insert into event_sponsor (event_id, group_sponsor_id, level) - select p_event_id, sponsor.group_sponsor_id, sponsor.level - from jsonb_to_recordset(p_event->'sponsors') as sponsor(group_sponsor_id uuid, level text); - end if; + -- Synchronize event hosts, speakers, and sponsors + perform sync_event_hosts_speakers_sponsors(p_event_id, p_event); -- Synchronize event sessions and speakers perform sync_event_sessions(p_event_id, p_event, v_event_before); diff --git a/database/migrations/functions/dashboard-group/validate_add_event_dates.sql b/database/migrations/functions/dashboard-group/validate_add_event_dates.sql new file mode 100644 index 000000000..681588947 --- /dev/null +++ b/database/migrations/functions/dashboard-group/validate_add_event_dates.sql @@ -0,0 +1,45 @@ +-- validate_add_event_dates validates add-specific event and session dates. +create or replace function validate_add_event_dates(p_event jsonb) +returns void as $$ +declare + v_ends_at timestamptz; + v_session jsonb; + v_session_ends_at timestamptz; + v_session_starts_at timestamptz; + v_starts_at timestamptz; + v_timezone text := p_event->>'timezone'; +begin + -- New events cannot be created with past event dates + if p_event->>'starts_at' is not null then + v_starts_at := (p_event->>'starts_at')::timestamp at time zone v_timezone; + if v_starts_at < current_timestamp then + raise exception 'event starts_at cannot be in the past'; + end if; + end if; + + if p_event->>'ends_at' is not null then + v_ends_at := (p_event->>'ends_at')::timestamp at time zone v_timezone; + if v_ends_at < current_timestamp then + raise exception 'event ends_at cannot be in the past'; + end if; + end if; + + -- New event sessions cannot be created with past session dates + if p_event->'sessions' is not null then + for v_session in select jsonb_array_elements(p_event->'sessions') + loop + v_session_starts_at := (v_session->>'starts_at')::timestamp at time zone v_timezone; + if v_session_starts_at < current_timestamp then + raise exception 'session starts_at cannot be in the past'; + end if; + + if v_session->>'ends_at' is not null then + v_session_ends_at := (v_session->>'ends_at')::timestamp at time zone v_timezone; + if v_session_ends_at < current_timestamp then + raise exception 'session ends_at cannot be in the past'; + end if; + end if; + end loop; + end if; +end; +$$ language plpgsql; diff --git a/database/migrations/functions/dashboard-user/submit_event_registration_answers.sql b/database/migrations/functions/dashboard-user/submit_event_registration_answers.sql index 73528366b..f83b2fe08 100644 --- a/database/migrations/functions/dashboard-user/submit_event_registration_answers.sql +++ b/database/migrations/functions/dashboard-user/submit_event_registration_answers.sql @@ -1,5 +1,4 @@ -- Submits or updates registration answers for a user's event registration. -drop function if exists submit_event_registration_answers(uuid, uuid, uuid, jsonb); create or replace function submit_event_registration_answers( p_actor_user_id uuid, p_community_id uuid, diff --git a/database/migrations/functions/event/add_cfs_submission.sql b/database/migrations/functions/event/add_cfs_submission.sql index 1d93a563f..9e75d4cf0 100644 --- a/database/migrations/functions/event/add_cfs_submission.sql +++ b/database/migrations/functions/event/add_cfs_submission.sql @@ -61,24 +61,7 @@ begin end if; -- Validate labels payload - if coalesce(array_length(p_label_ids, 1), 0) > 10 then - raise exception 'too many submission labels'; - end if; - - if p_label_ids is not null then - perform 1 - from unnest(p_label_ids) as input_label_id - where not exists ( - select 1 - from event_cfs_label ecl - where ecl.event_cfs_label_id = input_label_id - and ecl.event_id = p_event_id - ); - - if found then - raise exception 'invalid event CFS labels'; - end if; - end if; + perform validate_cfs_submission_label_ids(p_event_id, p_label_ids); -- Create submission insert into cfs_submission ( @@ -93,12 +76,7 @@ begin returning cfs_submission_id into v_submission_id; -- Link labels to submission - if p_label_ids is not null then - insert into cfs_submission_label (cfs_submission_id, event_cfs_label_id) - select v_submission_id, input_label_id - from unnest(p_label_ids) as input_label_id - group by input_label_id; - end if; + perform sync_cfs_submission_labels(v_submission_id, p_event_id, p_label_ids); return v_submission_id; end; diff --git a/database/migrations/functions/event/attend_event.sql b/database/migrations/functions/event/attend_event.sql index 5fd03caf3..0f91ee14c 100644 --- a/database/migrations/functions/event/attend_event.sql +++ b/database/migrations/functions/event/attend_event.sql @@ -51,22 +51,31 @@ begin -- until promotion, while attendee and invitation paths still enforce answers. v_has_registration_questions := jsonb_array_length(coalesce(v_registration_questions, '[]'::jsonb)) > 0; - -- Lock organizer-created invitation rows before converting them into attendance. + -- Lock any existing attendee lifecycle row before deciding the RSVP path select ea.status into v_attendee_status from event_attendee ea where ea.event_id = p_event_id and ea.user_id = p_user_id - and ea.status in ('invitation-pending', 'invitation-rejected', 'registration-questions-pending') for update of ea; - if found then - -- Invitation acceptance confirms attendance, so validate answers here. + -- Reject duplicate confirmed attendance before other enrollment paths + if v_attendee_status = 'confirmed' then + raise exception 'user is already attending this event'; + end if; + + -- Convert pending attendee lifecycle states into confirmed attendance + if v_attendee_status in ( + 'invitation-pending', + 'invitation-rejected', + 'registration-questions-pending' + ) then + -- These lifecycle rows confirm attendance, so validate answers here if v_has_registration_questions then perform validate_questionnaire_answers_payload(v_registration_questions, p_registration_answers); v_registration_answers := p_registration_answers; end if; - -- Preserve the locked invitation row while updating only the status we read. + -- Preserve the locked attendee row while updating only the status we read update event_attendee set registration_answers = v_registration_answers, @@ -89,25 +98,14 @@ begin return 'attendee'; end if; - -- Ensure the user is not already attending - if exists ( - select 1 - from event_attendee ea - where ea.event_id = p_event_id - and ea.user_id = p_user_id - and ea.status = 'confirmed' - ) then - raise exception 'user is already attending this event'; - end if; - - -- Load any existing invitation request for approval-required decisions - select eir.status into v_invitation_request_status - from event_invitation_request eir - where eir.event_id = p_event_id - and eir.user_id = p_user_id; - -- Route approval-required events through the invitation request flow if v_attendee_approval_required then + -- Load any existing invitation request for approval-required decisions + select eir.status into v_invitation_request_status + from event_invitation_request eir + where eir.event_id = p_event_id + and eir.user_id = p_user_id; + -- Approval requests and accepted-request rejoins are attendee paths, -- so required registration answers must be present before proceeding. if v_has_registration_questions then @@ -131,6 +129,7 @@ begin values (p_event_id, p_user_id, v_registration_answers) on conflict (event_id, user_id) do update set + manually_invited = false, registration_answers = v_registration_answers, status = 'confirmed' where event_attendee.status = 'invitation-canceled'; @@ -177,12 +176,14 @@ begin and user_id = p_user_id and status = 'invitation-canceled'; - begin - insert into event_waitlist (event_id, user_id) - values (p_event_id, p_user_id); - exception when unique_violation then + -- Add the user to the waitlist, rejecting duplicate joins below + insert into event_waitlist (event_id, user_id) + values (p_event_id, p_user_id) + on conflict (event_id, user_id) do nothing; + + if not found then raise exception 'user is already on the waiting list for this event'; - end; + end if; return 'waitlisted'; end if; @@ -202,6 +203,7 @@ begin values (p_event_id, p_user_id, v_registration_answers) on conflict (event_id, user_id) do update set + manually_invited = false, registration_answers = v_registration_answers, status = 'confirmed' where event_attendee.status in ('invitation-canceled', 'registration-questions-pending'); diff --git a/database/migrations/functions/meetings/add_meeting.sql b/database/migrations/functions/meetings/add_meeting.sql index b1e3964d2..04f33eb6f 100644 --- a/database/migrations/functions/meetings/add_meeting.sql +++ b/database/migrations/functions/meetings/add_meeting.sql @@ -1,5 +1,4 @@ -- add_meeting adds a new meeting and completes the event/session claim. -drop function if exists add_meeting(text, text, text, text, text, uuid, uuid); create or replace function add_meeting( p_meeting_provider_id text, p_provider_meeting_id text, diff --git a/database/migrations/functions/meetings/assign_zoom_host_user.sql b/database/migrations/functions/meetings/assign_zoom_host_user.sql index 3dcccede2..6712faaad 100644 --- a/database/migrations/functions/meetings/assign_zoom_host_user.sql +++ b/database/migrations/functions/meetings/assign_zoom_host_user.sql @@ -1,5 +1,4 @@ -- assign_zoom_host_user reserves one available host user for a Zoom meeting. -drop function if exists assign_zoom_host_user(uuid, uuid, text[], integer, timestamptz, timestamptz); create or replace function assign_zoom_host_user( p_event_id uuid, p_session_id uuid, diff --git a/database/migrations/functions/meetings/claim_meeting_out_of_sync.sql b/database/migrations/functions/meetings/claim_meeting_out_of_sync.sql index 3ca3cbd4a..f8b1bea15 100644 --- a/database/migrations/functions/meetings/claim_meeting_out_of_sync.sql +++ b/database/migrations/functions/meetings/claim_meeting_out_of_sync.sql @@ -1,11 +1,8 @@ -- claim_meeting_out_of_sync claims one meeting that needs synchronization. -drop function if exists claim_meeting_out_of_sync(); create or replace function claim_meeting_out_of_sync() returns jsonb as $$ declare - v_claimed_event_id uuid; - v_claimed_meeting_id uuid; - v_claimed_session_id uuid; + v_claimed_meeting jsonb; begin -- Case 1: Event needing create/update with next_event as ( @@ -28,44 +25,41 @@ begin meeting_sync_claimed_at = current_timestamp from next_event ne where e.event_id = ne.event_id - returning e.event_id + returning e.* ) - select ce.event_id into v_claimed_event_id from claimed_event ce; + select jsonb_strip_nulls(jsonb_build_object( + 'delete', false, + 'duration_secs', extract(epoch from ce.ends_at - ce.starts_at)::double precision, + 'event_id', ce.event_id, + 'hosts', ( + select array_agg(distinct email order by email) filter (where email is not null) + from ( + select unnest(ce.meeting_hosts) as email + union + select u.email from event_host eh join "user" u using (user_id) where eh.event_id = ce.event_id + union + select u.email from event_speaker es join "user" u using (user_id) where es.event_id = ce.event_id + ) combined + ), + 'join_url', m.join_url, + 'meeting_id', m.meeting_id, + 'meeting_provider_id', ce.meeting_provider_id, + 'meeting_recording_requested', ce.meeting_recording_requested, + 'password', m.password, + 'provider_host_user_id', ce.meeting_provider_host_user, + 'provider_meeting_id', m.provider_meeting_id, + 'starts_at', ce.starts_at, + 'sync_claimed_at', ce.meeting_sync_claimed_at, + 'sync_state_hash', get_event_meeting_sync_state_hash(ce.event_id), + 'timezone', ce.timezone, + 'topic', ce.name + )) + into v_claimed_meeting + from claimed_event ce + left join meeting m on m.event_id = ce.event_id; - if v_claimed_event_id is not null then - return ( - select jsonb_strip_nulls(jsonb_build_object( - 'delete', false, - 'duration_secs', extract(epoch from e.ends_at - e.starts_at)::double precision, - 'event_id', e.event_id, - 'hosts', ( - select array_agg(distinct email order by email) filter (where email is not null) - from ( - select unnest(e.meeting_hosts) as email - union - select u.email from event_host eh join "user" u using (user_id) where eh.event_id = e.event_id - union - select u.email from event_speaker es join "user" u using (user_id) where es.event_id = e.event_id - ) combined - ), - 'join_url', m.join_url, - 'meeting_id', m.meeting_id, - 'meeting_provider_id', e.meeting_provider_id, - 'meeting_recording_requested', e.meeting_recording_requested, - 'password', m.password, - 'provider_host_user_id', e.meeting_provider_host_user, - 'provider_meeting_id', m.provider_meeting_id, - 'session_id', null::uuid, - 'starts_at', e.starts_at, - 'sync_claimed_at', e.meeting_sync_claimed_at, - 'sync_state_hash', get_event_meeting_sync_state_hash(e.event_id), - 'timezone', e.timezone, - 'topic', e.name - )) - from event e - left join meeting m on m.event_id = e.event_id - where e.event_id = v_claimed_event_id - ); + if v_claimed_meeting is not null then + return v_claimed_meeting; end if; -- Case 2: Session needing create/update @@ -90,52 +84,48 @@ begin meeting_sync_claimed_at = current_timestamp from next_session ns where s.session_id = ns.session_id - returning s.session_id + returning s.* ) - select cs.session_id into v_claimed_session_id from claimed_session cs; + select jsonb_strip_nulls(jsonb_build_object( + 'delete', false, + 'duration_secs', extract(epoch from cs.ends_at - cs.starts_at)::double precision, + 'hosts', ( + select array_agg(distinct email order by email) filter (where email is not null) + from ( + select unnest(cs.meeting_hosts) as email + union + select u.email from event_host eh join "user" u using (user_id) where eh.event_id = cs.event_id + union + select u.email from session_speaker ss join "user" u using (user_id) where ss.session_id = cs.session_id + ) combined + ), + 'join_url', m.join_url, + 'meeting_id', m.meeting_id, + 'meeting_provider_id', cs.meeting_provider_id, + 'meeting_recording_requested', e.meeting_recording_requested, + 'password', m.password, + 'provider_host_user_id', cs.meeting_provider_host_user, + 'provider_meeting_id', m.provider_meeting_id, + 'session_id', cs.session_id, + 'starts_at', cs.starts_at, + 'sync_claimed_at', cs.meeting_sync_claimed_at, + 'sync_state_hash', get_session_meeting_sync_state_hash(cs.session_id), + 'timezone', e.timezone, + 'topic', cs.name + )) + into v_claimed_meeting + from claimed_session cs + join event e on e.event_id = cs.event_id + left join meeting m on m.session_id = cs.session_id; - if v_claimed_session_id is not null then - return ( - select jsonb_strip_nulls(jsonb_build_object( - 'delete', false, - 'duration_secs', extract(epoch from s.ends_at - s.starts_at)::double precision, - 'event_id', null::uuid, - 'hosts', ( - select array_agg(distinct email order by email) filter (where email is not null) - from ( - select unnest(s.meeting_hosts) as email - union - select u.email from event_host eh join "user" u using (user_id) where eh.event_id = s.event_id - union - select u.email from session_speaker ss join "user" u using (user_id) where ss.session_id = s.session_id - ) combined - ), - 'join_url', m.join_url, - 'meeting_id', m.meeting_id, - 'meeting_provider_id', s.meeting_provider_id, - 'meeting_recording_requested', e.meeting_recording_requested, - 'password', m.password, - 'provider_host_user_id', s.meeting_provider_host_user, - 'provider_meeting_id', m.provider_meeting_id, - 'session_id', s.session_id, - 'starts_at', s.starts_at, - 'sync_claimed_at', s.meeting_sync_claimed_at, - 'sync_state_hash', get_session_meeting_sync_state_hash(s.session_id), - 'timezone', e.timezone, - 'topic', s.name - )) - from session s - join event e on e.event_id = s.event_id - left join meeting m on m.session_id = s.session_id - where s.session_id = v_claimed_session_id - ); + if v_claimed_meeting is not null then + return v_claimed_meeting; end if; -- Case 3: Event needing delete with next_event as ( select e.event_id from event e - left join meeting m on m.event_id = e.event_id where e.meeting_in_sync = false and e.meeting_sync_claimed_at is null and ( @@ -152,34 +142,26 @@ begin meeting_sync_claimed_at = current_timestamp from next_event ne where e.event_id = ne.event_id - returning e.event_id + returning e.* ) - select ce.event_id into v_claimed_event_id from claimed_event ce; + select jsonb_strip_nulls(jsonb_build_object( + 'delete', true, + 'event_id', ce.event_id, + 'join_url', m.join_url, + 'meeting_id', m.meeting_id, + 'meeting_provider_id', m.meeting_provider_id, + 'password', m.password, + 'provider_host_user_id', ce.meeting_provider_host_user, + 'provider_meeting_id', m.provider_meeting_id, + 'sync_claimed_at', ce.meeting_sync_claimed_at, + 'sync_state_hash', get_event_meeting_sync_state_hash(ce.event_id) + )) + into v_claimed_meeting + from claimed_event ce + left join meeting m on m.event_id = ce.event_id; - if v_claimed_event_id is not null then - return ( - select jsonb_strip_nulls(jsonb_build_object( - 'delete', true, - 'duration_secs', null::double precision, - 'event_id', e.event_id, - 'hosts', null::text[], - 'join_url', m.join_url, - 'meeting_id', m.meeting_id, - 'meeting_provider_id', m.meeting_provider_id, - 'password', m.password, - 'provider_host_user_id', e.meeting_provider_host_user, - 'provider_meeting_id', m.provider_meeting_id, - 'session_id', null::uuid, - 'starts_at', null::timestamptz, - 'sync_claimed_at', e.meeting_sync_claimed_at, - 'sync_state_hash', get_event_meeting_sync_state_hash(e.event_id), - 'timezone', null::text, - 'topic', null::text - )) - from event e - left join meeting m on m.event_id = e.event_id - where e.event_id = v_claimed_event_id - ); + if v_claimed_meeting is not null then + return v_claimed_meeting; end if; -- Case 4: Session needing delete @@ -187,7 +169,6 @@ begin select s.session_id from session s join event e on e.event_id = s.event_id - left join meeting m on m.session_id = s.session_id where s.meeting_in_sync = false and s.meeting_sync_claimed_at is null and ( @@ -204,34 +185,26 @@ begin meeting_sync_claimed_at = current_timestamp from next_session ns where s.session_id = ns.session_id - returning s.session_id + returning s.* ) - select cs.session_id into v_claimed_session_id from claimed_session cs; + select jsonb_strip_nulls(jsonb_build_object( + 'delete', true, + 'join_url', m.join_url, + 'meeting_id', m.meeting_id, + 'meeting_provider_id', m.meeting_provider_id, + 'password', m.password, + 'provider_host_user_id', cs.meeting_provider_host_user, + 'provider_meeting_id', m.provider_meeting_id, + 'session_id', cs.session_id, + 'sync_claimed_at', cs.meeting_sync_claimed_at, + 'sync_state_hash', get_session_meeting_sync_state_hash(cs.session_id) + )) + into v_claimed_meeting + from claimed_session cs + left join meeting m on m.session_id = cs.session_id; - if v_claimed_session_id is not null then - return ( - select jsonb_strip_nulls(jsonb_build_object( - 'delete', true, - 'duration_secs', null::double precision, - 'event_id', null::uuid, - 'hosts', null::text[], - 'join_url', m.join_url, - 'meeting_id', m.meeting_id, - 'meeting_provider_id', m.meeting_provider_id, - 'password', m.password, - 'provider_host_user_id', s.meeting_provider_host_user, - 'provider_meeting_id', m.provider_meeting_id, - 'session_id', s.session_id, - 'starts_at', null::timestamptz, - 'sync_claimed_at', s.meeting_sync_claimed_at, - 'sync_state_hash', get_session_meeting_sync_state_hash(s.session_id), - 'timezone', null::text, - 'topic', null::text - )) - from session s - left join meeting m on m.session_id = s.session_id - where s.session_id = v_claimed_session_id - ); + if v_claimed_meeting is not null then + return v_claimed_meeting; end if; -- Case 5: Orphan meetings @@ -251,32 +224,23 @@ begin updated_at = current_timestamp from next_meeting nm where m.meeting_id = nm.meeting_id - returning m.meeting_id + returning m.* ) - select cm.meeting_id into v_claimed_meeting_id from claimed_meeting cm; + select jsonb_strip_nulls(jsonb_build_object( + 'delete', true, + 'join_url', cm.join_url, + 'meeting_id', cm.meeting_id, + 'meeting_provider_id', cm.meeting_provider_id, + 'password', cm.password, + 'provider_host_user_id', cm.provider_host_user_id, + 'provider_meeting_id', cm.provider_meeting_id, + 'sync_claimed_at', cm.sync_claimed_at + )) + into v_claimed_meeting + from claimed_meeting cm; - if v_claimed_meeting_id is not null then - return ( - select jsonb_strip_nulls(jsonb_build_object( - 'delete', true, - 'duration_secs', null::double precision, - 'event_id', null::uuid, - 'hosts', null::text[], - 'join_url', m.join_url, - 'meeting_id', m.meeting_id, - 'meeting_provider_id', m.meeting_provider_id, - 'password', m.password, - 'provider_host_user_id', m.provider_host_user_id, - 'provider_meeting_id', m.provider_meeting_id, - 'session_id', null::uuid, - 'starts_at', null::timestamptz, - 'sync_claimed_at', m.sync_claimed_at, - 'timezone', null::text, - 'topic', null::text - )) - from meeting m - where m.meeting_id = v_claimed_meeting_id - ); + if v_claimed_meeting is not null then + return v_claimed_meeting; end if; return null; diff --git a/database/migrations/functions/meetings/delete_meeting.sql b/database/migrations/functions/meetings/delete_meeting.sql index c65bba9ad..701a5e0a6 100644 --- a/database/migrations/functions/meetings/delete_meeting.sql +++ b/database/migrations/functions/meetings/delete_meeting.sql @@ -1,5 +1,4 @@ -- delete_meeting deletes a meeting and completes the event/session claim. -drop function if exists delete_meeting(uuid, uuid, uuid); create or replace function delete_meeting( p_meeting_id uuid, p_event_id uuid, diff --git a/database/migrations/functions/meetings/release_meeting_sync_claim.sql b/database/migrations/functions/meetings/release_meeting_sync_claim.sql index 36122fcd8..096a756ad 100644 --- a/database/migrations/functions/meetings/release_meeting_sync_claim.sql +++ b/database/migrations/functions/meetings/release_meeting_sync_claim.sql @@ -1,5 +1,4 @@ -- release_meeting_sync_claim releases a retryable meeting sync claim. -drop function if exists release_meeting_sync_claim(uuid, uuid, uuid); create or replace function release_meeting_sync_claim( p_event_id uuid, p_meeting_id uuid, diff --git a/database/migrations/functions/meetings/set_meeting_error.sql b/database/migrations/functions/meetings/set_meeting_error.sql index e07d4c9c3..52cace4e9 100644 --- a/database/migrations/functions/meetings/set_meeting_error.sql +++ b/database/migrations/functions/meetings/set_meeting_error.sql @@ -1,5 +1,4 @@ -- set_meeting_error records a sync error and completes the target claim. -drop function if exists set_meeting_error(text, uuid, uuid, uuid); create or replace function set_meeting_error( p_error text, p_event_id uuid, diff --git a/database/migrations/functions/meetings/update_meeting.sql b/database/migrations/functions/meetings/update_meeting.sql index 87bba8cc2..b90b5cf25 100644 --- a/database/migrations/functions/meetings/update_meeting.sql +++ b/database/migrations/functions/meetings/update_meeting.sql @@ -1,5 +1,4 @@ -- update_meeting updates a meeting and completes the event/session claim. -drop function if exists update_meeting(uuid, text, text, text, uuid, uuid); create or replace function update_meeting( p_meeting_id uuid, p_provider_meeting_id text, diff --git a/database/migrations/functions/payments/reconcile_event_purchase_for_checkout_session.sql b/database/migrations/functions/payments/reconcile_event_purchase_for_checkout_session.sql index c3f9cbf3e..f6cad96af 100644 --- a/database/migrations/functions/payments/reconcile_event_purchase_for_checkout_session.sql +++ b/database/migrations/functions/payments/reconcile_event_purchase_for_checkout_session.sql @@ -1,6 +1,6 @@ -- Used by the checkout-completed webhook handler: reconciles the provider -- checkout session with the local purchase by completing it, returning noop, --- or marking it for automatic refund when it can no longer be fulfilled +-- or marking it for automatic refund when it can no longer be fulfilled. create or replace function reconcile_event_purchase_for_checkout_session( p_provider text, p_provider_session_id text, @@ -10,52 +10,45 @@ returns jsonb as $$ declare v_amount_minor bigint; v_community_id uuid; - v_event_canceled boolean; v_event_discount_code_id uuid; - v_event_deleted boolean; - v_event_ends_at timestamptz; v_event_id uuid; - v_event_published boolean; - v_event_starts_at timestamptz; - v_group_active boolean; - v_hold_expires_at timestamptz; + v_hold_expired boolean; v_provider_payment_reference text; v_purchase_id uuid; v_status text; + v_unfulfillable boolean; v_user_id uuid; begin -- Lock the purchase before deciding how to reconcile the provider checkout select ep.amount_minor, g.community_id, - e.canceled, ep.event_discount_code_id, - e.deleted, - e.ends_at, ep.event_id, - e.published, - e.starts_at, - g.active, - ep.hold_expires_at, + ep.hold_expires_at is not null + and ep.hold_expires_at <= current_timestamp, coalesce(p_provider_payment_reference, ep.provider_payment_reference), ep.event_purchase_id, ep.status, + e.canceled + or e.deleted + or not e.published + or not g.active + or ( + coalesce(e.ends_at, e.starts_at) is not null + and coalesce(e.ends_at, e.starts_at) <= current_timestamp + ), ep.user_id into v_amount_minor, v_community_id, - v_event_canceled, v_event_discount_code_id, - v_event_deleted, - v_event_ends_at, v_event_id, - v_event_published, - v_event_starts_at, - v_group_active, - v_hold_expires_at, + v_hold_expired, v_provider_payment_reference, v_purchase_id, v_status, + v_unfulfillable, v_user_id from event_purchase ep join event e on e.event_id = ep.event_id @@ -71,97 +64,43 @@ begin -- Ignore purchases that are already reconciled if not ( - v_status = 'pending' - or v_status = 'refund-pending' + v_status in ('pending', 'refund-pending') or ( v_status = 'expired' - and v_hold_expires_at is not null - and v_hold_expires_at <= current_timestamp + and v_hold_expired ) ) then return jsonb_build_object('outcome', 'noop'); end if; - -- Refund purchases whose hold has already expired locally - if v_status in ('pending', 'expired') - and v_hold_expires_at is not null - and v_hold_expires_at <= current_timestamp then + -- Refund purchases that cannot be completed or are awaiting refund retry + if v_status = 'refund-pending' + or v_hold_expired + or v_unfulfillable then -- Require a provider payment reference before requesting a refund if v_provider_payment_reference is null then raise exception 'provider payment reference is required for refund'; end if; -- Persist the refund-pending state before the provider refund step - update event_purchase - set - hold_expires_at = null, - provider_payment_reference = v_provider_payment_reference, - status = 'refund-pending', - updated_at = current_timestamp - where event_purchase_id = v_purchase_id; - - -- Release the discount reservation only when expiring a pending hold - if v_status = 'pending' and v_event_discount_code_id is not null then - perform release_event_discount_code_availability(v_event_discount_code_id); + if v_status <> 'refund-pending' then + update event_purchase + set + hold_expires_at = null, + provider_payment_reference = v_provider_payment_reference, + status = 'refund-pending', + updated_at = current_timestamp + where event_purchase_id = v_purchase_id; + + -- Release the discount reservation only when expiring a pending hold + if v_status = 'pending' and v_event_discount_code_id is not null then + perform release_event_discount_code_availability(v_event_discount_code_id); + end if; + + -- Release the pending attendee row created for checkout answers + perform release_event_checkout_attendee_hold(v_event_id, v_user_id); end if; - -- Release the pending attendee row created for checkout answers - perform release_event_checkout_attendee_hold(v_event_id, v_user_id); - - return jsonb_build_object( - 'amount_minor', v_amount_minor, - 'event_purchase_id', v_purchase_id, - 'outcome', 'refund_required', - 'provider_payment_reference', v_provider_payment_reference - ); - end if; - - -- Retry automatic refunds that were already handed off previously - if v_status = 'refund-pending' then - -- Require a provider payment reference before requesting a refund - if v_provider_payment_reference is null then - raise exception 'provider payment reference is required for refund'; - end if; - - return jsonb_build_object( - 'amount_minor', v_amount_minor, - 'event_purchase_id', v_purchase_id, - 'outcome', 'refund_required', - 'provider_payment_reference', v_provider_payment_reference - ); - end if; - - -- Refund purchases that can no longer be fulfilled locally - if v_event_canceled - or v_event_deleted - or not v_event_published - or not v_group_active - or ( - coalesce(v_event_ends_at, v_event_starts_at) is not null - and coalesce(v_event_ends_at, v_event_starts_at) <= current_timestamp - ) then - -- Require a provider payment reference before requesting a refund - if v_provider_payment_reference is null then - raise exception 'provider payment reference is required for refund'; - end if; - - -- Persist the refund-pending state before the provider refund step - update event_purchase - set - hold_expires_at = null, - provider_payment_reference = v_provider_payment_reference, - status = 'refund-pending', - updated_at = current_timestamp - where event_purchase_id = v_purchase_id; - - -- Release the discount reservation only when expiring a pending hold - if v_status = 'pending' and v_event_discount_code_id is not null then - perform release_event_discount_code_availability(v_event_discount_code_id); - end if; - - -- Release the pending attendee row created for checkout answers - perform release_event_checkout_attendee_hold(v_event_id, v_user_id); - return jsonb_build_object( 'amount_minor', v_amount_minor, 'event_purchase_id', v_purchase_id, @@ -182,7 +121,7 @@ begin set completed_at = current_timestamp, hold_expires_at = null, - provider_payment_reference = coalesce(v_provider_payment_reference, provider_payment_reference), + provider_payment_reference = v_provider_payment_reference, status = 'completed', updated_at = current_timestamp where event_purchase_id = v_purchase_id; diff --git a/database/tests/functions/auth/resolve_unique_username.sql b/database/tests/functions/auth/resolve_unique_username.sql new file mode 100644 index 000000000..334918b82 --- /dev/null +++ b/database/tests/functions/auth/resolve_unique_username.sql @@ -0,0 +1,63 @@ +-- ============================================================================ +-- SETUP +-- ============================================================================ + +begin; +select plan(4); + +-- ============================================================================ +-- VARIABLES +-- ============================================================================ + +\set excludedUserID '00000000-0000-0000-0000-000000000001' +\set user2ID '00000000-0000-0000-0000-000000000002' +\set user3ID '00000000-0000-0000-0000-000000000003' + +-- ============================================================================ +-- SEED DATA +-- ============================================================================ + +-- Users +insert into "user" (auth_hash, email, email_verified, name, user_id, username) values + ('hash1', 'reserved@example.com', true, 'Reserved User', :'excludedUserID', 'reserved'), + ('hash2', 'taken@example.com', true, 'Taken User', :'user2ID', 'taken'), + ('hash3', 'taken2@example.com', true, 'Taken User 2', :'user3ID', 'taken2'); + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +-- Should return the base username when it is available +select is( + resolve_unique_username('available'), + 'available', + 'Should return the base username when it is available' +); + +-- Should append the first available numeric suffix +select is( + resolve_unique_username('taken'), + 'taken3', + 'Should append the first available numeric suffix' +); + +-- Should ignore the excluded user row when resolving a username +select is( + resolve_unique_username('reserved', :'excludedUserID'), + 'reserved', + 'Should ignore the excluded user row when resolving a username' +); + +-- Should still resolve collisions outside the excluded row +select is( + resolve_unique_username('taken', :'excludedUserID'), + 'taken3', + 'Should still resolve collisions outside the excluded row' +); + +-- ============================================================================ +-- CLEANUP +-- ============================================================================ + +select * from finish(); +rollback; diff --git a/database/tests/functions/common/jsonb_geography_point.sql b/database/tests/functions/common/jsonb_geography_point.sql new file mode 100644 index 000000000..f9144a1ec --- /dev/null +++ b/database/tests/functions/common/jsonb_geography_point.sql @@ -0,0 +1,65 @@ +-- ============================================================================ +-- SETUP +-- ============================================================================ + +begin; +select plan(8); + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +-- Should convert latitude and longitude fields to a geography point +select is( + st_srid(jsonb_geography_point(jsonb_build_object('latitude', 37.7749, 'longitude', -122.4194))::geometry), + 4326, + 'Should use SRID 4326' +); + +select is( + st_y(jsonb_geography_point(jsonb_build_object('latitude', 37.7749, 'longitude', -122.4194))::geometry), + 37.7749::double precision, + 'Should preserve latitude' +); + +select is( + st_x(jsonb_geography_point(jsonb_build_object('latitude', 37.7749, 'longitude', -122.4194))::geometry), + -122.4194::double precision, + 'Should preserve longitude' +); + +-- Should return null when coordinates are absent +select ok( + jsonb_geography_point(null) is null, + 'Should return null for SQL null' +); + +select ok( + jsonb_geography_point('null'::jsonb) is null, + 'Should return null for JSON null' +); + +select ok( + jsonb_geography_point(jsonb_build_object('longitude', -122.4194)) is null, + 'Should return null without latitude' +); + +select ok( + jsonb_geography_point(jsonb_build_object('latitude', 37.7749)) is null, + 'Should return null without longitude' +); + +-- Should preserve current cast behavior for empty coordinate strings +select throws_ok( + $$ select jsonb_geography_point(jsonb_build_object('latitude', '', 'longitude', -122.4194)) $$, + '22P02', + 'invalid input syntax for type double precision: ""', + 'Should reject empty coordinate strings' +); + +-- ============================================================================ +-- CLEANUP +-- ============================================================================ + +select * from finish(); +rollback; diff --git a/database/tests/functions/common/jsonb_text_array.sql b/database/tests/functions/common/jsonb_text_array.sql new file mode 100644 index 000000000..0ca34b25a --- /dev/null +++ b/database/tests/functions/common/jsonb_text_array.sql @@ -0,0 +1,45 @@ +-- ============================================================================ +-- SETUP +-- ============================================================================ + +begin; +select plan(4); + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +-- Should convert JSON text arrays to SQL text arrays +select is( + jsonb_text_array('["alpha", "beta"]'::jsonb), + array['alpha', 'beta'], + 'Should convert JSON text arrays to SQL text arrays' +); + +-- Should preserve empty arrays +select is( + jsonb_text_array('[]'::jsonb), + array[]::text[], + 'Should preserve empty arrays' +); + +-- Should return null for SQL null +select is( + jsonb_text_array(null), + null::text[], + 'Should return null for SQL null' +); + +-- Should return null for JSON null +select is( + jsonb_text_array('null'::jsonb), + null::text[], + 'Should return null for JSON null' +); + +-- ============================================================================ +-- CLEANUP +-- ============================================================================ + +select * from finish(); +rollback; diff --git a/database/tests/functions/common/search_events.sql b/database/tests/functions/common/search_events.sql index 3491515b2..4b5305dbc 100644 --- a/database/tests/functions/common/search_events.sql +++ b/database/tests/functions/common/search_events.sql @@ -3,7 +3,7 @@ -- ============================================================================ begin; -select plan(18); +select plan(19); -- ============================================================================ -- VARIABLES @@ -349,6 +349,28 @@ select is( 'Should filter events by distance (event location is used when available, otherwise group location)' ); +-- Should sort events by distance +select is( + (select search_events( + jsonb_build_object( + 'latitude', 37.7749, + 'longitude', -122.4194, + 'sort_by', 'distance', + 'sort_direction', 'desc', + 'limit', 10, + 'offset', 0 + ) + )::jsonb->'events'), + jsonb_build_array( + get_event_summary(:'community2ID'::uuid, :'group3ID'::uuid, :'event6ID'::uuid)::jsonb, + get_event_summary(:'community1ID'::uuid, :'group1ID'::uuid, :'event1ID'::uuid)::jsonb, + get_event_summary(:'community1ID'::uuid, :'group1ID'::uuid, :'event2ID'::uuid)::jsonb, + get_event_summary(:'community1ID'::uuid, :'group1ID'::uuid, :'event3ID'::uuid)::jsonb, + get_event_summary(:'community1ID'::uuid, :'group2ID'::uuid, :'event5ID'::uuid)::jsonb + ), + 'Should sort events by distance' +); + -- Should paginate results correctly select is( (select search_events( diff --git a/database/tests/functions/common/search_groups.sql b/database/tests/functions/common/search_groups.sql index d1efa6e3e..3871471c9 100644 --- a/database/tests/functions/common/search_groups.sql +++ b/database/tests/functions/common/search_groups.sql @@ -3,7 +3,7 @@ -- ============================================================================ begin; -select plan(13); +select plan(14); -- ============================================================================ -- VARIABLES @@ -279,6 +279,27 @@ select is( 'Should filter groups by distance' ); +-- Should sort groups by distance +select is( + (select search_groups( + jsonb_build_object( + 'community', jsonb_build_array('test-community'), + 'latitude', 37.7749, + 'longitude', -122.4194, + 'sort_by', 'distance', + 'limit', 10, + 'offset', 0 + ) + )::jsonb->'groups'), + jsonb_build_array( + get_group_summary(:'community1ID'::uuid, :'group1ID'::uuid)::jsonb, + get_group_summary(:'community1ID'::uuid, :'group4ID'::uuid)::jsonb, + get_group_summary(:'community1ID'::uuid, :'group2ID'::uuid)::jsonb, + get_group_summary(:'community1ID'::uuid, :'group3ID'::uuid)::jsonb + ), + 'Should sort groups by distance' +); + -- Should paginate results correctly select is( (select search_groups( diff --git a/database/tests/functions/common/sync_cfs_submission_labels.sql b/database/tests/functions/common/sync_cfs_submission_labels.sql new file mode 100644 index 000000000..e18b6538d --- /dev/null +++ b/database/tests/functions/common/sync_cfs_submission_labels.sql @@ -0,0 +1,187 @@ +-- ============================================================================ +-- SETUP +-- ============================================================================ + +begin; +select plan(7); + +-- ============================================================================ +-- VARIABLES +-- ============================================================================ + +\set communityID '00000000-0000-0000-0000-000000000001' +\set eventCategoryID '00000000-0000-0000-0000-000000000011' +\set eventID '00000000-0000-0000-0000-000000000021' +\set eventOtherID '00000000-0000-0000-0000-000000000022' +\set groupCategoryID '00000000-0000-0000-0000-000000000031' +\set groupID '00000000-0000-0000-0000-000000000041' +\set label1ID '00000000-0000-0000-0000-000000000051' +\set label2ID '00000000-0000-0000-0000-000000000052' +\set labelOtherID '00000000-0000-0000-0000-000000000053' +\set proposalOtherID '00000000-0000-0000-0000-000000000062' +\set proposalID '00000000-0000-0000-0000-000000000061' +\set submissionOtherID '00000000-0000-0000-0000-000000000072' +\set submissionID '00000000-0000-0000-0000-000000000071' +\set userID '00000000-0000-0000-0000-000000000081' + +-- ============================================================================ +-- SEED DATA +-- ============================================================================ + +-- User +insert into "user" (user_id, email, username, auth_hash) +values (:'userID', 'speaker@example.com', 'speaker', 'hash'); + +-- Community +insert into community (community_id, name, display_name, description, logo_url, banner_mobile_url, banner_url) +values (:'communityID', 'community-1', 'Community 1', 'Test community', 'https://e/logo.png', 'https://e/banner-mobile.png', 'https://e/banner.png'); + +-- Group category +insert into group_category (group_category_id, community_id, name) +values (:'groupCategoryID', :'communityID', 'Technology'); + +-- Group +insert into "group" (group_id, community_id, group_category_id, name, slug) +values (:'groupID', :'communityID', :'groupCategoryID', 'Group 1', 'group-1'); + +-- Event category +insert into event_category (event_category_id, community_id, name) +values (:'eventCategoryID', :'communityID', 'Meetup'); + +-- Events +insert into event ( + event_id, + group_id, + name, + slug, + description, + timezone, + event_category_id, + event_kind_id +) values + (:'eventID', :'groupID', 'Labels Event', 'labels-event', 'Test event', 'UTC', :'eventCategoryID', 'in-person'), + (:'eventOtherID', :'groupID', 'Other Labels Event', 'other-labels-event', 'Test event', 'UTC', :'eventCategoryID', 'in-person'); + +-- Event CFS labels +insert into event_cfs_label (event_cfs_label_id, event_id, name, color) values + (:'label1ID', :'eventID', 'Track / Backend', '#DBEAFE'), + (:'label2ID', :'eventID', 'Track / Frontend', '#FEE2E2'), + (:'labelOtherID', :'eventOtherID', 'Track / Other', '#CCFBF1'); + +-- Session proposal +insert into session_proposal ( + session_proposal_id, + user_id, + title, + description, + duration, + session_proposal_level_id +) values + ( + :'proposalID', + :'userID', + 'Proposal', + 'Proposal description', + interval '45 minutes', + 'intermediate' + ), + ( + :'proposalOtherID', + :'userID', + 'Other Proposal', + 'Other proposal description', + interval '45 minutes', + 'intermediate' + ); + +-- CFS submission +insert into cfs_submission (cfs_submission_id, event_id, session_proposal_id, status_id) +values + (:'submissionID', :'eventID', :'proposalID', 'not-reviewed'), + (:'submissionOtherID', :'eventOtherID', :'proposalOtherID', 'not-reviewed'); + +-- Existing submission label +insert into cfs_submission_label (cfs_submission_id, event_cfs_label_id) +values + (:'submissionID', :'label1ID'), + (:'submissionOtherID', :'labelOtherID'); + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +-- Should replace labels and remove duplicates +select lives_ok( + format( + $$select sync_cfs_submission_labels('%s'::uuid, '%s'::uuid, array['%s'::uuid, '%s'::uuid, '%s'::uuid])$$, + :'submissionID', + :'eventID', + :'label2ID', + :'label2ID', + :'label1ID' + ), + 'Should replace labels and remove duplicates' +); + +select is( + ( + select jsonb_agg(event_cfs_label_id order by event_cfs_label_id) + from cfs_submission_label + where cfs_submission_id = :'submissionID'::uuid + ), + jsonb_build_array(:'label1ID'::uuid, :'label2ID'::uuid), + 'Should store unique labels' +); + +-- Should clear labels when payload is null +select lives_ok( + format($$select sync_cfs_submission_labels('%s'::uuid, '%s'::uuid, null)$$, :'submissionID', :'eventID'), + 'Should clear labels when payload is null' +); + +select is( + (select count(*) from cfs_submission_label where cfs_submission_id = :'submissionID'::uuid), + 0::bigint, + 'Should remove existing labels' +); + +-- Should reject mismatched submission and event IDs +select throws_ok( + format( + $$select sync_cfs_submission_labels('%s'::uuid, '%s'::uuid, array['%s'::uuid])$$, + :'submissionOtherID', + :'eventID', + :'label1ID' + ), + 'submission not found', + 'Should reject mismatched submission and event IDs' +); + +select is( + ( + select jsonb_agg(event_cfs_label_id order by event_cfs_label_id) + from cfs_submission_label + where cfs_submission_id = :'submissionOtherID'::uuid + ), + jsonb_build_array(:'labelOtherID'::uuid), + 'Should leave mismatched submission labels unchanged' +); + +-- Should reject labels from another event +select throws_ok( + format( + $$select sync_cfs_submission_labels('%s'::uuid, '%s'::uuid, array['%s'::uuid])$$, + :'submissionID', + :'eventID', + :'labelOtherID' + ), + 'invalid event CFS labels', + 'Should reject labels from another event' +); + +-- ============================================================================ +-- CLEANUP +-- ============================================================================ + +select * from finish(); +rollback; diff --git a/database/tests/functions/common/validate_cfs_submission_label_ids.sql b/database/tests/functions/common/validate_cfs_submission_label_ids.sql new file mode 100644 index 000000000..addeca1f5 --- /dev/null +++ b/database/tests/functions/common/validate_cfs_submission_label_ids.sql @@ -0,0 +1,126 @@ +-- ============================================================================ +-- SETUP +-- ============================================================================ + +begin; +select plan(5); + +-- ============================================================================ +-- VARIABLES +-- ============================================================================ + +\set communityID '00000000-0000-0000-0000-000000000001' +\set eventCategoryID '00000000-0000-0000-0000-000000000011' +\set eventID '00000000-0000-0000-0000-000000000021' +\set eventOtherID '00000000-0000-0000-0000-000000000022' +\set groupCategoryID '00000000-0000-0000-0000-000000000031' +\set groupID '00000000-0000-0000-0000-000000000041' +\set label1ID '00000000-0000-0000-0000-000000000051' +\set label2ID '00000000-0000-0000-0000-000000000052' +\set labelOtherID '00000000-0000-0000-0000-000000000053' + +-- ============================================================================ +-- SEED DATA +-- ============================================================================ + +-- Community +insert into community (community_id, name, display_name, description, logo_url, banner_mobile_url, banner_url) +values (:'communityID', 'community-1', 'Community 1', 'Test community', 'https://e/logo.png', 'https://e/banner-mobile.png', 'https://e/banner.png'); + +-- Group category +insert into group_category (group_category_id, community_id, name) +values (:'groupCategoryID', :'communityID', 'Technology'); + +-- Group +insert into "group" (group_id, community_id, group_category_id, name, slug) +values (:'groupID', :'communityID', :'groupCategoryID', 'Group 1', 'group-1'); + +-- Event category +insert into event_category (event_category_id, community_id, name) +values (:'eventCategoryID', :'communityID', 'Meetup'); + +-- Events +insert into event ( + event_id, + group_id, + name, + slug, + description, + timezone, + event_category_id, + event_kind_id +) values + (:'eventID', :'groupID', 'Labels Event', 'labels-event', 'Test event', 'UTC', :'eventCategoryID', 'in-person'), + (:'eventOtherID', :'groupID', 'Other Labels Event', 'other-labels-event', 'Test event', 'UTC', :'eventCategoryID', 'in-person'); + +-- Event CFS labels +insert into event_cfs_label (event_cfs_label_id, event_id, name, color) values + (:'label1ID', :'eventID', 'Track / Backend', '#DBEAFE'), + (:'label2ID', :'eventID', 'Track / Frontend', '#FEE2E2'), + (:'labelOtherID', :'eventOtherID', 'Track / Other', '#CCFBF1'); + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +-- Should accept null labels +select lives_ok( + format($$select validate_cfs_submission_label_ids('%s'::uuid, null)$$, :'eventID'), + 'Should accept null labels' +); + +-- Should accept empty labels +select lives_ok( + format($$select validate_cfs_submission_label_ids('%s'::uuid, array[]::uuid[])$$, :'eventID'), + 'Should accept empty labels' +); + +-- Should accept valid labels with duplicates +select lives_ok( + format( + $$select validate_cfs_submission_label_ids('%s'::uuid, array['%s'::uuid, '%s'::uuid, '%s'::uuid])$$, + :'eventID', + :'label1ID', + :'label1ID', + :'label2ID' + ), + 'Should accept valid labels with duplicates' +); + +-- Should reject more than ten labels +select throws_ok( + format( + $$select validate_cfs_submission_label_ids( + '%s'::uuid, + array[ + '%s'::uuid, '%s'::uuid, '%s'::uuid, '%s'::uuid, + '%s'::uuid, '%s'::uuid, '%s'::uuid, '%s'::uuid, + '%s'::uuid, '%s'::uuid, '%s'::uuid + ] + )$$, + :'eventID', + :'label1ID', :'label1ID', :'label1ID', :'label1ID', + :'label1ID', :'label1ID', :'label1ID', :'label1ID', + :'label1ID', :'label1ID', :'label1ID' + ), + 'too many submission labels', + 'Should reject more than ten labels' +); + +-- Should reject labels from another event +select throws_ok( + format( + $$select validate_cfs_submission_label_ids('%s'::uuid, array['%s'::uuid])$$, + :'eventID', + :'labelOtherID' + ), + 'invalid event CFS labels', + 'Should reject labels from another event' +); + +-- ============================================================================ +-- CLEANUP +-- ============================================================================ + +select * from finish(); +rollback; diff --git a/database/tests/functions/dashboard-group/sync_event_hosts_speakers_sponsors.sql b/database/tests/functions/dashboard-group/sync_event_hosts_speakers_sponsors.sql new file mode 100644 index 000000000..acafc34b4 --- /dev/null +++ b/database/tests/functions/dashboard-group/sync_event_hosts_speakers_sponsors.sql @@ -0,0 +1,231 @@ +-- ============================================================================ +-- SETUP +-- ============================================================================ + +begin; +select plan(8); + +-- ============================================================================ +-- VARIABLES +-- ============================================================================ + +\set communityID '00000000-0000-0000-0000-000000000001' +\set eventCategoryID '00000000-0000-0000-0000-000000000011' +\set eventID '00000000-0000-0000-0000-000000000021' +\set groupCategoryID '00000000-0000-0000-0000-000000000031' +\set groupID '00000000-0000-0000-0000-000000000041' +\set sponsor1ID '00000000-0000-0000-0000-000000000051' +\set sponsor2ID '00000000-0000-0000-0000-000000000052' +\set sponsor3ID '00000000-0000-0000-0000-000000000053' +\set user1ID '00000000-0000-0000-0000-000000000061' +\set user2ID '00000000-0000-0000-0000-000000000062' +\set user3ID '00000000-0000-0000-0000-000000000063' + +-- ============================================================================ +-- SEED DATA +-- ============================================================================ + +-- Community +insert into community (community_id, name, display_name, description, logo_url, banner_mobile_url, banner_url) +values (:'communityID', 'community-1', 'Community 1', 'Test community', 'https://e/logo.png', 'https://e/banner-mobile.png', 'https://e/banner.png'); + +-- Group category +insert into group_category (group_category_id, community_id, name) +values (:'groupCategoryID', :'communityID', 'Technology'); + +-- Group +insert into "group" (group_id, community_id, group_category_id, name, slug) +values (:'groupID', :'communityID', :'groupCategoryID', 'Group 1', 'group-1'); + +-- Event category +insert into event_category (event_category_id, community_id, name) +values (:'eventCategoryID', :'communityID', 'Meetup'); + +-- Users +insert into "user" (user_id, auth_hash, email, username, email_verified, name) values + (:'user1ID', gen_random_bytes(32), 'user1@example.com', 'user1', true, 'User 1'), + (:'user2ID', gen_random_bytes(32), 'user2@example.com', 'user2', true, 'User 2'), + (:'user3ID', gen_random_bytes(32), 'user3@example.com', 'user3', true, 'User 3'); + +-- Group sponsors +insert into group_sponsor (group_sponsor_id, group_id, name, logo_url, website_url) values + (:'sponsor1ID', :'groupID', 'Sponsor 1', 'https://e/sponsor-1.png', null), + (:'sponsor2ID', :'groupID', 'Sponsor 2', 'https://e/sponsor-2.png', 'https://e/sponsor-2'), + (:'sponsor3ID', :'groupID', 'Sponsor 3', 'https://e/sponsor-3.png', null); + +-- Event +insert into event ( + event_id, + group_id, + name, + slug, + description, + timezone, + event_category_id, + event_kind_id +) values ( + :'eventID', + :'groupID', + 'Associations Event', + 'associations-event', + 'Event used for association sync tests', + 'UTC', + :'eventCategoryID', + 'in-person' +); + +-- Existing event associations +insert into event_host (event_id, user_id) +values (:'eventID', :'user1ID'); + +insert into event_speaker (event_id, user_id, featured) +values (:'eventID', :'user1ID', true); + +insert into event_sponsor (event_id, group_sponsor_id, level) +values (:'eventID', :'sponsor1ID', 'Gold'); + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +-- Should replace hosts, speakers, and sponsors from the payload +select lives_ok( + format( + $$select sync_event_hosts_speakers_sponsors( + '%s'::uuid, + '{ + "hosts": ["%s", "%s"], + "speakers": [ + {"user_id": "%s", "featured": false}, + {"user_id": "%s", "featured": true} + ], + "sponsors": [ + {"group_sponsor_id": "%s", "level": "Silver"}, + {"group_sponsor_id": "%s", "level": "Bronze"} + ] + }'::jsonb + )$$, + :'eventID', + :'user2ID', + :'user3ID', + :'user2ID', + :'user3ID', + :'sponsor2ID', + :'sponsor3ID' + ), + 'Should replace hosts, speakers, and sponsors from the payload' +); + +-- Should replace event hosts +select is( + ( + select array_agg(user_id order by user_id) + from event_host + where event_id = :'eventID'::uuid + ), + array[:'user2ID'::uuid, :'user3ID'::uuid], + 'Should replace event hosts' +); + +-- Should replace event speakers +select is( + ( + select jsonb_agg( + jsonb_build_object( + 'featured', featured, + 'user_id', user_id + ) + order by user_id + ) + from event_speaker + where event_id = :'eventID'::uuid + ), + jsonb_build_array( + jsonb_build_object('featured', false, 'user_id', :'user2ID'::uuid), + jsonb_build_object('featured', true, 'user_id', :'user3ID'::uuid) + ), + 'Should replace event speakers' +); + +-- Should replace event sponsors +select is( + ( + select jsonb_agg( + jsonb_build_object( + 'group_sponsor_id', group_sponsor_id, + 'level', level + ) + order by group_sponsor_id + ) + from event_sponsor + where event_id = :'eventID'::uuid + ), + jsonb_build_array( + jsonb_build_object('group_sponsor_id', :'sponsor2ID'::uuid, 'level', 'Silver'), + jsonb_build_object('group_sponsor_id', :'sponsor3ID'::uuid, 'level', 'Bronze') + ), + 'Should replace event sponsors' +); + +-- Should clear omitted association sections +select lives_ok( + format( + $$select sync_event_hosts_speakers_sponsors( + '%s'::uuid, + '{"hosts": ["%s"]}'::jsonb + )$$, + :'eventID', + :'user1ID' + ), + 'Should clear omitted association sections' +); + +-- Should leave only supplied hosts when speakers and sponsors are omitted +select is( + ( + select jsonb_build_object( + 'hosts', (select count(*) from event_host where event_id = :'eventID'::uuid), + 'speakers', (select count(*) from event_speaker where event_id = :'eventID'::uuid), + 'sponsors', (select count(*) from event_sponsor where event_id = :'eventID'::uuid) + ) + ), + jsonb_build_object( + 'hosts', 1::bigint, + 'speakers', 0::bigint, + 'sponsors', 0::bigint + ), + 'Should leave only supplied hosts when speakers and sponsors are omitted' +); + +-- Should clear all association sections when payload is empty +select lives_ok( + format( + $$select sync_event_hosts_speakers_sponsors('%s'::uuid, '{}'::jsonb)$$, + :'eventID' + ), + 'Should clear all association sections when payload is empty' +); + +-- Should leave no associations after clearing with an empty payload +select is( + ( + select jsonb_build_object( + 'hosts', (select count(*) from event_host where event_id = :'eventID'::uuid), + 'speakers', (select count(*) from event_speaker where event_id = :'eventID'::uuid), + 'sponsors', (select count(*) from event_sponsor where event_id = :'eventID'::uuid) + ) + ), + jsonb_build_object( + 'hosts', 0::bigint, + 'speakers', 0::bigint, + 'sponsors', 0::bigint + ), + 'Should leave no associations after clearing with an empty payload' +); + +-- ============================================================================ +-- CLEANUP +-- ============================================================================ + +select * from finish(); +rollback; diff --git a/database/tests/functions/dashboard-group/validate_add_event_dates.sql b/database/tests/functions/dashboard-group/validate_add_event_dates.sql new file mode 100644 index 000000000..77efb7330 --- /dev/null +++ b/database/tests/functions/dashboard-group/validate_add_event_dates.sql @@ -0,0 +1,104 @@ +-- ============================================================================ +-- SETUP +-- ============================================================================ + +begin; +select plan(6); + +-- ============================================================================ +-- TESTS +-- ============================================================================ + +-- Should accept future event and session dates +select lives_ok( + $$select validate_add_event_dates( + jsonb_build_object( + 'starts_at', to_char(current_timestamp at time zone 'UTC' + interval '1 day', 'YYYY-MM-DD"T"HH24:MI:SS'), + 'ends_at', to_char(current_timestamp at time zone 'UTC' + interval '1 day' + interval '1 hour', 'YYYY-MM-DD"T"HH24:MI:SS'), + 'timezone', 'UTC', + 'sessions', jsonb_build_array( + jsonb_build_object( + 'starts_at', to_char(current_timestamp at time zone 'UTC' + interval '1 day' + interval '15 minutes', 'YYYY-MM-DD"T"HH24:MI:SS'), + 'ends_at', to_char(current_timestamp at time zone 'UTC' + interval '1 day' + interval '45 minutes', 'YYYY-MM-DD"T"HH24:MI:SS') + ) + ) + ) + )$$, + 'Should accept future event and session dates' +); + +-- Should reject event starts_at in the past +select throws_ok( + $$select validate_add_event_dates( + jsonb_build_object( + 'starts_at', to_char(current_timestamp at time zone 'UTC' - interval '1 hour', 'YYYY-MM-DD"T"HH24:MI:SS'), + 'timezone', 'UTC' + ) + )$$, + 'event starts_at cannot be in the past', + 'Should reject event starts_at in the past' +); + +-- Should reject event ends_at in the past +select throws_ok( + $$select validate_add_event_dates( + jsonb_build_object( + 'ends_at', to_char(current_timestamp at time zone 'UTC' - interval '1 hour', 'YYYY-MM-DD"T"HH24:MI:SS'), + 'timezone', 'UTC' + ) + )$$, + 'event ends_at cannot be in the past', + 'Should reject event ends_at in the past' +); + +-- Should reject session starts_at in the past +select throws_ok( + $$select validate_add_event_dates( + jsonb_build_object( + 'timezone', 'UTC', + 'sessions', jsonb_build_array( + jsonb_build_object( + 'starts_at', to_char(current_timestamp at time zone 'UTC' - interval '1 hour', 'YYYY-MM-DD"T"HH24:MI:SS') + ) + ) + ) + )$$, + 'session starts_at cannot be in the past', + 'Should reject session starts_at in the past' +); + +-- Should reject session ends_at in the past +select throws_ok( + $$select validate_add_event_dates( + jsonb_build_object( + 'timezone', 'UTC', + 'sessions', jsonb_build_array( + jsonb_build_object( + 'starts_at', to_char(current_timestamp at time zone 'UTC' + interval '1 hour', 'YYYY-MM-DD"T"HH24:MI:SS'), + 'ends_at', to_char(current_timestamp at time zone 'UTC' - interval '1 hour', 'YYYY-MM-DD"T"HH24:MI:SS') + ) + ) + ) + )$$, + 'session ends_at cannot be in the past', + 'Should reject session ends_at in the past' +); + +-- Should validate dates using the event timezone +select throws_ok( + $$select validate_add_event_dates( + jsonb_build_object( + 'starts_at', to_char(current_timestamp at time zone 'Asia/Kolkata' - interval '1 hour', 'YYYY-MM-DD"T"HH24:MI:SS'), + 'timezone', 'Asia/Kolkata' + ) + )$$, + 'event starts_at cannot be in the past', + 'Should validate dates using the event timezone' +); + +-- ============================================================================ +-- CLEANUP +-- ============================================================================ + +select * from finish(); +rollback; diff --git a/database/tests/functions/event/attend_event.sql b/database/tests/functions/event/attend_event.sql index 2a84dac8a..cefbeaa9e 100644 --- a/database/tests/functions/event/attend_event.sql +++ b/database/tests/functions/event/attend_event.sql @@ -213,7 +213,7 @@ values ( -- Existing organizer invitation decisions insert into event_attendee (event_id, user_id, manually_invited, status) values - (:'eventOK', :'user3ID', false, 'invitation-canceled'), + (:'eventOK', :'user3ID', true, 'invitation-canceled'), (:'eventFullWaitlist', :'user3ID', false, 'invitation-canceled'), (:'eventFullNoWaitlist', :'user5ID', true, 'invitation-pending'), (:'eventFullNoWaitlist', :'user6ID', true, 'invitation-rejected'); diff --git a/database/tests/functions/payments/reconcile_event_purchase_for_checkout_session.sql b/database/tests/functions/payments/reconcile_event_purchase_for_checkout_session.sql index d5d973657..6356a6cec 100644 --- a/database/tests/functions/payments/reconcile_event_purchase_for_checkout_session.sql +++ b/database/tests/functions/payments/reconcile_event_purchase_for_checkout_session.sql @@ -3,7 +3,7 @@ -- ============================================================================ begin; -select plan(13); +select plan(14); -- ============================================================================ -- VARIABLES @@ -28,6 +28,7 @@ select plan(13); \set purchaseStartedID '79000000-0000-0000-0000-000000000022' \set startedEventID '79000000-0000-0000-0000-000000000023' \set startedTicketTypeID '79000000-0000-0000-0000-000000000024' +\set purchaseExpiredActiveHoldID '79000000-0000-0000-0000-000000000026' \set user1ID '79000000-0000-0000-0000-000000000017' \set user2ID '79000000-0000-0000-0000-000000000018' \set user3ID '79000000-0000-0000-0000-000000000019' @@ -210,6 +211,22 @@ insert into event_purchase ( 'pending', 'General admission', :'user2ID' +), ( + :'purchaseExpiredActiveHoldID', + 2500, + 'USD', + 0, + null, + null, + :'activeEventID', + :'activeTicketTypeID', + now() + interval '15 minutes', + 'stripe', + 'cs_expired_active_hold', + 'pi_expired_active_hold', + 'expired', + 'General admission', + :'user5ID' ), ( :'purchaseCanceledID', 2500, @@ -315,6 +332,13 @@ select is( 'Should return noop when there is no matching checkout session' ); +-- Should return noop for expired purchases whose hold has not expired locally +select is( + reconcile_event_purchase_for_checkout_session('stripe', 'cs_expired_active_hold', null)::jsonb, + '{"outcome":"noop"}'::jsonb, + 'Should return noop for expired purchases whose hold has not expired locally' +); + -- Should complete a valid pending checkout session select is( reconcile_event_purchase_for_checkout_session('stripe', 'cs_complete', 'pi_complete')::jsonb, diff --git a/database/tests/schema/05_functions_and_triggers.sql b/database/tests/schema/05_functions_and_triggers.sql index 2c4377577..ff25a5543 100644 --- a/database/tests/schema/05_functions_and_triggers.sql +++ b/database/tests/schema/05_functions_and_triggers.sql @@ -3,7 +3,7 @@ -- ============================================================================ begin; -select plan(250); +select plan(257); -- ============================================================================ -- TESTS @@ -109,6 +109,8 @@ select has_function('is_event_meeting_in_sync'); select has_function('is_group_member'); select has_function('is_session_meeting_in_sync'); select has_function('join_group'); +select has_function('jsonb_geography_point'); +select has_function('jsonb_text_array'); select has_function('leave_event'); select has_function('leave_group'); select has_function('list_communities'); @@ -182,6 +184,7 @@ select has_function('release_event_discount_code_availability'); select has_function('release_meeting_auto_end_check_claim'); select has_function('release_meeting_sync_claim'); select has_function('request_event_refund'); +select has_function('resolve_unique_username'); select has_function('resubmit_cfs_submission'); select has_function('revert_event_refund_approval'); select has_function('search_event_attendees'); @@ -194,8 +197,10 @@ select has_function('set_meeting_auto_end_check_outcome'); select has_function('set_meeting_error'); select has_function('sign_up_user'); select has_function('submit_event_registration_answers'); +select has_function('sync_cfs_submission_labels'); select has_function('sync_event_cfs_labels'); select has_function('sync_event_discount_codes'); +select has_function('sync_event_hosts_speakers_sponsors'); select has_function('sync_event_sessions'); select has_function('sync_event_ticket_types'); select has_function('track_custom_notification'); @@ -224,6 +229,8 @@ select has_function('update_user_provider'); select has_function('upsert_pending_registration_answers'); select has_function('user_has_community_permission'); select has_function('user_has_group_permission'); +select has_function('validate_add_event_dates'); +select has_function('validate_cfs_submission_label_ids'); select has_function('validate_event_capacity'); select has_function('validate_event_cfs_labels_payload'); select has_function('validate_event_discount_codes_payload');