From ba5c6f4395eef582adf5ae0a483660363a1697e4 Mon Sep 17 00:00:00 2001 From: lonerthefirst3-sudo Date: Sat, 27 Jun 2026 17:24:27 +0000 Subject: [PATCH 1/4] test(pagination): add cursor edge-case unit tests and cursor validation Adds format validation for cursor values in validate_pagination so that tampered/invalid cursors (containing <, >, null bytes, etc.) return 400 Bad Request instead of propagating to the database and risking a 500. Adds PageResponse builder that trims the sentinel row and sets next_cursor only when there is a next page (last page gets no field). New tests cover: empty result set, single-item result, exactly one full page, limit-boundary sentinel, valid-looking cursor (deleted-item path), tampered cursors with invalid chars, null bytes, and empty string, and asserts the error produces HTTP 400. Co-Authored-By: Claude Sonnet 4.6 --- services/api/src/pagination.rs | 122 +++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/services/api/src/pagination.rs b/services/api/src/pagination.rs index 1e43466d..001adfc1 100644 --- a/services/api/src/pagination.rs +++ b/services/api/src/pagination.rs @@ -68,6 +68,23 @@ pub fn validate_pagination(params: PaginationParams) -> Result Result { + pub items: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, +} + +impl PageResponse { + pub fn from_fetched(mut items: Vec, limit: u32, make_cursor: impl Fn(&T) -> String) -> Self { + let has_next = items.len() > limit as usize; + if has_next { + items.truncate(limit as usize); + } + let next_cursor = if has_next { items.last().map(make_cursor) } else { None }; + Self { items, next_cursor } + } +} + pub struct ValidatedPaginationQuery(pub ValidatedPagination); #[axum::async_trait] @@ -107,6 +147,10 @@ mod tests { PaginationParams { limit, cursor: None, offset: None } } + fn params_with_cursor(limit: Option, cursor: Option) -> PaginationParams { + PaginationParams { limit, cursor, offset: None } + } + #[test] fn default_limit_applied_when_omitted() { let v = validate_pagination(params(None)).unwrap(); @@ -138,4 +182,82 @@ mod tests { assert_eq!(err.max_limit, MAX_PAGE_LIMIT); assert!(err.message.contains("1000000")); } + + // ── Cursor-based pagination edge cases ──────────────────────────────────── + + #[test] + fn empty_result_set_produces_no_next_cursor() { + let page = PageResponse::::from_fetched(vec![], DEFAULT_LIMIT, |s| s.clone()); + assert!(page.items.is_empty()); + assert!(page.next_cursor.is_none(), "last page must have no next_cursor"); + } + + #[test] + fn single_item_result_has_no_next_cursor() { + let page = PageResponse::from_fetched(vec!["item-1".to_string()], DEFAULT_LIMIT, |s| s.clone()); + assert_eq!(page.items.len(), 1); + assert!(page.next_cursor.is_none()); + } + + #[test] + fn exactly_one_full_page_has_no_next_cursor() { + // Simulate fetching limit rows (no +1 sentinel) → no next page. + let items: Vec = (1..=DEFAULT_LIMIT as u64).collect(); + let page = PageResponse::from_fetched(items, DEFAULT_LIMIT, |n| n.to_string()); + assert_eq!(page.items.len(), DEFAULT_LIMIT as usize); + assert!(page.next_cursor.is_none()); + } + + #[test] + fn results_at_limit_boundary_signals_next_page() { + // Fetch limit+1 rows (the sentinel pattern); next_cursor should be set. + let items: Vec = (1..=(DEFAULT_LIMIT as u64 + 1)).collect(); + let page = PageResponse::from_fetched(items, DEFAULT_LIMIT, |n| n.to_string()); + assert_eq!(page.items.len(), DEFAULT_LIMIT as usize, "extra sentinel row must be trimmed"); + assert!(page.next_cursor.is_some(), "sentinel row means there is a next page"); + } + + #[test] + fn valid_looking_cursor_accepted() { + // A cursor that looks like a deleted-item cursor (valid base64-safe chars) must + // pass validation; the DB will simply return an empty result set. + let cursor = "dGVzdC1jdXJzb3I="; // base64("test-cursor") + let v = validate_pagination(params_with_cursor(None, Some(cursor.to_string()))).unwrap(); + assert_eq!(v.cursor.as_deref(), Some(cursor)); + } + + #[test] + fn tampered_cursor_with_invalid_chars_returns_400() { + // Angle brackets, quotes, or script injection characters must be rejected. + let tampered = ""; + let err = validate_pagination(params_with_cursor(None, Some(tampered.to_string()))).unwrap_err(); + assert_eq!(err.error, "invalid_cursor"); + } + + #[test] + fn tampered_cursor_with_null_byte_returns_400() { + let tampered = "valid-prefix\x00injected"; + let err = validate_pagination(params_with_cursor(None, Some(tampered.to_string()))).unwrap_err(); + assert_eq!(err.error, "invalid_cursor"); + } + + #[test] + fn empty_cursor_string_returns_400() { + let err = validate_pagination(params_with_cursor(None, Some(String::new()))).unwrap_err(); + assert_eq!(err.error, "invalid_cursor"); + } + + #[test] + fn tampered_cursor_response_code_is_bad_request() { + use axum::response::IntoResponse; + use axum::http::StatusCode; + + let err = PaginationError { + error: "invalid_cursor", + message: "cursor contains invalid characters.".to_string(), + max_limit: MAX_PAGE_LIMIT, + }; + let resp = err.into_response(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } } From 1946022d9b4971a055099626b4738046d392e353 Mon Sep 17 00:00:00 2001 From: lonerthefirst3-sudo Date: Sat, 27 Jun 2026 17:25:06 +0000 Subject: [PATCH 2/4] ci(bench): raise regression threshold to 20% and store baseline on benchmarks branch The existing api-criterion-benchmarks job compared against a 10% threshold; raises it to 20% per the acceptance criteria. The save-api-bench-baseline job previously filed a PR to update baseline.json in the main tree; now it pushes directly to a dedicated `benchmarks` orphan branch (api-benchmark-baseline.json) so the file is not mixed with source history. The comparison step on PRs first tries the branch baseline, falling back to the committed seed file on first run. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yml | 60 +++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f270b8ad..aee2b8b9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -725,13 +725,24 @@ jobs: node services/api/scripts/parse-bench-output.js \ bench-output.txt bench-results.json + - name: Fetch baseline from benchmarks branch + if: github.event_name == 'pull_request' + run: | + git fetch origin benchmarks:refs/remotes/origin/benchmarks 2>/dev/null || true + if git show origin/benchmarks:api-benchmark-baseline.json > baseline-from-branch.json 2>/dev/null; then + echo "BASELINE_FILE=baseline-from-branch.json" >> "$GITHUB_ENV" + else + echo "BASELINE_FILE=services/api/benches/.benchmarks/baseline.json" >> "$GITHUB_ENV" + echo "No dedicated branch baseline found, falling back to repo baseline." + fi + - name: Compare against baseline if: github.event_name == 'pull_request' run: | node services/api/scripts/compare-api-benchmarks.js \ bench-results.json \ - services/api/benches/.benchmarks/baseline.json \ - --threshold 10 + "$BASELINE_FILE" \ + --threshold 20 - name: Upload benchmark results if: always() @@ -748,8 +759,12 @@ jobs: runs-on: ubuntu-latest needs: [api-criterion-benchmarks] if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -772,31 +787,30 @@ jobs: with: node-version: "18" - - name: Parse and save new baseline + - name: Parse benchmark results run: | node services/api/scripts/parse-bench-output.js \ bench-output.txt \ - services/api/benches/.benchmarks/baseline.json + api-benchmark-baseline.json - - name: Create Pull Request with baseline updates - uses: peter-evans/create-pull-request@v6 - with: - commit-message: "chore: update API benchmark baseline for main branch" - title: "chore: update API benchmark baseline" - body: | - ## Automated API Benchmark Baseline Update - - This PR updates the API benchmark baseline after changes to main branch. - - **Changes:** - - Updated `services/api/benches/.benchmarks/baseline.json` with latest benchmark results - - **Note:** This is an automated PR. Please review the baseline changes before merging. - branch: chore/api-baseline-${{ github.run_id }} - delete-branch: true - labels: | - ci/cd - automated + - name: Push baseline to benchmarks branch + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Switch to (or create) the dedicated benchmarks branch. + git fetch origin benchmarks 2>/dev/null || true + if git show-ref --verify --quiet refs/remotes/origin/benchmarks; then + git checkout -B benchmarks origin/benchmarks + else + git checkout --orphan benchmarks + git rm -rf . --quiet + fi + + cp api-benchmark-baseline.json . + git add api-benchmark-baseline.json + git commit --allow-empty -m "chore: update API benchmark baseline (run ${{ github.run_id }})" + git push origin benchmarks # ── #743: frontend unit-test coverage gate ─────────────────────────────────── frontend-unit-coverage: From 73c25e324ad96d55fae717b0f6b8396ab5846aa8 Mon Sep 17 00:00:00 2001 From: lonerthefirst3-sudo Date: Sat, 27 Jun 2026 17:28:33 +0000 Subject: [PATCH 3/4] ci(schema): add schema-drift detection job and committed snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a schema-drift-check CI job that: - Spins up a fresh PostgreSQL 16 service container - Applies every migration in services/api/database/migrations/ in order, failing immediately if any migration file errors - Dumps the resulting schema with pg_dump --schema-only --no-owner - Normalises and diffs the dump against the committed snapshot at services/api/database/schema.sql - Fails with a clear diff output and remediation instructions if drift is detected; uploads the live dump as a CI artifact for inspection Also adds the initial schema.sql snapshot derived from migrations 000–016. The snapshot must be updated as part of every migration PR. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yml | 80 ++++++ services/api/database/schema.sql | 433 +++++++++++++++++++++++++++++++ 2 files changed, 513 insertions(+) create mode 100644 services/api/database/schema.sql diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aee2b8b9..d02e0495 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -845,6 +845,85 @@ jobs: path: frontend/coverage/ retention-days: 14 + # ── #742: schema drift detection ───────────────────────────────────────────── + schema-drift-check: + name: Schema Drift Detection + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: predictiq_schema_check + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + PGUSER: postgres + PGPASSWORD: postgres + PGHOST: localhost + PGDATABASE: predictiq_schema_check + steps: + - uses: actions/checkout@v4 + + - name: Apply all migrations + run: | + for f in $(ls services/api/database/migrations/*.sql | sort); do + echo "▶ $f" + psql -f "$f" || { echo "❌ Migration failed: $f"; exit 1; } + done + + - name: Dump live schema + run: | + pg_dump \ + --schema-only \ + --no-owner \ + --no-privileges \ + --no-comments \ + --schema=public \ + > /tmp/live-schema.sql + + - name: Normalise both dumps for comparison + run: | + # Strip pg_dump header lines (SET …, SELECT …) that are not structural. + normalize() { + grep -vE '^(SET |SELECT |--|$)' "$1" \ + | sed 's/ *$//; s/\t/ /g' \ + | sort + } + normalize /tmp/live-schema.sql > /tmp/live-normalized.sql + normalize services/api/database/schema.sql > /tmp/snap-normalized.sql + + - name: Compare against committed snapshot + run: | + if ! diff -u /tmp/snap-normalized.sql /tmp/live-normalized.sql; then + echo "" + echo "❌ Schema drift detected!" + echo " The live schema (after running all migrations) differs from" + echo " services/api/database/schema.sql." + echo "" + echo " To update the snapshot:" + echo " pg_dump --schema-only --no-owner --no-privileges --no-comments \\" + echo " --schema=public > services/api/database/schema.sql" + echo " git add services/api/database/schema.sql" + echo " git commit -m 'chore: update schema snapshot'" + exit 1 + fi + echo "✅ Schema matches snapshot — no drift detected." + + - name: Upload live schema dump + if: always() + uses: actions/upload-artifact@v4 + with: + name: live-schema-dump + path: /tmp/live-schema.sql + retention-days: 7 + all-tests-passed: name: All Tests Passed needs: @@ -869,6 +948,7 @@ jobs: - validate-migration-rollbacks - api-criterion-benchmarks - frontend-unit-coverage + - schema-drift-check runs-on: ubuntu-latest steps: - name: Success diff --git a/services/api/database/schema.sql b/services/api/database/schema.sql new file mode 100644 index 00000000..aad132de --- /dev/null +++ b/services/api/database/schema.sql @@ -0,0 +1,433 @@ +-- Expected database schema snapshot. +-- Generated from: services/api/database/migrations/ (migrations 000–016) +-- Update workflow: run `pg_dump --schema-only` after applying all migrations +-- on a fresh database, then commit the result here. +-- +-- The schema-drift CI job (schema-drift-check in .github/workflows/test.yml) +-- applies all migrations to a fresh PostgreSQL instance, dumps the schema, +-- and diffs it against this file. Any discrepancy fails the build. + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- Extension: pgcrypto (migration 001) +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; + +-- ── Tables ──────────────────────────────────────────────────────────────────── + +-- migration 000 +CREATE TABLE IF NOT EXISTS public.schema_migrations ( + version text NOT NULL, + name text NOT NULL, + applied_at timestamptz NOT NULL DEFAULT now(), + checksum text NOT NULL, + CONSTRAINT schema_migrations_pkey PRIMARY KEY (version) +); + +-- migration 002 +CREATE TABLE IF NOT EXISTS public.newsletter_subscribers ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + email varchar(255) NOT NULL, + source varchar(100) NOT NULL DEFAULT 'direct', + confirmed boolean NOT NULL DEFAULT false, + confirmation_token varchar(255), + created_at timestamptz NOT NULL DEFAULT now(), + confirmed_at timestamptz, + unsubscribed_at timestamptz, + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT newsletter_subscribers_pkey PRIMARY KEY (id), + CONSTRAINT newsletter_subscribers_email_key UNIQUE (email) +); + +-- migration 003 +CREATE TABLE IF NOT EXISTS public.contact_form_submissions ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + name varchar(120) NOT NULL, + email varchar(255) NOT NULL, + subject varchar(200) NOT NULL, + message text NOT NULL, + status varchar(50) NOT NULL DEFAULT 'new', + submitted_at timestamptz NOT NULL DEFAULT now(), + resolved_at timestamptz, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT contact_form_submissions_pkey PRIMARY KEY (id) +); + +-- migration 004 +CREATE TABLE IF NOT EXISTS public.waitlist_entries ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + email varchar(255) NOT NULL, + status varchar(50) NOT NULL DEFAULT 'pending', + source varchar(100), + priority_score integer NOT NULL DEFAULT 0, + joined_at timestamptz NOT NULL DEFAULT now(), + converted_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT waitlist_entries_pkey PRIMARY KEY (id), + CONSTRAINT waitlist_entries_email_key UNIQUE (email) +); + +-- migration 005 +CREATE TABLE IF NOT EXISTS public.content_management ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + slug varchar(180) NOT NULL, + title varchar(220) NOT NULL, + body text NOT NULL, + excerpt text, + status varchar(50) NOT NULL DEFAULT 'draft', + author_email varchar(255) NOT NULL, + version integer NOT NULL DEFAULT 1, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + published_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT content_management_pkey PRIMARY KEY (id), + CONSTRAINT content_management_slug_key UNIQUE (slug) +); + +-- migration 006 +CREATE TABLE IF NOT EXISTS public.analytics_events ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + event_name varchar(120) NOT NULL, + event_category varchar(80), + user_id uuid, + session_id varchar(120), + page_url text, + referrer text, + properties jsonb NOT NULL DEFAULT '{}'::jsonb, + ip_address inet, + user_agent text, + occurred_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + content_id uuid, + CONSTRAINT analytics_events_pkey PRIMARY KEY (id), + CONSTRAINT fk_analytics_content FOREIGN KEY (content_id) + REFERENCES public.content_management(id) ON DELETE SET NULL +); + +-- migration 007 +CREATE TABLE IF NOT EXISTS public.audit_logs ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + action varchar(120) NOT NULL, + entity_type varchar(80) NOT NULL, + entity_id uuid, + actor_email varchar(255), + actor_ip inet, + reason text, + changes jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + newsletter_subscription_id uuid, + contact_submission_id uuid, + waitlist_entry_id uuid, + content_id uuid, + CONSTRAINT audit_logs_pkey PRIMARY KEY (id), + CONSTRAINT fk_audit_newsletter FOREIGN KEY (newsletter_subscription_id) + REFERENCES public.newsletter_subscribers(id) ON DELETE SET NULL, + CONSTRAINT fk_audit_contact FOREIGN KEY (contact_submission_id) + REFERENCES public.contact_form_submissions(id) ON DELETE SET NULL, + CONSTRAINT fk_audit_waitlist FOREIGN KEY (waitlist_entry_id) + REFERENCES public.waitlist_entries(id) ON DELETE SET NULL, + CONSTRAINT fk_audit_content FOREIGN KEY (content_id) + REFERENCES public.content_management(id) ON DELETE SET NULL +); + +-- migration 008: email tracking tables +CREATE TABLE IF NOT EXISTS public.email_jobs ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + job_type varchar(50) NOT NULL, + recipient_email varchar(255) NOT NULL, + template_name varchar(100) NOT NULL, + template_data jsonb NOT NULL DEFAULT '{}'::jsonb, + status varchar(50) NOT NULL DEFAULT 'pending', + priority integer NOT NULL DEFAULT 0, + attempts integer NOT NULL DEFAULT 0, + max_attempts integer NOT NULL DEFAULT 3, + scheduled_at timestamptz NOT NULL DEFAULT now(), + started_at timestamptz, + completed_at timestamptz, + failed_at timestamptz, + error_message text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT email_jobs_pkey PRIMARY KEY (id) +); + +CREATE TABLE IF NOT EXISTS public.email_events ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + email_job_id uuid, + message_id varchar(255), + event_type varchar(50) NOT NULL, + recipient_email varchar(255) NOT NULL, + timestamp timestamptz NOT NULL DEFAULT now(), + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT email_events_pkey PRIMARY KEY (id), + CONSTRAINT email_events_email_job_id_fkey FOREIGN KEY (email_job_id) + REFERENCES public.email_jobs(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS public.email_suppressions ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + email varchar(255) NOT NULL, + suppression_type varchar(50) NOT NULL, + reason text, + bounce_type varchar(50), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT email_suppressions_pkey PRIMARY KEY (id), + CONSTRAINT email_suppressions_email_key UNIQUE (email) +); + +CREATE TABLE IF NOT EXISTS public.email_template_variants ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + template_name varchar(100) NOT NULL, + variant_name varchar(50) NOT NULL, + subject_line text NOT NULL, + html_content text NOT NULL, + text_content text, + is_active boolean NOT NULL DEFAULT true, + weight integer NOT NULL DEFAULT 100, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT email_template_variants_pkey PRIMARY KEY (id), + CONSTRAINT email_template_variants_template_name_variant_name_key UNIQUE (template_name, variant_name) +); + +CREATE TABLE IF NOT EXISTS public.email_analytics ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + template_name varchar(100) NOT NULL, + variant_name varchar(50), + date date NOT NULL, + sent_count integer NOT NULL DEFAULT 0, + delivered_count integer NOT NULL DEFAULT 0, + opened_count integer NOT NULL DEFAULT 0, + clicked_count integer NOT NULL DEFAULT 0, + bounced_count integer NOT NULL DEFAULT 0, + complained_count integer NOT NULL DEFAULT 0, + unsubscribed_count integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT email_analytics_pkey PRIMARY KEY (id), + CONSTRAINT email_analytics_template_name_variant_name_date_key UNIQUE (template_name, variant_name, date) +); + +-- migration 010 (audit_log) +CREATE TABLE IF NOT EXISTS public.audit_log ( + id bigint NOT NULL GENERATED ALWAYS AS IDENTITY, + timestamp timestamptz NOT NULL DEFAULT now(), + actor varchar(255) NOT NULL, + actor_ip inet, + action varchar(100) NOT NULL, + resource_type varchar(50) NOT NULL, + resource_id varchar(255), + details jsonb, + status varchar(20) NOT NULL DEFAULT 'success', + error_message text, + request_id uuid, + user_agent text, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT audit_log_pkey PRIMARY KEY (id) +); + +-- migration 011 +CREATE TABLE IF NOT EXISTS public.markets ( + id bigint NOT NULL GENERATED ALWAYS AS IDENTITY, + title text NOT NULL, + status text NOT NULL DEFAULT 'active', + outcome_index integer, + total_volume double precision NOT NULL DEFAULT 0, + ends_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + resolved_at timestamptz, + CONSTRAINT markets_pkey PRIMARY KEY (id), + CONSTRAINT markets_status_check CHECK (status IN ('active', 'resolved', 'cancelled')) +); + +-- ── Indexes ─────────────────────────────────────────────────────────────────── + +-- newsletter_subscribers (migrations 002, 009, 010, 015) +CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_email + ON public.newsletter_subscribers (email); +CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_confirmation_token + ON public.newsletter_subscribers (confirmation_token) + WHERE confirmation_token IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_created_at + ON public.newsletter_subscribers (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_status + ON public.newsletter_subscribers (confirmed, unsubscribed_at) + WHERE unsubscribed_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_email_status + ON public.newsletter_subscribers (email, confirmed) + WHERE unsubscribed_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_deleted_at + ON public.newsletter_subscribers (deleted_at) + WHERE deleted_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_active + ON public.newsletter_subscribers (email) + WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_cleanup + ON public.newsletter_subscribers (created_at ASC) + WHERE confirmed = false; + +-- contact_form_submissions (migration 003) +CREATE INDEX IF NOT EXISTS idx_contact_form_submissions_email + ON public.contact_form_submissions (email); +CREATE INDEX IF NOT EXISTS idx_contact_form_submissions_status + ON public.contact_form_submissions (status); +CREATE INDEX IF NOT EXISTS idx_contact_form_submissions_created_at + ON public.contact_form_submissions (created_at DESC); + +-- waitlist_entries (migration 004) +CREATE INDEX IF NOT EXISTS idx_waitlist_entries_email + ON public.waitlist_entries (email); +CREATE INDEX IF NOT EXISTS idx_waitlist_entries_status + ON public.waitlist_entries (status); +CREATE INDEX IF NOT EXISTS idx_waitlist_entries_created_at + ON public.waitlist_entries (created_at DESC); + +-- content_management (migration 005) +CREATE INDEX IF NOT EXISTS idx_content_management_slug + ON public.content_management (slug); +CREATE INDEX IF NOT EXISTS idx_content_management_status + ON public.content_management (status); +CREATE INDEX IF NOT EXISTS idx_content_management_created_at + ON public.content_management (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_content_management_deleted_at + ON public.content_management (deleted_at); + +-- analytics_events (migration 006) +CREATE INDEX IF NOT EXISTS idx_analytics_events_event_name + ON public.analytics_events (event_name); +CREATE INDEX IF NOT EXISTS idx_analytics_events_occurred_at + ON public.analytics_events (occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_analytics_events_session_id + ON public.analytics_events (session_id); +CREATE INDEX IF NOT EXISTS idx_analytics_events_created_at + ON public.analytics_events (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_analytics_events_properties_gin + ON public.analytics_events USING gin (properties); + +-- audit_logs (migration 007) +CREATE INDEX IF NOT EXISTS idx_audit_logs_action + ON public.audit_logs (action); +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity_type + ON public.audit_logs (entity_type); +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity_id + ON public.audit_logs (entity_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at + ON public.audit_logs (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_logs_deleted_at + ON public.audit_logs (deleted_at); + +-- email_jobs (migrations 008, 013) +CREATE INDEX IF NOT EXISTS idx_email_jobs_status + ON public.email_jobs (status); +CREATE INDEX IF NOT EXISTS idx_email_jobs_scheduled_at + ON public.email_jobs (scheduled_at); +CREATE INDEX IF NOT EXISTS idx_email_jobs_recipient + ON public.email_jobs (recipient_email); +CREATE INDEX IF NOT EXISTS idx_email_jobs_type + ON public.email_jobs (job_type); +CREATE INDEX IF NOT EXISTS idx_email_jobs_status_priority_scheduled + ON public.email_jobs (status, priority DESC, scheduled_at ASC); + +-- email_events (migrations 008, 014) +CREATE INDEX IF NOT EXISTS idx_email_events_job_id + ON public.email_events (email_job_id); +CREATE INDEX IF NOT EXISTS idx_email_events_message_id + ON public.email_events (message_id); +CREATE INDEX IF NOT EXISTS idx_email_events_type + ON public.email_events (event_type); +CREATE INDEX IF NOT EXISTS idx_email_events_recipient + ON public.email_events (recipient_email); +CREATE INDEX IF NOT EXISTS idx_email_events_timestamp + ON public.email_events (timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_email_events_job_timestamp + ON public.email_events (email_job_id, timestamp DESC); + +-- email_suppressions (migration 008) +CREATE INDEX IF NOT EXISTS idx_email_suppressions_email + ON public.email_suppressions (email); +CREATE INDEX IF NOT EXISTS idx_email_suppressions_type + ON public.email_suppressions (suppression_type); + +-- email_template_variants (migration 008) +CREATE INDEX IF NOT EXISTS idx_email_template_variants_name + ON public.email_template_variants (template_name); +CREATE INDEX IF NOT EXISTS idx_email_template_variants_active + ON public.email_template_variants (is_active); + +-- email_analytics (migration 008) +CREATE INDEX IF NOT EXISTS idx_email_analytics_template + ON public.email_analytics (template_name); +CREATE INDEX IF NOT EXISTS idx_email_analytics_date + ON public.email_analytics (date DESC); + +-- audit_log (migration 010) +CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp + ON public.audit_log (timestamp DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_actor + ON public.audit_log (actor); +CREATE INDEX IF NOT EXISTS idx_audit_log_action + ON public.audit_log (action); +CREATE INDEX IF NOT EXISTS idx_audit_log_resource + ON public.audit_log (resource_type, resource_id); +CREATE INDEX IF NOT EXISTS idx_audit_log_request_id + ON public.audit_log (request_id); + +-- markets (migrations 011, 012, 016) +CREATE INDEX IF NOT EXISTS markets_status_idx + ON public.markets (status); +CREATE INDEX IF NOT EXISTS idx_markets_status_volume_ends_at + ON public.markets (status, total_volume DESC, ends_at ASC); +CREATE INDEX IF NOT EXISTS idx_markets_active_ends_at + ON public.markets (ends_at ASC) + WHERE status = 'active'; + +-- ── Functions & triggers (migration 010) ───────────────────────────────────── + +CREATE OR REPLACE FUNCTION public.prevent_audit_log_modification() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + RAISE EXCEPTION 'Audit log is append-only. Modifications are not allowed.'; +END; +$$; + +CREATE TRIGGER prevent_audit_log_update + BEFORE UPDATE ON public.audit_log + FOR EACH ROW EXECUTE FUNCTION public.prevent_audit_log_modification(); + +CREATE TRIGGER prevent_audit_log_delete + BEFORE DELETE ON public.audit_log + FOR EACH ROW EXECUTE FUNCTION public.prevent_audit_log_modification(); + +-- ── Views (migration 010) ───────────────────────────────────────────────────── + +CREATE OR REPLACE VIEW public.recent_admin_actions AS +SELECT + id, + timestamp, + actor, + action, + resource_type, + resource_id, + status, + error_message +FROM public.audit_log +WHERE timestamp > (now() - '30 days'::interval) +ORDER BY timestamp DESC; From 65636927c0ee85ebf549eee79cfccc76d77d1d0b Mon Sep 17 00:00:00 2001 From: lonerthefirst3-sudo Date: Sat, 27 Jun 2026 17:29:02 +0000 Subject: [PATCH 4/4] ci(security): add TTS container image to Trivy vulnerability scanning The container-scanning job previously built and scanned only the API Docker image, leaving the TTS service (services/tts/Dockerfile) unscanned. Adds a build + Trivy scan step for the TTS image in the same job, using the same severity threshold (CRITICAL,HIGH, exit-code 1). Each image's SARIF output is uploaded with a distinct category (trivy-api / trivy-tts) so findings appear under separate entries in the GitHub Security tab. npm audit --audit-level=high for TTS dependencies was already present in dependency-scan.yml and is unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/test.yml | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d02e0495..cb407f84 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -434,8 +434,7 @@ jobs: - uses: actions/checkout@v4 - name: Build API container image - run: | - docker build -t predictiq-api:${{ github.sha }} -f services/api/Dockerfile . + run: docker build -t predictiq-api:${{ github.sha }} -f services/api/Dockerfile . continue-on-error: true - name: Scan API container with Trivy @@ -444,15 +443,37 @@ jobs: with: image-ref: "predictiq-api:${{ github.sha }}" format: "sarif" - output: "trivy-container.sarif" + output: "trivy-api.sarif" + severity: "CRITICAL,HIGH" + exit-code: "1" + + - name: Upload API container scan SARIF + uses: github/codeql-action/upload-sarif@v4 + if: always() + with: + sarif_file: trivy-api.sarif + category: trivy-api + + - name: Build TTS container image + run: docker build -t predictiq-tts:${{ github.sha }} -f services/tts/Dockerfile services/tts/ + continue-on-error: true + + - name: Scan TTS container with Trivy + uses: aquasecurity/trivy-action@314ff8b43182423b84c50b1670b0e10f858f2d98 # master + if: success() + with: + image-ref: "predictiq-tts:${{ github.sha }}" + format: "sarif" + output: "trivy-tts.sarif" severity: "CRITICAL,HIGH" exit-code: "1" - - name: Upload container scan SARIF + - name: Upload TTS container scan SARIF uses: github/codeql-action/upload-sarif@v4 if: always() with: - sarif_file: trivy-container.sarif + sarif_file: trivy-tts.sarif + category: trivy-tts codeql-analysis: name: CodeQL Analysis