diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 6d18177..c01bf7e 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -86,7 +86,7 @@ dependencies = [ "ark-serialize", "ark-std", "derivative", - "digest 0.10.7", + "digest", "itertools", "num-bigint", "num-traits", @@ -139,7 +139,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ "ark-serialize-derive", "ark-std", - "digest 0.10.7", + "digest", "num-bigint", ] @@ -203,15 +203,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - [[package]] name = "bs58" version = "0.5.1" @@ -288,15 +279,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - [[package]] name = "crate-git-revision" version = "0.0.6" @@ -315,7 +297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core 0.6.4", + "rand_core", "subtle", "zeroize", ] @@ -330,16 +312,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", - "rand_core 0.10.1", -] - [[package]] name = "ctor" version = "0.2.9" @@ -357,27 +329,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto 0.2.9", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures", "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto 0.3.0", - "rand_core 0.10.1", + "digest", + "fiat-crypto", "rustc_version", "subtle", "zeroize", @@ -516,22 +471,12 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", + "block-buffer", "const-oid", - "crypto-common 0.1.6", + "crypto-common", "subtle", ] -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "crypto-common 0.2.2", -] - [[package]] name = "downcast-rs" version = "1.2.1" @@ -551,10 +496,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest 0.10.7", + "digest", "elliptic-curve", "rfc6979", - "signature 2.2.0", + "signature", ] [[package]] @@ -564,16 +509,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8", - "signature 2.2.0", -] - -[[package]] -name = "ed25519" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" -dependencies = [ - "signature 3.0.0", + "signature", ] [[package]] @@ -582,26 +518,11 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek 4.1.3", - "ed25519 2.2.3", - "rand_core 0.6.4", + "curve25519-dalek", + "ed25519", + "rand_core", "serde", - "sha2 0.10.9", - "subtle", - "zeroize", -] - -[[package]] -name = "ed25519-dalek" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" -dependencies = [ - "curve25519-dalek 5.0.0", - "ed25519 3.0.0", - "rand_core 0.10.1", - "sha2 0.11.0", - "signature 3.0.0", + "sha2", "subtle", "zeroize", ] @@ -620,11 +541,11 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest 0.10.7", + "digest", "ff", "generic-array", "group", - "rand_core 0.6.4", + "rand_core", "sec1", "subtle", "zeroize", @@ -654,7 +575,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core 0.6.4", + "rand_core", "subtle", ] @@ -664,12 +585,6 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -737,7 +652,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core 0.6.4", + "rand_core", "subtle", ] @@ -783,16 +698,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hybrid-array" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" -dependencies = [ - "typenum", + "digest", ] [[package]] @@ -889,7 +795,7 @@ dependencies = [ "cfg-if", "ecdsa", "elliptic-curve", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -898,7 +804,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures 0.2.17", + "cpufeatures", ] [[package]] @@ -985,7 +891,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2 0.10.9", + "sha2", ] [[package]] @@ -1070,7 +976,7 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -1080,7 +986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -1092,12 +998,6 @@ dependencies = [ "getrandom", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "ref-cast" version = "1.0.26" @@ -1268,19 +1168,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -1289,7 +1178,7 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ - "digest 0.10.7", + "digest", "keccak", ] @@ -1305,17 +1194,8 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - -[[package]] -name = "signature" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "rand_core 0.10.1", + "digest", + "rand_core", ] [[package]] @@ -1381,9 +1261,9 @@ dependencies = [ "ark-ec", "ark-ff", "ark-serialize", - "curve25519-dalek 5.0.0", + "curve25519-dalek", "ecdsa", - "ed25519-dalek 3.0.0", + "ed25519-dalek", "elliptic-curve", "generic-array", "getrandom", @@ -1397,7 +1277,7 @@ dependencies = [ "rand", "rand_chacha", "sec1", - "sha2 0.10.9", + "sha2", "sha3", "soroban-builtin-sdk-macros", "soroban-env-common", @@ -1446,7 +1326,7 @@ dependencies = [ "bytes-lit", "ctor", "derive_arbitrary", - "ed25519-dalek 2.2.0", + "ed25519-dalek", "rand", "rustc_version", "serde", @@ -1470,7 +1350,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "sha2 0.10.9", + "sha2", "soroban-env-common", "soroban-spec", "soroban-spec-rust", @@ -1499,7 +1379,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "sha2 0.10.9", + "sha2", "soroban-spec", "stellar-xdr", "syn 2.0.119", diff --git a/contracts/Makefile b/contracts/Makefile index b281988..17201a5 100644 --- a/contracts/Makefile +++ b/contracts/Makefile @@ -4,7 +4,7 @@ build: cargo build --release --workspace test: - cargo test --workspace --all-features + cargo test --workspace --all-features --target $(shell rustc -vV | sed -n 's|host: ||p') clean: cargo clean --workspace diff --git a/contracts/escrow/Cargo.toml b/contracts/escrow/Cargo.toml index 3324364..61831c2 100644 --- a/contracts/escrow/Cargo.toml +++ b/contracts/escrow/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -soroban-sdk = { workspace = true } +soroban-sdk = { workspace = true, features = ["alloc"] } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 3806d55..431d289 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -1,6 +1,8 @@ #![no_std] #![allow(clippy::too_many_arguments)] +extern crate alloc; + use soroban_sdk::{ contract, contractimpl, contracttype, symbol_short, token, Address, Env, Symbol, Vec, @@ -67,6 +69,8 @@ pub struct SubEscrow { #[derive(Clone, Debug, PartialEq)] pub enum ContractError { ContractPaused, + Unauthorized, + TimelockNotExpired, } // --------------------------------------------------------------------------- @@ -832,6 +836,50 @@ mod test { client.cancel_and_refund(&buyer, &trade_id); } + #[test] + fn test_admin_cancels_immediately() { + let (env, client, admin, seller, buyer, token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let trade_id = client.create_listing( + &seller, + &token, + &500_0000000i128, + &symbol_short!("AIRTIME"), + &(1_000_000 + 86_400), + ); + client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); + + // Admin cancels immediately before timelock expiry + client.cancel_and_refund(&admin, &trade_id); + + let trade = client.get_trade(&trade_id); + assert_eq!(trade.status, TradeStatus::Cancelled); + assert_eq!(trade.filled_amount, 0); + + let token_client = TokenClient::new(&env, &token); + assert_eq!(token_client.balance(&buyer), 10_000_0000000i128); + } + + #[test] + #[should_panic(expected = "only admin or buyer can cancel")] + fn test_seller_cancel_fails() { + let (env, client, _admin, seller, buyer, token) = setup(); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + + let trade_id = client.create_listing( + &seller, + &token, + &500_0000000i128, + &symbol_short!("AIRTIME"), + &(1_000_000 + 86_400), + ); + client.deposit_to_escrow(&buyer, &trade_id, &500_0000000i128); + + // Seller attempts to cancel and refund + client.cancel_and_refund(&seller, &trade_id); + } + // ----------------------------------------------------------------------- // Pausability tests // ----------------------------------------------------------------------- diff --git a/contracts/readme.md b/contracts/readme.md index e4b3be8..9f06b85 100644 --- a/contracts/readme.md +++ b/contracts/readme.md @@ -2,6 +2,8 @@ Soroban (Rust) smart contracts for the AirFlex P2P airtime/data marketplace on the Stellar network. +**Last Updated:** August 28, 2026 + --- ## Contracts @@ -193,19 +195,43 @@ The returned XDR decodes to a `TradeOffer` struct (seller, token, amounts, statu --- -## Escrow Contract Functions +## ⚡ Contract Functions + +### 1. `create_listing` + +**Who calls it:** The Seller + +* **Parameters:** `seller: Address`, `token: Address`, `amount: i128`, `asset_type: Symbol`, `expires_at: u64` +* **Returns:** `u64` (the new trade ID) +* **Logic:** Registers a new trade offer in persistent storage and sets its status to `Open`. Validates that `expires_at` is in the future, `amount` is positive, and the payment token is whitelisted. +* **Authorisation:** The seller address must sign the transaction — `seller.require_auth()` is enforced before any state writes. + +### 2. `deposit_to_escrow` + +**Who calls it:** The Buyer + +* **Parameters:** `buyer: Address`, `trade_id: u64`, `fill_amount: i128` +* **Returns:** `()` +* **Logic:** Transfers tokens from the buyer to the contract's escrow storage. Records a sub-escrow entry for the fill and transitions trade status to `Locked` once fully filled (or `PartiallyFilled` for partial purchases). +* **Authorisation:** Caller must be the buyer — `buyer.require_auth()` is enforced before reading trade state or transferring tokens. -### `create_listing(seller, token, amount, asset_type, expires_at) → u64` +### 3. `release_payment` -Called by the seller to register a new trade offer on-chain. Returns the assigned trade ID. +**Who calls it:** System Backend (via Oracle / Admin) -**Authorisation:** The seller address must sign the transaction — `seller.require_auth()` is enforced as the first statement. +* **Parameters:** `caller: Address`, `trade_id: u64`, `fill_id: u64` +* **Returns:** `()` +* **Logic:** Finalizes the trade once delivery of airtime or data is verified by transferring funds from the contract to the seller. Sets the sub-escrow to released and transitions the trade status to `Completed` when all fills are released. +* **Authorisation:** Caller must be the contract admin address — `caller.require_auth()` is enforced. -### `deposit_to_escrow(buyer, trade_id, fill_amount)` +### 4. `cancel_and_refund` -Locks the buyer's funds into escrow for a specific trade. Sets trade status to `Locked` when fully filled. +**Who calls it:** Buyer (after timelock) or Admin (immediate bypass) -**Authorisation:** Caller must be the buyer — `buyer.require_auth()` is enforced before reading trade state or transferring tokens. +* **Parameters:** `caller: Address`, `trade_id: u64` +* **Returns:** `()` +* **Logic:** Caller guard enforces that only the buyer or the contract admin can invoke this function. If the caller is the buyer, an additional check enforces the 24-hour timelock (the trade must have been in `Locked` status for at least 86,400 seconds; premature calls fail). If the caller is the admin, the timelock check is bypassed for immediate cancellation and dispute resolution. Escrowed tokens are transferred back to the buyer and trade status transitions to `Cancelled`. +* **Authorisation:** Caller must authenticate with `caller.require_auth()`. Any caller other than the buyer or admin is rejected with an unauthorized error. --- diff --git a/server/package.json b/server/package.json index ebdc0f8..be5769f 100644 --- a/server/package.json +++ b/server/package.json @@ -12,7 +12,9 @@ "test": "jest --runInBand --forceExit", "test:watch": "jest --watch", "generate:openapi": "ts-node scripts/generate-openapi.ts", - "check:openapi": "ts-node scripts/generate-openapi.ts && git diff --exit-code openapi.json" + "check:openapi": "ts-node scripts/generate-openapi.ts && git diff --exit-code openapi.json", + "db:migrate": "ts-node src/db/migrate.ts", + "db:migrate:rollback": "ts-node src/db/migrate.ts rollback" }, "dependencies": { "@airflex/shared": "workspace:*", diff --git a/server/src/db/migrate.test.ts b/server/src/db/migrate.test.ts new file mode 100644 index 0000000..031e467 --- /dev/null +++ b/server/src/db/migrate.test.ts @@ -0,0 +1,130 @@ +import fs from "fs"; +import path from "path"; +import os from "os"; +import { runMigrations, rollbackLastMigration, ensureMigrationTable } from "./migrate"; + +describe("Database Migrations CLI (migrate.ts)", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "airflex-migrate-test-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + it("creates schema_migrations table if not exists", async () => { + const mockQuery = jest.fn().mockResolvedValue({ rows: [] }); + const mockClient = { + query: mockQuery, + release: jest.fn(), + }; + await ensureMigrationTable(mockClient as any); + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining("CREATE TABLE IF NOT EXISTS schema_migrations") + ); + }); + + it("executes and records a new migration file when not previously applied", async () => { + const migrationFile = "001_test_migration.sql"; + const migrationSql = "CREATE TABLE test_table (id INT);"; + fs.writeFileSync(path.join(tempDir, migrationFile), migrationSql); + + const queryLog: Array<{ text: string; params?: unknown[] }> = []; + const mockClient = { + query: jest.fn().mockImplementation(async (text: string, params?: unknown[]) => { + queryLog.push({ text, params }); + if (text.includes("SELECT filename FROM schema_migrations")) { + return { rows: [] }; + } + return { rows: [] }; + }), + release: jest.fn(), + }; + + const mockPool = { + connect: jest.fn().mockResolvedValue(mockClient), + }; + + const result = await runMigrations({ + pool: mockPool as any, + migrationsDir: tempDir, + }); + + expect(result.applied).toEqual([migrationFile]); + expect(result.skipped).toEqual([]); + + // Verify transaction and insertion + expect(mockClient.query).toHaveBeenCalledWith("BEGIN"); + expect(mockClient.query).toHaveBeenCalledWith(migrationSql); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining("INSERT INTO schema_migrations"), + [migrationFile] + ); + expect(mockClient.query).toHaveBeenCalledWith("COMMIT"); + expect(mockClient.release).toHaveBeenCalled(); + }); + + it("skips previously applied migration file and reports no pending migrations", async () => { + const migrationFile = "001_test_migration.sql"; + fs.writeFileSync(path.join(tempDir, migrationFile), "SELECT 1;"); + + const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const mockClient = { + query: jest.fn().mockImplementation(async (text: string) => { + if (text.includes("SELECT filename FROM schema_migrations")) { + return { rows: [{ filename: migrationFile }] }; + } + return { rows: [] }; + }), + release: jest.fn(), + }; + + const mockPool = { + connect: jest.fn().mockResolvedValue(mockClient), + }; + + const result = await runMigrations({ + pool: mockPool as any, + migrationsDir: tempDir, + }); + + expect(result.applied).toEqual([]); + expect(result.skipped).toEqual([migrationFile]); + expect(consoleSpy).toHaveBeenCalledWith("No pending migrations"); + expect(mockClient.query).not.toHaveBeenCalledWith("BEGIN"); + }); + + it("rolls back the last migration by removing it from schema_migrations", async () => { + const consoleSpy = jest.spyOn(console, "log").mockImplementation(() => {}); + + const mockClient = { + query: jest.fn().mockImplementation(async (text: string) => { + if (text.includes("SELECT id, filename FROM schema_migrations")) { + return { rows: [{ id: 42, filename: "002_kyc.sql" }] }; + } + return { rows: [] }; + }), + release: jest.fn(), + }; + + const mockPool = { + connect: jest.fn().mockResolvedValue(mockClient), + }; + + const rolledBackFile = await rollbackLastMigration({ + pool: mockPool as any, + }); + + expect(rolledBackFile).toBe("002_kyc.sql"); + expect(mockClient.query).toHaveBeenCalledWith( + expect.stringContaining("DELETE FROM schema_migrations WHERE id = $1"), + [42] + ); + expect(consoleSpy).toHaveBeenCalledWith("Rolled back migration record: 002_kyc.sql"); + expect(consoleSpy).toHaveBeenCalledWith("Migration to reverse: 002_kyc.sql"); + }); +}); diff --git a/server/src/db/migrate.ts b/server/src/db/migrate.ts new file mode 100644 index 0000000..515edef --- /dev/null +++ b/server/src/db/migrate.ts @@ -0,0 +1,177 @@ +import fs from "fs"; +import path from "path"; +import { Pool, PoolClient } from "pg"; +import dotenv from "dotenv"; + +dotenv.config(); + +export interface MigrationResult { + applied: string[]; + skipped: string[]; +} + +export interface MigrateOptions { + pool?: Pool; + migrationsDir?: string; + databaseUrl?: string; +} + +export function getMigrationsDir(customDir?: string): string { + if (customDir) return customDir; + const primary = path.resolve(__dirname, "migrations"); + if (fs.existsSync(primary)) { + return primary; + } + const fallback = path.resolve(__dirname, "../../migrations"); + if (fs.existsSync(fallback)) { + return fallback; + } + return primary; +} + +export async function ensureMigrationTable(client: PoolClient | Pool): Promise { + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id SERIAL PRIMARY KEY, + filename TEXT UNIQUE NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); +} + +export async function getAppliedMigrations(client: PoolClient | Pool): Promise { + const res = await client.query<{ filename: string }>( + `SELECT filename FROM schema_migrations ORDER BY id ASC;` + ); + return res.rows.map((row) => row.filename); +} + +export async function runMigrations(options?: MigrateOptions): Promise { + const migrationsDir = getMigrationsDir(options?.migrationsDir); + const databaseUrl = options?.databaseUrl || process.env["DATABASE_URL"]; + + let pool = options?.pool; + let closePoolOnComplete = false; + + if (!pool) { + if (!databaseUrl) { + throw new Error("DATABASE_URL environment variable is required to run migrations"); + } + pool = new Pool({ connectionString: databaseUrl }); + closePoolOnComplete = true; + } + + const client = await pool.connect(); + try { + await ensureMigrationTable(client); + + const appliedList = await getAppliedMigrations(client); + const appliedSet = new Set(appliedList); + + if (!fs.existsSync(migrationsDir)) { + console.log("No pending migrations"); + return { applied: [], skipped: [] }; + } + + const files = fs + .readdirSync(migrationsDir) + .filter((file) => file.endsWith(".sql")) + .sort(); + + const pending = files.filter((file) => !appliedSet.has(file)); + const skipped = files.filter((file) => appliedSet.has(file)); + + if (pending.length === 0) { + console.log("No pending migrations"); + return { applied: [], skipped }; + } + + const applied: string[] = []; + + for (const file of pending) { + const filePath = path.join(migrationsDir, file); + const sql = fs.readFileSync(filePath, "utf-8"); + + await client.query("BEGIN"); + try { + await client.query(sql); + await client.query( + "INSERT INTO schema_migrations (filename, applied_at) VALUES ($1, NOW());", + [file] + ); + await client.query("COMMIT"); + applied.push(file); + console.log(`Applied migration: ${file}`); + } catch (err) { + await client.query("ROLLBACK"); + console.error(`Failed to apply migration ${file}:`, err); + throw err; + } + } + + return { applied, skipped }; + } finally { + client.release(); + if (closePoolOnComplete && pool) { + await pool.end(); + } + } +} + +export async function rollbackLastMigration(options?: MigrateOptions): Promise { + const databaseUrl = options?.databaseUrl || process.env["DATABASE_URL"]; + + let pool = options?.pool; + let closePoolOnComplete = false; + + if (!pool) { + if (!databaseUrl) { + throw new Error("DATABASE_URL environment variable is required to rollback migrations"); + } + pool = new Pool({ connectionString: databaseUrl }); + closePoolOnComplete = true; + } + + const client = await pool.connect(); + try { + await ensureMigrationTable(client); + + const res = await client.query<{ id: number; filename: string }>( + `SELECT id, filename FROM schema_migrations ORDER BY id DESC LIMIT 1;` + ); + + if (res.rows.length === 0) { + console.log("No migrations to rollback"); + return null; + } + + const lastMigration = res.rows[0]; + await client.query(`DELETE FROM schema_migrations WHERE id = $1;`, [lastMigration.id]); + + console.log(`Rolled back migration record: ${lastMigration.filename}`); + console.log(`Migration to reverse: ${lastMigration.filename}`); + + return lastMigration.filename; + } finally { + client.release(); + if (closePoolOnComplete && pool) { + await pool.end(); + } + } +} + +// Standalone CLI execution +if (require.main === module) { + const isRollback = process.argv.includes("rollback") || process.argv.includes("--rollback"); + + const action = isRollback ? rollbackLastMigration() : runMigrations(); + + action + .then(() => { + process.exit(0); + }) + .catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/server/src/db/migrations/001_referrals.sql b/server/src/db/migrations/001_referrals.sql new file mode 100644 index 0000000..5748c50 --- /dev/null +++ b/server/src/db/migrations/001_referrals.sql @@ -0,0 +1,11 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS referral_code VARCHAR(8) UNIQUE; + +CREATE TABLE IF NOT EXISTS referrals ( + referrer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + referred_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + rewarded_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CHECK (referrer_id <> referred_id) +); + +CREATE INDEX IF NOT EXISTS referrals_referrer_idx ON referrals(referrer_id); diff --git a/server/src/db/migrations/002_kyc.sql b/server/src/db/migrations/002_kyc.sql new file mode 100644 index 0000000..1099f0b --- /dev/null +++ b/server/src/db/migrations/002_kyc.sql @@ -0,0 +1,6 @@ +-- Seller KYC columns for issue #114 +ALTER TABLE users ADD COLUMN IF NOT EXISTS kyc_status VARCHAR(20) DEFAULT 'unverified'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS kyc_legal_name VARCHAR(255); +ALTER TABLE users ADD COLUMN IF NOT EXISTS kyc_date_of_birth DATE; +ALTER TABLE users ADD COLUMN IF NOT EXISTS kyc_nin VARCHAR(11); +ALTER TABLE users ADD COLUMN IF NOT EXISTS kyc_document_path TEXT;