diff --git a/docs/architecture/c4-model.md b/docs/architecture/c4-model.md new file mode 100644 index 00000000..051c5089 --- /dev/null +++ b/docs/architecture/c4-model.md @@ -0,0 +1,132 @@ +# C4 Model: System Context & Container Architecture + +This document gives a high-level view of the PropChain platform using the +[C4 model](https://c4model.com/): a System Context diagram (who uses the +system and what it talks to) and a Container diagram (the deployable units +inside the system and how they communicate). + +PropChain is split across three repositories: + +| Container | Repository | Stack | +|---|---|---| +| Web Client | [PropChain-FrontEnd](https://github.com/Dataguru-tech/PropChain-FrontEnd) (this repo) | Next.js 15 (App Router), React, wagmi/viem | +| NestJS API | [PropChain-BackEnd](https://github.com/MettaChain/PropChain-BackEnd) | NestJS, Prisma, PostgreSQL, Redis | +| Smart Contracts | PropChain-contract | Soroban (Rust/WASM) on Stellar, plus EVM contracts (Ethereum/Polygon/BSC) | + +## System Context + +At the highest level, an investor uses the Web Client in their browser. The +Web Client is the only thing end users talk to directly — it in turn talks +to the backend API and, for on-chain actions, to the user's connected wallet +and the blockchain networks. + +```mermaid +C4Context + title System Context — PropChain + + Person(investor, "Property Investor", "Browses, buys, and manages tokenized real estate") + + System_Boundary(propchain, "PropChain Platform") { + System(webClient, "Web Client", "Next.js web application") + System(api, "NestJS API", "Business logic, auth, listings, transactions") + } + + System_Ext(wallet, "Web3 Wallet", "MetaMask / WalletConnect / Coinbase Wallet") + System_Ext(chains, "Blockchain Networks", "Ethereum, Polygon, BSC, Soroban (Stellar)") + + Rel(investor, webClient, "Uses", "HTTPS (browser)") + Rel(webClient, api, "Reads/writes property, user, and transaction data", "HTTPS/REST + GraphQL") + Rel(webClient, wallet, "Requests signatures for", "Wallet provider RPC") + Rel(wallet, chains, "Submits signed transactions to", "JSON-RPC / Soroban RPC") + Rel(api, chains, "Reads on-chain state for indexing/verification", "JSON-RPC / Soroban RPC") +``` + +## Container Diagram + +Zooming in on the "PropChain Platform" boundary above: the Web Client is not +a pure static site — it has its own server-side layer (Next.js Route +Handlers / Server Components) that talks to Redis directly to cache property +search results, independent of the backend API's own Redis usage. + +```mermaid +C4Container + title Container Diagram — PropChain + + Person(investor, "Property Investor", "Browses, buys, and manages tokenized real estate") + + System_Boundary(propchain, "PropChain Platform") { + Container(webClient, "Web Client", "Next.js 15, React, wagmi/viem", "Renders the UI, manages wallet connections, and does client-side search/filtering over cached results") + Container(api, "NestJS API", "NestJS, TypeScript", "Owns business logic: auth, users, property listings, transactions, commissions, support tickets, admin") + ContainerDb(postgres, "PostgreSQL", "Relational database (via Prisma)", "System of record for users, properties, transactions, and RBAC") + ContainerDb(redis, "Redis", "In-memory cache / pub-sub / queue", "Caches property search results and single-property lookups; backs the API's job queue (BullMQ) and Socket.IO scaling") + Container(soroban, "Soroban WASM Contracts", "Rust compiled to WASM, deployed on Stellar", "On-chain tokenized-property and settlement logic") + } + + System_Ext(wallet, "Web3 Wallet", "MetaMask / WalletConnect / Coinbase Wallet") + System_Ext(evm, "EVM Chains", "Ethereum, Polygon, BSC — PropertyNFT, Marketplace, Staking contracts") + + Rel(investor, webClient, "Uses", "HTTPS (browser)") + Rel(webClient, api, "Fetches/updates listings, users, transactions", "HTTPS/REST + GraphQL (JSON)") + Rel(webClient, redis, "Reads/writes cached search results and property lookups (stale-while-revalidate)", "Redis protocol (TCP 6379)") + Rel(webClient, wallet, "Requests transaction signatures from", "Injected provider / WalletConnect RPC") + Rel(wallet, evm, "Submits signed transactions to", "JSON-RPC") + Rel(wallet, soroban, "Submits signed transactions to", "Soroban RPC") + Rel(api, postgres, "Reads/writes via Prisma ORM", "SQL (TCP 5432)") + Rel(api, redis, "Caches responses; enqueues background jobs; coordinates WebSocket instances", "Redis protocol (TCP 6379)") + Rel(api, evm, "Verifies/indexes on-chain events", "JSON-RPC") + Rel(api, soroban, "Verifies/indexes on-chain state", "Soroban RPC") +``` + +## Container Responsibilities + +### Web Client (this repository) +- Renders the property marketplace, dashboards, and wallet-connected flows. +- Connects wallets via `wagmi`/`viem` and submits signed transactions + directly from the browser — it never holds private keys. +- Has its own thin server-side layer (Next.js Route Handlers/Server + Components) that calls Redis directly (`src/lib/redisCache.ts`) to cache + property search results with a stale-while-revalidate strategy, and falls + back to an in-browser IndexedDB cache when offline (`src/lib/propertyCache.ts`). +- Talks to the NestJS API over HTTPS for anything that isn't purely + on-chain (listings, user profiles, transaction history). + +### NestJS API +- Source of truth for business logic: authentication/RBAC (`USER`, `AGENT`, + `ADMIN`), property listings, transactions, commissions, support tickets, + and admin operations. +- Persists relational data to PostgreSQL through Prisma. +- Uses Redis for response caching, as a BullMQ job queue backend, and as the + adapter that lets Socket.IO scale across multiple API instances. +- Exposes both REST and GraphQL endpoints, documented via Swagger + (`/api/docs`). + +### PostgreSQL +- The relational system of record: users, properties, transactions, and + role-based access control data. +- Accessed only by the NestJS API, never directly by the Web Client. + +### Redis +- Shared caching layer used by **both** the Web Client (for property search + results) and the NestJS API (for response caching, job queuing, and + WebSocket fan-out). Each side owns its own key namespace. + +### Soroban WASM Contracts +- Rust smart contracts compiled to WASM and deployed on Stellar via Soroban, + handling tokenized-property issuance and settlement logic. +- Invoked directly from the connected wallet (signed client-side) and read + back by both the Web Client and the NestJS API for verification/indexing. +- Complements the existing EVM contracts (Ethereum/Polygon/BSC) that back + the current `PropertyNFT`, `Marketplace`, and `Staking` flows in this + repo's `src/lib/abis/` — see + [`docs/smart-contract-integration.md`](../smart-contract-integration.md). + +## Notes + +- This diagram reflects the target platform architecture. The frontend's + current on-chain integration (`src/types/property.ts` `BLOCKCHAIN_NETWORKS`) + supports Ethereum, Polygon, and BSC today; Soroban support is part of the + platform's broader roadmap and is included here per the C4 documentation + requirement so the diagram stays accurate as that work lands. +- For frontend-specific caching behavior and TTLs, see + [`docs/CACHE_TTL_RATIONALE.md`](../CACHE_TTL_RATIONALE.md) and + [`docs/cache-api.md`](../cache-api.md). diff --git a/src/lib/__tests__/transactionService.test.ts b/src/lib/__tests__/transactionService.test.ts index 66bed725..6b3873e4 100644 --- a/src/lib/__tests__/transactionService.test.ts +++ b/src/lib/__tests__/transactionService.test.ts @@ -1,7 +1,6 @@ -import { mapApiTransaction, type ApiTransaction } from '@/lib/transactionService'; +import { mapApiTransaction, transactionService, type ApiTransaction } from '@/lib/transactionService'; const walletAddress = '0x1234567890123456789012345678901234567890'; - const baseApiTx: ApiTransaction = { id: 'tx-1', type: 'purchase', @@ -17,7 +16,6 @@ const baseApiTx: ApiTransaction = { describe('mapApiTransaction', () => { it('maps API fields to store Transaction shape', () => { const result = mapApiTransaction(baseApiTx, walletAddress); - expect(result.id).toBe('tx-1'); expect(result.hash).toBe('0xabc123'); expect(result.type).toBe('purchase'); @@ -44,3 +42,77 @@ describe('mapApiTransaction', () => { expect(mapApiTransaction({ ...withoutTotal, amount: 42 }, walletAddress).value).toBe('42'); }); }); + +describe('transactionService.getTransactions', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('requests the transactions endpoint with the URL-encoded wallet address', async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => [], + }); + global.fetch = mockFetch as unknown as typeof fetch; + + const trickyAddress = '0xabc def/123'; + await transactionService.getTransactions(trickyAddress); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith( + `/api/transactions?walletAddress=${encodeURIComponent(trickyAddress)}` + ); + }); + + it('maps each returned API transaction into the store Transaction shape', async () => { + const apiTxs: ApiTransaction[] = [ + { ...baseApiTx, id: 'tx-1' }, + { ...baseApiTx, id: 'tx-2', status: 'pending' }, + ]; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => apiTxs, + }) as unknown as typeof fetch; + + const result = await transactionService.getTransactions(walletAddress); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual(mapApiTransaction(apiTxs[0], walletAddress)); + expect(result[1].status).toBe('pending'); + }); + + it('throws the server-provided error message when the response is not ok', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + json: async () => ({ error: 'Wallet not found' }), + }) as unknown as typeof fetch; + + await expect(transactionService.getTransactions(walletAddress)).rejects.toThrow( + 'Wallet not found' + ); + }); + + it('falls back to a generic error message when the error response body is not valid JSON', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + json: async () => { + throw new Error('invalid json'); + }, + }) as unknown as typeof fetch; + + await expect(transactionService.getTransactions(walletAddress)).rejects.toThrow( + 'Failed to fetch transactions' + ); + }); + + it('propagates a network failure thrown by fetch itself', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Network down')) as unknown as typeof fetch; + + await expect(transactionService.getTransactions(walletAddress)).rejects.toThrow( + 'Network down' + ); + }); +});