diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d23af0c6..36251aa1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -910,6 +910,15 @@ jobs: BUNDLEWATCH_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # ── #740: validate every migration has a rollback script ──────────────────── + validate-migration-registry: + name: Validate Migration Registry + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Check migrations dir, registry and rollbacks agree + run: scripts/check_migrations_registry.sh + validate-migration-rollbacks: name: Validate Migration Rollback Scripts runs-on: ubuntu-latest @@ -1510,6 +1519,7 @@ jobs: - bundle-size - documentation-sync - openapi-drift-check + - validate-migration-registry - validate-migration-rollbacks - rollback-idempotency - api-criterion-benchmarks diff --git a/frontend/src/components/markets/PlaceBetForm.tsx b/frontend/src/components/markets/PlaceBetForm.tsx index e306ddf2..feef858b 100644 --- a/frontend/src/components/markets/PlaceBetForm.tsx +++ b/frontend/src/components/markets/PlaceBetForm.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { api, ApiError } from '../../lib/api/public-client'; import { useWallet, WALLET_NOT_INSTALLED } from '../../lib/wallet/WalletProvider'; +import { placeBetOnChain } from '../../lib/wallet/soroban'; import { fetchAssetBalance, fetchNativeBalance } from '../../lib/wallet/balance'; import { SUPPORTED_ASSETS, getSupportedAsset } from '../../lib/assets'; import { INSUFFICIENT_BALANCE_MESSAGE, isInsufficientBalanceError } from '../../lib/wallet/errors'; @@ -115,14 +116,20 @@ export const PlaceBetForm: React.FC = ({ market, onSubmitted setInsufficientBalance(null); try { - const result = await api.placeBet(market.id, { - wallet: wallet.address, - outcome: selectedOutcome as number, - amount, - }); - setTxHash(result.tx_hash); - setTxStatus(result.status); - onSubmitted?.({ txHash: result.tx_hash, outcome: selectedOutcome as number, amount }); + // The wallet signs the contract invocation; the API has no bet route. + const hash = await placeBetOnChain( + { + bettor: wallet.address, + marketId: market.id, + outcome: selectedOutcome as number, + amount, + tokenAddress: market.token_address, + }, + wallet.signAndSubmit + ); + setTxHash(hash); + setTxStatus('pending'); + onSubmitted?.({ txHash: hash, outcome: selectedOutcome as number, amount }); } catch (err) { if (err instanceof ApiError && isInsufficientBalanceError(err.details)) { // Read the balance fresh at the moment of failure — never the value @@ -136,7 +143,7 @@ export const PlaceBetForm: React.FC = ({ market, onSubmitted setInsufficientBalance({ required: amount, current: currentBalance, code: asset.code }); } else { const message = - err instanceof ApiError ? err.message : 'Failed to submit your bet. Please try again.'; + err instanceof Error ? err.message : 'Failed to submit your bet. Please try again.'; setFormError(message); } } finally { diff --git a/frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx b/frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx index ff54967e..e19d1962 100644 --- a/frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx +++ b/frontend/src/components/markets/__tests__/PlaceBetForm.test.tsx @@ -33,12 +33,15 @@ jest.mock('../../../lib/api/public-client', () => { ...actual, api: { ...actual.api, - placeBet: (...args: unknown[]) => mockPlaceBet(...args), getTransactionStatus: (...args: unknown[]) => mockGetTransactionStatus(...args), }, }; }); +jest.mock('../../../lib/wallet/soroban', () => ({ + placeBetOnChain: (...args: unknown[]) => mockPlaceBet(...args), +})); + const mockFetchNativeBalance = jest.fn(); const mockFetchAssetBalance = jest.fn(); jest.mock('../../../lib/wallet/balance', () => ({ @@ -188,7 +191,7 @@ describe('PlaceBetForm', () => { }); it('clears a previous validation error on a new submit attempt', async () => { - mockPlaceBet.mockResolvedValue({ tx_hash: 'abc123', status: 'pending' }); + mockPlaceBet.mockResolvedValue('abc123'); renderForm(); const amountInput = screen.getByRole('spinbutton'); @@ -217,9 +220,9 @@ describe('PlaceBetForm', () => { setWalletConnected(); }); - it('calls api.placeBet with correct arguments and shows the tx status', async () => { + it('signs and submits placeBetOnChain with correct arguments and shows the tx status', async () => { const TX_HASH = 'abcdef1234567890'; - mockPlaceBet.mockResolvedValue({ tx_hash: TX_HASH, status: 'pending' }); + mockPlaceBet.mockResolvedValue(TX_HASH); const onSubmitted = jest.fn(); renderForm(onSubmitted); @@ -230,8 +233,14 @@ describe('PlaceBetForm', () => { await waitFor(() => { expect(mockPlaceBet).toHaveBeenCalledWith( - MARKET.id, - { wallet: WALLET_ADDRESS, outcome: 0, amount: '25' } + { + bettor: WALLET_ADDRESS, + marketId: MARKET.id, + outcome: 0, + amount: '25', + tokenAddress: undefined, + }, + mockWallet.signAndSubmit ); }); @@ -243,7 +252,7 @@ describe('PlaceBetForm', () => { it('calls onSubmitted callback with txHash, outcome, and amount', async () => { const TX_HASH = 'deadbeef12345678'; - mockPlaceBet.mockResolvedValue({ tx_hash: TX_HASH, status: 'pending' }); + mockPlaceBet.mockResolvedValue(TX_HASH); const onSubmitted = jest.fn(); renderForm(onSubmitted); @@ -265,7 +274,7 @@ describe('PlaceBetForm', () => { }); it('works without an onSubmitted prop (optional callback)', async () => { - mockPlaceBet.mockResolvedValue({ tx_hash: 'txhash001', status: 'pending' }); + mockPlaceBet.mockResolvedValue('txhash001'); renderForm(); // no onSubmitted diff --git a/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts b/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts index d10b434f..2fe050af 100644 --- a/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts +++ b/frontend/src/lib/api/__tests__/client-schema-path-contract.test.ts @@ -144,7 +144,6 @@ describe('API client paths match schema.d.ts (contract test, #3)', () => { // new endpoint with a mistyped or un-schema'd path fails here at PR time (#1342). const KNOWN_UNLISTED: ReadonlySet = new Set([ '/health', // liveness probe, not part of the versioned API surface - '/api/v1/blockchain/markets/{market_id}/bets', // placeBet - pending openapi.yaml (#78) '/api/v1/newsletter/subscribe', '/api/v1/newsletter/confirm', '/api/v1/newsletter/unsubscribe', diff --git a/frontend/src/lib/api/__tests__/idempotency.test.ts b/frontend/src/lib/api/__tests__/idempotency.test.ts index 1f58d1e5..640ef4ad 100644 --- a/frontend/src/lib/api/__tests__/idempotency.test.ts +++ b/frontend/src/lib/api/__tests__/idempotency.test.ts @@ -48,13 +48,13 @@ describe('idempotency key on mutating requests', () => { return headers['Idempotency-Key']; } - it('attaches an Idempotency-Key header to placeBet', async () => { + it('attaches an Idempotency-Key header to newsletterSubscribe', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true, - text: async () => JSON.stringify({ tx_hash: '0xabc', status: 'pending' }), + text: async () => JSON.stringify({ success: true, message: 'ok' }), }); - await api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' }); + await api.newsletterSubscribe({ email: 'a@example.com' }); const call = (global.fetch as jest.Mock).mock.calls[0]; expect(keyOf(call)).toMatch(UUID_RE); @@ -73,10 +73,10 @@ describe('idempotency key on mutating requests', () => { // retry: succeeds .mockResolvedValueOnce({ ok: true, - text: async () => JSON.stringify({ tx_hash: '0xabc', status: 'pending' }), + text: async () => JSON.stringify({ success: true, message: 'ok' }), }); - await api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' }); + await api.newsletterSubscribe({ email: 'a@example.com' }); const calls = (global.fetch as jest.Mock).mock.calls; expect(calls).toHaveLength(2); @@ -87,31 +87,16 @@ describe('idempotency key on mutating requests', () => { it('generates a fresh key for each new user-initiated submission', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true, - text: async () => JSON.stringify({ tx_hash: '0xabc', status: 'pending' }), + text: async () => JSON.stringify({ success: true, message: 'ok' }), }); - await api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' }); - await api.placeBet(7, { wallet: 'GABC', outcome: 2, amount: '25' }); + await api.newsletterSubscribe({ email: 'a@example.com' }); + await api.newsletterSubscribe({ email: 'a@example.com' }); const calls = (global.fetch as jest.Mock).mock.calls; expect(keyOf(calls[0])).not.toBe(keyOf(calls[1])); }); - it('honours a caller-supplied key (double-click guard use case)', async () => { - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - text: async () => JSON.stringify({ tx_hash: '0xabc', status: 'pending' }), - }); - - const key = newIdempotencyKey(); - await api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' }, { idempotencyKey: key }); - await api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' }, { idempotencyKey: key }); - - const calls = (global.fetch as jest.Mock).mock.calls; - expect(keyOf(calls[0])).toBe(key); - expect(keyOf(calls[1])).toBe(key); - }); - it('does not create a duplicate when a failed-then-succeeded submit is retried (deduping backend)', async () => { // A backend that has already committed the bet on the (lost) first response // and now deduplicates on the key: a second request with the same key @@ -125,7 +110,7 @@ describe('idempotency key on mutating requests', () => { if (already) { return Promise.resolve({ ok: true, - text: async () => JSON.stringify({ tx_hash: '0xabc', status: 'pending', deduped: true }), + text: async () => JSON.stringify({ success: true, message: 'ok', deduped: true }), }); } committed.push({ key }); @@ -138,9 +123,9 @@ describe('idempotency key on mutating requests', () => { }); }); - const result = await api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' }); + const result = await api.newsletterSubscribe({ email: 'a@example.com' }); - expect(result).toMatchObject({ tx_hash: '0xabc' }); + expect(result).toMatchObject({ success: true }); expect(committed).toHaveLength(1); }, 10000); @@ -153,7 +138,7 @@ describe('idempotency key on mutating requests', () => { }); await expect( - api.placeBet(7, { wallet: 'GABC', outcome: 1, amount: '10' }), + api.newsletterSubscribe({ email: 'a@example.com' }), ).rejects.toBeInstanceOf(ApiError); }, 10000); }); diff --git a/frontend/src/lib/api/__tests__/request-validation.test.ts b/frontend/src/lib/api/__tests__/request-validation.test.ts index 699ca13e..6007566e 100644 --- a/frontend/src/lib/api/__tests__/request-validation.test.ts +++ b/frontend/src/lib/api/__tests__/request-validation.test.ts @@ -79,14 +79,6 @@ describe('boundary validation in the client', () => { expect(global.fetch).not.toHaveBeenCalled(); }); - it('rejects a malformed placeBet body locally, without calling fetch', async () => { - await expect( - api.placeBet(1, { wallet: 'GABC', outcome: -3, amount: '10' }), - ).rejects.toBeInstanceOf(ApiError); - - expect(global.fetch).not.toHaveBeenCalled(); - }); - it('sends a well-formed body through to the network', async () => { global.fetch = jest.fn().mockResolvedValue({ ok: true, diff --git a/frontend/src/lib/api/public-client.ts b/frontend/src/lib/api/public-client.ts index a43373af..708c86c3 100644 --- a/frontend/src/lib/api/public-client.ts +++ b/frontend/src/lib/api/public-client.ts @@ -21,7 +21,6 @@ import { newsletterSubscribeSchema, emailRequestSchema, gdprExportSchema, - placeBetSchema, } from './requestSchemas'; import { reportRateLimited } from './rateLimit'; import type { paths, components } from './schema'; @@ -51,13 +50,6 @@ const PATHS = { transactionStatus: "/api/v1/blockchain/tx/{tx_hash}", } satisfies Record; -/** - * `placeBet` is not yet part of services/api/openapi.yaml (see #78), so its - * path is kept out of the `satisfies keyof paths` check above rather than - * loosening that check for every other entry. - */ -const PLACE_BET_PATH = "/api/v1/blockchain/markets/{market_id}/bets"; - // Path-parameter encoding lives in ./paths. Re-exported here so existing importers of // `fillPath` from './public-client' keep working. export { fillPath, fillPathParams } from './paths'; @@ -491,32 +483,6 @@ export const api = { signal, }), - /** - * Submits a bet for the connected wallet. Returns the pending on-chain tx. - * - * An idempotency key is generated per call so an automatic network retry - * can't place the bet twice (#1340). A caller that also needs to survive a - * double-click (see #13) can pass its own `idempotencyKey` and hold it - * stable until the submission succeeds; editing the amount/outcome and - * resubmitting is a new logical submission and should use a new key. - */ - placeBet: ( - marketId: number | string, - body: { wallet: string; outcome: number; amount: string }, - options: { idempotencyKey?: string; signal?: AbortSignal } = {} - ) => - request<{ tx_hash: string; status: string }>( - "POST", - fillPath(PLACE_BET_PATH, 'market_id', marketId), - { - body, - bodySchema: placeBetSchema, - cacheTags: [CacheTag.BLOCKCHAIN, CacheTag.MARKETS], - idempotencyKey: options.idempotencyKey ?? true, - signal: options.signal, - }, - ), - // Newsletter (public subscription / self-service) newsletterSubscribe: (body: { email: string; source?: string }, signal?: AbortSignal) => request<{ success: boolean; message: string }>("POST", "/api/v1/newsletter/subscribe", { diff --git a/frontend/src/lib/wallet/__tests__/soroban.test.ts b/frontend/src/lib/wallet/__tests__/soroban.test.ts index 744a7fd2..788ad596 100644 --- a/frontend/src/lib/wallet/__tests__/soroban.test.ts +++ b/frontend/src/lib/wallet/__tests__/soroban.test.ts @@ -78,6 +78,7 @@ import { isSorobanConfigured, resolveSorobanClientConfig, submitInvocation, + toBaseUnits, type SorobanClientConfig, } from '../soroban'; @@ -328,3 +329,11 @@ describe('invokeContract', () => { expect(sign).not.toHaveBeenCalled(); }); }); + +describe('toBaseUnits', () => { + it('converts decimal amounts to 7-decimal base units', () => { + expect(toBaseUnits('10')).toBe(100000000n); + expect(toBaseUnits('1.5')).toBe(15000000n); + expect(toBaseUnits('0.0000001')).toBe(1n); + }); +}); diff --git a/frontend/src/lib/wallet/soroban.ts b/frontend/src/lib/wallet/soroban.ts index 1d54bd80..d6490ef1 100644 --- a/frontend/src/lib/wallet/soroban.ts +++ b/frontend/src/lib/wallet/soroban.ts @@ -19,6 +19,7 @@ // the older `SorobanRpc` alias was removed in v17, so importing `rpc` keeps this // module working across the versions sibling repos pin (12.x and 17.x). import { + Asset, BASE_FEE, Contract, Networks, @@ -30,13 +31,13 @@ import { } from '@stellar/stellar-sdk'; /** The on-chain write entry points this client can invoke. */ -export type ContractWriteMethod = 'file_dispute' | 'cast_vote' | 'claim_winnings'; +export type ContractWriteMethod = 'file_dispute' | 'cast_vote' | 'claim_winnings' | 'place_bet'; /** One positional argument for a contract invocation. */ export interface ContractArg { /** Soroban type to encode the value as. */ - type: 'address' | 'u64' | 'u32' | 'i128'; - value: string | number | bigint; + type: 'address' | 'u64' | 'u32' | 'i128' | 'void'; + value?: string | number | bigint; } export interface ContractCallParams { @@ -79,11 +80,13 @@ export function isSorobanConfigured(): boolean { function toScVal(arg: ContractArg): xdr.ScVal { switch (arg.type) { case 'u64': - return nativeToScVal(BigInt(arg.value), { type: 'u64' }); + return nativeToScVal(BigInt(arg.value as bigint), { type: 'u64' }); case 'u32': return nativeToScVal(Number(arg.value), { type: 'u32' }); case 'i128': - return nativeToScVal(BigInt(arg.value), { type: 'i128' }); + return nativeToScVal(BigInt(arg.value as bigint), { type: 'i128' }); + case 'void': + return xdr.ScVal.scvVoid(); case 'address': throw new Error('address arguments are encoded in encodeContractArg'); default: @@ -208,3 +211,41 @@ export async function invokeContract( export function decodeResult(scVal: xdr.ScVal): unknown { return scValToNative(scVal); } + +/** Convert a decimal amount string to 7-decimal base units (stroops). */ +export function toBaseUnits(amount: string): bigint { + const [whole, frac = ''] = amount.split('.'); + return BigInt(whole || '0') * 10_000_000n + BigInt(frac.padEnd(7, '0').slice(0, 7)); +} + +/** + * Place a bet by invoking the contract's `place_bet` directly: build -> wallet + * sign -> submit. Returns the transaction hash. `tokenAddress` may be 'native' + * (resolved to the XLM Stellar Asset Contract on the configured network). + */ +export function placeBetOnChain( + params: { bettor: string; marketId: number | string; outcome: number; amount: string; tokenAddress?: string }, + sign: SignTransaction +): Promise { + const passphrase = + process.env.NEXT_PUBLIC_STELLAR_NETWORK_PASSPHRASE?.trim() || Networks.TESTNET; + const token = + !params.tokenAddress || params.tokenAddress === 'native' + ? Asset.native().contractId(passphrase) + : params.tokenAddress; + return invokeContract( + params.bettor, + { + method: 'place_bet', + args: [ + { type: 'address', value: params.bettor }, + { type: 'u64', value: params.marketId }, + { type: 'u32', value: params.outcome }, + { type: 'i128', value: toBaseUnits(params.amount) }, + { type: 'address', value: token }, + { type: 'void' }, + ], + }, + sign + ); +} diff --git a/scripts/check_migrations_registry.sh b/scripts/check_migrations_registry.sh new file mode 100755 index 00000000..95b13519 --- /dev/null +++ b/scripts/check_migrations_registry.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Verifies migrations dir, Rust registry and rollbacks agree. +set -euo pipefail +DIR="services/api/database/migrations" +RS="services/api/src/migrations.rs" +fail=0 + +for f in "$DIR"/*.sql; do + n=$(basename "$f" .sql) + grep -q "name: \"$n\"" "$RS" || { echo "::error::$n.sql not registered in migrations.rs"; fail=1; } + [ -f "$DIR/rollbacks/${n}_down.sql" ] || { echo "::error::missing rollback for $n"; fail=1; } +done + +for r in "$DIR"/rollbacks/*_down.sql; do + n=$(basename "$r" _down.sql) + [ -f "$DIR/$n.sql" ] || { echo "::error::orphan rollback $(basename "$r")"; fail=1; } +done + +for n in $(grep -oE 'name: "[^"]+"' "$RS" | cut -d'"' -f2); do + [ -f "$DIR/$n.sql" ] || { echo "::error::registry entry $n has no file"; fail=1; } +done + +dups=$(grep -oE 'version: "[^"]+"' "$RS" | sort | uniq -d || true) +[ -z "$dups" ] || { echo "::error::duplicate registry versions: $dups"; fail=1; } + +exit $fail diff --git a/services/api/database/migrations/023_reconcile_watched_transactions.sql b/services/api/database/migrations/023_reconcile_watched_transactions.sql new file mode 100644 index 00000000..2f600e74 --- /dev/null +++ b/services/api/database/migrations/023_reconcile_watched_transactions.sql @@ -0,0 +1,27 @@ +-- Migration 023: reconcile watched_transactions (#1747) +-- +-- 017_create_watched_transactions and 019_create_watched_transactions both use +-- CREATE TABLE IF NOT EXISTS with different schemas; 017 runs first, so 019 is +-- a no-op. The 017 shape is the one db.rs queries (status, market_id, +-- expires_at), so this migration makes that the single guaranteed schema, +-- whichever definition created the table. + +ALTER TABLE watched_transactions + ADD COLUMN IF NOT EXISTS market_id BIGINT, + ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'watched_transactions_status_check' + ) THEN + ALTER TABLE watched_transactions + ADD CONSTRAINT watched_transactions_status_check + CHECK (status IN ('pending', 'confirmed', 'expired')); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_watched_transactions_pending + ON watched_transactions (expires_at) + WHERE status = 'pending'; diff --git a/services/api/database/migrations/024_drop_audit_logs.sql b/services/api/database/migrations/024_drop_audit_logs.sql new file mode 100644 index 00000000..5a2543d5 --- /dev/null +++ b/services/api/database/migrations/024_drop_audit_logs.sql @@ -0,0 +1,7 @@ +-- Migration 024: drop the unused audit_logs table (#1748) +-- +-- audit_logs (007) is soft-deletable and unused; the append-only audit_log +-- (010) is the table audit.rs writes. Keeping the older one would let a +-- future writer weaken the audit guarantee. + +DROP TABLE IF EXISTS audit_logs; diff --git a/services/api/database/migrations/rollbacks/017_create_unsubscribe_tokens_down.sql b/services/api/database/migrations/rollbacks/017_create_unsubscribe_tokens_down.sql new file mode 100644 index 00000000..b076dd66 --- /dev/null +++ b/services/api/database/migrations/rollbacks/017_create_unsubscribe_tokens_down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS unsubscribe_tokens; diff --git a/services/api/database/migrations/rollbacks/022_add_audit_filter_time_indexes_down.sql b/services/api/database/migrations/rollbacks/022_add_audit_filter_time_indexes_down.sql new file mode 100644 index 00000000..7f50d072 --- /dev/null +++ b/services/api/database/migrations/rollbacks/022_add_audit_filter_time_indexes_down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_audit_log_resource_type_time; +DROP INDEX IF EXISTS idx_audit_log_action_time; diff --git a/services/api/database/migrations/rollbacks/023_reconcile_watched_transactions_down.sql b/services/api/database/migrations/rollbacks/023_reconcile_watched_transactions_down.sql new file mode 100644 index 00000000..13e084a7 --- /dev/null +++ b/services/api/database/migrations/rollbacks/023_reconcile_watched_transactions_down.sql @@ -0,0 +1,4 @@ +-- Rollback 023: remove the objects added by the reconcile migration. +-- Columns are left in place: 017 owns them on databases that ran it first. +ALTER TABLE watched_transactions DROP CONSTRAINT IF EXISTS watched_transactions_status_check; +DROP INDEX IF EXISTS idx_watched_transactions_pending; diff --git a/services/api/database/migrations/rollbacks/024_drop_audit_logs_down.sql b/services/api/database/migrations/rollbacks/024_drop_audit_logs_down.sql new file mode 100644 index 00000000..9b222ffe --- /dev/null +++ b/services/api/database/migrations/rollbacks/024_drop_audit_logs_down.sql @@ -0,0 +1,47 @@ +-- Rollback 024: recreate audit_logs exactly as migration 007 (+ 018 constraint) defined it. +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY 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 fk_audit_newsletter + FOREIGN KEY (newsletter_subscription_id) REFERENCES newsletter_subscribers(id) + ON DELETE SET NULL, + CONSTRAINT fk_audit_contact + FOREIGN KEY (contact_submission_id) REFERENCES contact_form_submissions(id) + ON DELETE SET NULL, + CONSTRAINT fk_audit_waitlist + FOREIGN KEY (waitlist_entry_id) REFERENCES waitlist_entries(id) + ON DELETE SET NULL, + CONSTRAINT fk_audit_content + FOREIGN KEY (content_id) REFERENCES content_management(id) + ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_action +ON audit_logs (action); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity_type +ON audit_logs (entity_type); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity_id +ON audit_logs (entity_id); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at +ON audit_logs (created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_deleted_at +ON audit_logs (deleted_at); + +ALTER TABLE audit_logs + ADD CONSTRAINT chk_audit_logs_reason_length CHECK (reason IS NULL OR char_length(reason) <= 2000); diff --git a/services/api/database/schema.sql b/services/api/database/schema.sql index d3b227c0..c13f929e 100644 --- a/services/api/database/schema.sql +++ b/services/api/database/schema.sql @@ -117,33 +117,6 @@ CREATE TABLE IF NOT EXISTS public.analytics_events ( 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(), @@ -320,18 +293,6 @@ CREATE INDEX IF NOT EXISTS idx_analytics_events_created_at 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); diff --git a/services/api/database/seeds/06_audit_logs.sql b/services/api/database/seeds/06_audit_logs.sql deleted file mode 100644 index d78afdbf..00000000 --- a/services/api/database/seeds/06_audit_logs.sql +++ /dev/null @@ -1,25 +0,0 @@ -INSERT INTO audit_logs (action, entity_type, entity_id, actor_email, reason, changes, newsletter_subscription_id) -SELECT - 'newsletter_status_updated', - 'newsletter_subscriptions', - ns.id, - 'system@predictiq.local', - 'Auto-confirmation after email verification', - '{"from":"pending","to":"confirmed"}'::jsonb, - ns.id -FROM newsletter_subscriptions ns -WHERE ns.email = 'alice@example.com' -LIMIT 1; - -INSERT INTO audit_logs (action, entity_type, entity_id, actor_email, reason, changes, content_id) -SELECT - 'content_published', - 'content_management', - c.id, - 'editor@example.com', - 'Editorial approval completed', - '{"from":"draft","to":"published"}'::jsonb, - c.id -FROM content_management c -WHERE c.slug = 'weekly-market-outlook' -LIMIT 1; diff --git a/services/api/src/migrations.rs b/services/api/src/migrations.rs index 86505c98..0636ae78 100644 --- a/services/api/src/migrations.rs +++ b/services/api/src/migrations.rs @@ -230,6 +230,16 @@ const MIGRATIONS: &[Migration] = &[ name: "022_add_audit_filter_time_indexes", sql: include_str!("../database/migrations/022_add_audit_filter_time_indexes.sql"), }, + Migration { + version: "023", + name: "023_reconcile_watched_transactions", + sql: include_str!("../database/migrations/023_reconcile_watched_transactions.sql"), + }, + Migration { + version: "024", + name: "024_drop_audit_logs", + sql: include_str!("../database/migrations/024_drop_audit_logs.sql"), + }, ]; // ---------------------------------------------------------------------------