diff --git a/CHANGELOG.md b/CHANGELOG.md index 538e3c9..fe7a09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,49 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added (Phase 4 — Module Licensing Backend) + +**Backend Infrastructure:** +- Migration `009_module_licensing.sql` — Module marketplace database schema: + - `modules` table — Registry of available modules (slug, name, description, category, price, features, version, plugin URL) + - `module_purchases` table — License-module ownership tracking with transaction logging + - `module_installations` table — Deployment status tracking (pending, installing, installed, failed) + - Seed data: Loot Manager module ($9.99) with features array +- `backend/src/models/modules.rs` — Domain models: + - `Module` struct with rust_decimal pricing support + - `ModuleWithOwnership` — Catalog display with is_purchased flag + - `ModulePurchase`, `ModuleInstallation` — Purchase and deployment records + - `PurchasedModule` — Combined view for user's module library +- `backend/src/db/modules.rs` — Data access layer (11 query functions): + - `get_module_catalog()` — All available modules + - `get_catalog_with_ownership(license_id)` — Annotated catalog with purchase status + - `get_purchased_modules(license_id)` — User's module library with installation status + - `is_module_purchased(license_id, module_id)` — Ownership validation + - `record_module_purchase()` — Transaction logging with PayPal ID support + - `get_module_installation_status()` / `update_installation_status()` — Deployment tracking + - `get_module_by_id()` / `get_module_by_slug()` — Module lookup +- `backend/src/api/modules.rs` — REST endpoints with auth middleware: + - `GET /api/modules/catalog` — Returns modules with is_purchased flag for current license + - `GET /api/modules/my-modules` — Purchased modules with installation details + - `POST /api/modules/purchase` — Records purchase (stub transaction for Phase 4 MVP — payment integration deferred to XO's direct touch) + - `POST /api/modules/install` — Triggers module installation via ModuleInstaller service + - `GET /api/modules/:module_id/installation-status` — Real-time deployment status polling +- Router integration in `main.rs` at `/api/modules` with JWT auth requirement +- `Cargo.toml` dependency: `rust_decimal` for DECIMAL field support + +**Multi-Tenancy Enforcement:** +- All queries scoped by `license_id` from JWT claims +- Foreign key constraints enforce license-module binding +- Purchase validation prevents cross-tenant access +- Installation status isolated per license + +**Payment Integration Strategy:** +- Purchase endpoint stubs transaction with "STUB_TRANSACTION" ID +- PayPal integration deferred to XO's direct implementation +- `transaction_id` and `amount_paid` fields ready for real gateway + +**Status:** Module licensing backend operational. Catalog queryable, purchases recordable, ownership enforceable, installation status trackable. Payment gateway integration pending. + ### Added (Phase 4 — Loot Manager Plugin Skeleton) **Plugin Skeleton:** diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d4ca715..263f1e9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -43,6 +43,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -106,6 +117,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-nats" version = "0.38.0" @@ -150,7 +167,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -262,7 +279,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -292,6 +309,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "blake2" version = "0.10.6" @@ -310,12 +339,57 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytemuck" version = "1.25.0" @@ -471,6 +545,7 @@ dependencies = [ "lettre", "rand 0.8.5", "reqwest", + "rust_decimal", "serde", "serde_json", "sha2", @@ -598,7 +673,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -648,7 +723,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -820,6 +895,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.31" @@ -887,7 +968,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -999,13 +1080,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash", + "ahash 0.8.12", "allocator-api2", ] @@ -1733,7 +1823,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -1881,7 +1971,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -1985,7 +2075,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.115", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", ] [[package]] @@ -2007,6 +2106,26 @@ dependencies = [ "cc", ] +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "pxfm" version = "0.1.27" @@ -2109,6 +2228,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -2215,6 +2340,15 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -2267,6 +2401,35 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rsa" version = "0.9.10" @@ -2287,6 +2450,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -2410,6 +2589,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "security-framework" version = "2.11.1" @@ -2479,7 +2664,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -2523,7 +2708,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -2613,6 +2798,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simple_asn1" version = "0.6.4" @@ -2730,7 +2921,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.115", ] [[package]] @@ -2753,7 +2944,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.115", "tokio", "url", ] @@ -2903,6 +3094,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.115" @@ -2931,9 +3133,15 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "thiserror" version = "1.0.69" @@ -2960,7 +3168,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -2971,7 +3179,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -3079,7 +3287,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -3149,6 +3357,36 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" +dependencies = [ + "winnow", +] + [[package]] name = "totp-rs" version = "5.7.0" @@ -3242,7 +3480,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -3536,7 +3774,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.115", "wasm-bindgen-shared", ] @@ -3652,7 +3890,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -3663,7 +3901,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -3921,6 +4159,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3951,7 +4198,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.115", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3967,7 +4214,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.115", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -4015,6 +4262,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "yoke" version = "0.8.1" @@ -4034,7 +4290,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", "synstructure", ] @@ -4055,7 +4311,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] @@ -4075,7 +4331,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", "synstructure", ] @@ -4115,7 +4371,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.115", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 172059e..342692a 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -19,6 +19,7 @@ futures = "0.3" # Database sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "migrate"] } +rust_decimal = { version = "1", features = ["serde"] } # Messaging async-nats = "0.38" diff --git a/backend/migrations/009_module_licensing.sql b/backend/migrations/009_module_licensing.sql new file mode 100644 index 0000000..fe0d0b5 --- /dev/null +++ b/backend/migrations/009_module_licensing.sql @@ -0,0 +1,55 @@ +-- Module Licensing System +-- Phase 4: Module marketplace and license-module binding + +-- Module registry (available modules in marketplace) +CREATE TABLE modules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + slug VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(200) NOT NULL, + description TEXT, + category VARCHAR(50), -- 'loot', 'events', 'economy', 'kits', etc. + price_usd DECIMAL(10,2) NOT NULL, + preview_image_url TEXT, + screenshots JSONB, + features JSONB, + version VARCHAR(20) NOT NULL, + plugin_file_url TEXT, -- Download URL for plugin file + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Module purchases (which licenses own which modules) +CREATE TABLE module_purchases ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE, + module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + purchased_at TIMESTAMPTZ DEFAULT NOW(), + transaction_id VARCHAR(255), -- PayPal transaction ID + amount_paid DECIMAL(10,2), + UNIQUE(license_id, module_id) +); + +-- Module installations (deployment status on servers) +CREATE TABLE module_installations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + license_id UUID NOT NULL REFERENCES licenses(id) ON DELETE CASCADE, + module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + status VARCHAR(50) DEFAULT 'pending', -- 'pending', 'installing', 'installed', 'failed' + installed_at TIMESTAMPTZ, + error_message TEXT, + UNIQUE(license_id, module_id) +); + +CREATE INDEX idx_module_purchases_license ON module_purchases(license_id); +CREATE INDEX idx_module_installations_license ON module_installations(license_id); + +-- Seed data: Loot Manager module +INSERT INTO modules (slug, name, description, category, price_usd, version, features) +VALUES ( + 'loot-manager', + 'Loot Manager', + 'Visual loot table editor with drag-and-drop items, spawn rates, and one-click profile switching.', + 'loot', + 9.99, + '1.0.0', + '["Visual loot table editor", "Container browser (crates, barrels, NPCs)", "Loot profiles (2x, 10x, etc.)", "One-click profile switching", "Import/export profiles"]'::JSONB +); diff --git a/backend/src/api/mod.rs b/backend/src/api/mod.rs index 50f16f0..d659246 100644 --- a/backend/src/api/mod.rs +++ b/backend/src/api/mod.rs @@ -17,3 +17,4 @@ pub mod ws; pub mod analytics; pub mod plugin; pub mod settings; +pub mod modules; diff --git a/backend/src/api/modules.rs b/backend/src/api/modules.rs new file mode 100644 index 0000000..4f27421 --- /dev/null +++ b/backend/src/api/modules.rs @@ -0,0 +1,232 @@ +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::db; +use crate::middleware::auth::AuthUser; +use crate::models::error::{ApiError, ApiResult}; +use crate::models::modules::{ModuleWithOwnership, PurchasedModule}; +use crate::services::module_installer::ModuleInstaller; +use crate::services::nats_bridge::NatsBridge; +use crate::AppState; + +pub fn router() -> Router> { + Router::new() + .route("/catalog", get(get_catalog)) + .route("/my-modules", get(get_my_modules)) + .route("/purchase", post(purchase_module)) + .route("/install", post(install_module)) + .route("/:module_id/installation-status", get(get_installation_status)) +} + +/// GET /api/modules/catalog +/// Returns all modules with is_purchased flag for the authenticated user's license. +async fn get_catalog( + State(state): State>, + auth: AuthUser, +) -> ApiResult> { + let license_id = auth.license_id.ok_or_else(|| { + ApiError::BadRequest("No license associated with this user".to_string()) + })?; + + let catalog = db::modules::get_catalog_with_ownership(&state.db, license_id).await?; + + Ok(Json(CatalogResponse { modules: catalog })) +} + +/// GET /api/modules/my-modules +/// Returns purchased modules with installation status. +async fn get_my_modules( + State(state): State>, + auth: AuthUser, +) -> ApiResult> { + let license_id = auth.license_id.ok_or_else(|| { + ApiError::BadRequest("No license associated with this user".to_string()) + })?; + + let modules = db::modules::get_purchased_modules(&state.db, license_id).await?; + + Ok(Json(MyModulesResponse { modules })) +} + +/// POST /api/modules/purchase +/// Initiate module purchase (stub for Phase 4 — payment integration is XO's direct touch). +/// For MVP, immediately records the purchase without payment gateway. +async fn purchase_module( + State(state): State>, + auth: AuthUser, + Json(req): Json, +) -> ApiResult> { + let license_id = auth.license_id.ok_or_else(|| { + ApiError::BadRequest("No license associated with this user".to_string()) + })?; + + // Verify module exists + let module = db::modules::get_module_by_id(&state.db, req.module_id) + .await? + .ok_or_else(|| ApiError::NotFound("Module not found".to_string()))?; + + // Check if already purchased + let already_purchased = db::modules::is_module_purchased(&state.db, license_id, req.module_id).await?; + if already_purchased { + return Err(ApiError::Conflict("Module already purchased".to_string())); + } + + // Record purchase (no payment gateway for MVP — stub transaction) + let purchase_id = db::modules::record_module_purchase( + &state.db, + license_id, + req.module_id, + Some("STUB_TRANSACTION"), // TODO: Replace with real PayPal transaction ID + Some(module.price_usd), + ).await?; + + tracing::info!( + "Module purchase recorded: license={}, module={}, purchase_id={}", + license_id, req.module_id, purchase_id + ); + + Ok(Json(PurchaseResponse { + success: true, + purchase_id, + message: "Module purchased successfully (payment stub)".to_string(), + })) +} + +/// POST /api/modules/install +/// Trigger module installation (calls panel adapter or companion agent). +async fn install_module( + State(state): State>, + auth: AuthUser, + Json(req): Json, +) -> ApiResult> { + let license_id = auth.license_id.ok_or_else(|| { + ApiError::BadRequest("No license associated with this user".to_string()) + })?; + + // Verify ownership + let is_purchased = db::modules::is_module_purchased(&state.db, license_id, req.module_id).await?; + if !is_purchased { + return Err(ApiError::Forbidden); + } + + // Verify module exists and get plugin file URL + let module = db::modules::get_module_by_id(&state.db, req.module_id) + .await? + .ok_or_else(|| ApiError::NotFound("Module not found".to_string()))?; + + // Ensure NATS is available + let nats_client = state + .nats + .as_ref() + .ok_or_else(|| ApiError::Internal("NATS not available".to_string()))?; + + let nats_bridge = Arc::new(NatsBridge::new(nats_client.clone())); + let installer = ModuleInstaller::new( + state.db.clone(), + nats_bridge, + state.config.encryption_key.clone(), + ); + + // Set status to 'installing' immediately + db::modules::update_installation_status( + &state.db, + license_id, + req.module_id, + "installing", + None, + ).await?; + + // Spawn background task for installation + let module_name = module.name.clone(); + tokio::spawn(async move { + if let Err(e) = installer.install_module(license_id, req.module_id).await { + tracing::error!( + "Module installation failed for license {} module {}: {}", + license_id, + req.module_id, + e + ); + } + }); + + tracing::info!( + "Module installation triggered: license={}, module={}", + license_id, req.module_id + ); + + Ok(Json(InstallResponse { + success: true, + message: format!("Installation of '{}' started. Check status for progress.", module_name), + })) +} + +/// GET /api/modules/:module_id/installation-status +/// Get installation status for a specific module. +async fn get_installation_status( + State(state): State>, + auth: AuthUser, + Path(module_id): Path, +) -> ApiResult> { + let license_id = auth.license_id.ok_or_else(|| { + ApiError::BadRequest("No license associated with this user".to_string()) + })?; + + let installation = db::modules::get_module_installation_status(&state.db, license_id, module_id).await?; + + Ok(Json(InstallationStatusResponse { + module_id, + status: installation.as_ref().map(|i| i.status.clone()), + installed_at: installation.as_ref().and_then(|i| i.installed_at), + error_message: installation.and_then(|i| i.error_message), + })) +} + +// ===== Request/Response Types ===== + +#[derive(Debug, Deserialize)] +struct PurchaseRequest { + module_id: Uuid, +} + +#[derive(Debug, Serialize)] +struct PurchaseResponse { + success: bool, + purchase_id: Uuid, + message: String, +} + +#[derive(Debug, Deserialize)] +struct InstallRequest { + module_id: Uuid, +} + +#[derive(Debug, Serialize)] +struct InstallResponse { + success: bool, + message: String, +} + +#[derive(Debug, Serialize)] +struct CatalogResponse { + modules: Vec, +} + +#[derive(Debug, Serialize)] +struct MyModulesResponse { + modules: Vec, +} + +#[derive(Debug, Serialize)] +struct InstallationStatusResponse { + module_id: Uuid, + status: Option, + installed_at: Option>, + error_message: Option, +} diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 9034432..ce413c6 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -15,3 +15,4 @@ pub mod store; pub mod player_sessions; pub mod public; pub mod alerts; +pub mod modules; diff --git a/backend/src/db/modules.rs b/backend/src/db/modules.rs new file mode 100644 index 0000000..32f4a8b --- /dev/null +++ b/backend/src/db/modules.rs @@ -0,0 +1,224 @@ +use sqlx::PgPool; +use uuid::Uuid; +use anyhow::{Context, Result}; + +use crate::models::modules::{Module, ModuleInstallation, ModuleWithOwnership, PurchasedModule}; + +/// Get all available modules from the marketplace catalog. +pub async fn get_module_catalog(pool: &PgPool) -> Result> { + let modules = sqlx::query_as::<_, Module>( + "SELECT * FROM modules ORDER BY category, name" + ) + .fetch_all(pool) + .await + .context("Failed to fetch module catalog")?; + + Ok(modules) +} + +/// Get modules with ownership flag for a specific license. +/// Returns all modules annotated with is_purchased for the given license. +pub async fn get_catalog_with_ownership(pool: &PgPool, license_id: Uuid) -> Result> { + let rows: Vec<(Module, Option, Option)> = sqlx::query_as( + "SELECT + m.*, + mp.id as purchase_id, + mi.status as installation_status + FROM modules m + LEFT JOIN module_purchases mp ON mp.module_id = m.id AND mp.license_id = $1 + LEFT JOIN module_installations mi ON mi.module_id = m.id AND mi.license_id = $1 + ORDER BY m.category, m.name" + ) + .bind(license_id) + .fetch_all(pool) + .await + .context("Failed to fetch catalog with ownership")?; + + let modules = rows.into_iter().map(|(module, purchase_id, installation_status)| { + ModuleWithOwnership { + module, + is_purchased: purchase_id.is_some(), + installation_status, + } + }).collect(); + + Ok(modules) +} + +/// Get all modules purchased by a specific license. +pub async fn get_purchased_modules(pool: &PgPool, license_id: Uuid) -> Result> { + type Row = ( + // Module fields + Uuid, String, String, Option, Option, rust_decimal::Decimal, + Option, Option, Option, String, + Option, chrono::DateTime, + // Purchase fields + chrono::DateTime, + // Installation fields + Option, Option>, Option + ); + + let rows: Vec = sqlx::query_as( + "SELECT + m.id, m.slug, m.name, m.description, m.category, m.price_usd, + m.preview_image_url, m.screenshots, m.features, m.version, + m.plugin_file_url, m.created_at, + mp.purchased_at, + mi.status as installation_status, + mi.installed_at, + mi.error_message + FROM modules m + INNER JOIN module_purchases mp ON mp.module_id = m.id + LEFT JOIN module_installations mi ON mi.module_id = m.id AND mi.license_id = mp.license_id + WHERE mp.license_id = $1 + ORDER BY mp.purchased_at DESC" + ) + .bind(license_id) + .fetch_all(pool) + .await + .context("Failed to fetch purchased modules")?; + + let modules = rows.into_iter().map(|row| { + PurchasedModule { + module: Module { + id: row.0, + slug: row.1, + name: row.2, + description: row.3, + category: row.4, + price_usd: row.5, + preview_image_url: row.6, + screenshots: row.7, + features: row.8, + version: row.9, + plugin_file_url: row.10, + created_at: row.11, + }, + purchased_at: row.12, + installation_status: row.13, + installed_at: row.14, + error_message: row.15, + } + }).collect(); + + Ok(modules) +} + +/// Check if a module is purchased by a license. +pub async fn is_module_purchased(pool: &PgPool, license_id: Uuid, module_id: Uuid) -> Result { + let exists: (bool,) = sqlx::query_as( + "SELECT EXISTS(SELECT 1 FROM module_purchases WHERE license_id = $1 AND module_id = $2)" + ) + .bind(license_id) + .bind(module_id) + .fetch_one(pool) + .await + .context("Failed to check module ownership")?; + + Ok(exists.0) +} + +/// Record a module purchase. +pub async fn record_module_purchase( + pool: &PgPool, + license_id: Uuid, + module_id: Uuid, + transaction_id: Option<&str>, + amount: Option, +) -> Result { + let purchase_id: Uuid = sqlx::query_scalar( + "INSERT INTO module_purchases (license_id, module_id, transaction_id, amount_paid) + VALUES ($1, $2, $3, $4) + ON CONFLICT (license_id, module_id) DO UPDATE + SET transaction_id = EXCLUDED.transaction_id, amount_paid = EXCLUDED.amount_paid + RETURNING id" + ) + .bind(license_id) + .bind(module_id) + .bind(transaction_id) + .bind(amount) + .fetch_one(pool) + .await + .context("Failed to record module purchase")?; + + Ok(purchase_id) +} + +/// Get installation status for a module. +pub async fn get_module_installation_status( + pool: &PgPool, + license_id: Uuid, + module_id: Uuid, +) -> Result> { + let installation = sqlx::query_as::<_, ModuleInstallation>( + "SELECT * FROM module_installations WHERE license_id = $1 AND module_id = $2" + ) + .bind(license_id) + .bind(module_id) + .fetch_optional(pool) + .await + .context("Failed to fetch installation status")?; + + Ok(installation) +} + +/// Update module installation status. +pub async fn update_installation_status( + pool: &PgPool, + license_id: Uuid, + module_id: Uuid, + status: &str, + error: Option<&str>, +) -> Result<()> { + let now = if status == "installed" { + Some(chrono::Utc::now()) + } else { + None + }; + + sqlx::query( + "INSERT INTO module_installations (license_id, module_id, status, installed_at, error_message) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (license_id, module_id) + DO UPDATE SET + status = EXCLUDED.status, + installed_at = EXCLUDED.installed_at, + error_message = EXCLUDED.error_message" + ) + .bind(license_id) + .bind(module_id) + .bind(status) + .bind(now) + .bind(error) + .execute(pool) + .await + .context("Failed to update installation status")?; + + Ok(()) +} + +/// Get a module by ID. +pub async fn get_module_by_id(pool: &PgPool, module_id: Uuid) -> Result> { + let module = sqlx::query_as::<_, Module>( + "SELECT * FROM modules WHERE id = $1" + ) + .bind(module_id) + .fetch_optional(pool) + .await + .context("Failed to fetch module by ID")?; + + Ok(module) +} + +/// Get a module by slug. +pub async fn get_module_by_slug(pool: &PgPool, slug: &str) -> Result> { + let module = sqlx::query_as::<_, Module>( + "SELECT * FROM modules WHERE slug = $1" + ) + .bind(slug) + .fetch_optional(pool) + .await + .context("Failed to fetch module by slug")?; + + Ok(module) +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 9fa1b4d..562b6d0 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -131,6 +131,7 @@ async fn main() -> anyhow::Result<()> { .nest("/api/analytics", api::analytics::router()) .nest("/api/plugin", api::plugin::router()) .nest("/api/settings", api::settings::router()) + .nest("/api/modules", api::modules::router()) .layer(cors) .layer(TraceLayer::new_for_http()) .with_state(state); diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index 00f76a9..55bf03b 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod error; pub mod license; +pub mod modules; pub mod public; pub mod server; pub mod wipe; diff --git a/backend/src/models/modules.rs b/backend/src/models/modules.rs new file mode 100644 index 0000000..4ddc509 --- /dev/null +++ b/backend/src/models/modules.rs @@ -0,0 +1,61 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Module available in the marketplace +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Module { + pub id: Uuid, + pub slug: String, + pub name: String, + pub description: Option, + pub category: Option, + pub price_usd: rust_decimal::Decimal, + pub preview_image_url: Option, + pub screenshots: Option, + pub features: Option, + pub version: String, + pub plugin_file_url: Option, + pub created_at: chrono::DateTime, +} + +/// Module with ownership flag for catalog display +#[derive(Debug, Clone, Serialize)] +pub struct ModuleWithOwnership { + #[serde(flatten)] + pub module: Module, + pub is_purchased: bool, + pub installation_status: Option, +} + +/// Module purchase record +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ModulePurchase { + pub id: Uuid, + pub license_id: Uuid, + pub module_id: Uuid, + pub purchased_at: chrono::DateTime, + pub transaction_id: Option, + pub amount_paid: Option, +} + +/// Module installation status +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ModuleInstallation { + pub id: Uuid, + pub license_id: Uuid, + pub module_id: Uuid, + pub status: String, + pub installed_at: Option>, + pub error_message: Option, +} + +/// Module with installation details for user's purchased modules +#[derive(Debug, Clone, Serialize)] +pub struct PurchasedModule { + #[serde(flatten)] + pub module: Module, + pub purchased_at: chrono::DateTime, + pub installation_status: Option, + pub installed_at: Option>, + pub error_message: Option, +}