From cda5921c3085ea5355f408a86454319134b445c3 Mon Sep 17 00:00:00 2001 From: Merul Dhiman Date: Sun, 7 Dec 2025 11:01:14 +0530 Subject: [PATCH] chore: move eCurrency --- platforms/eCurrency-api/package.json | 42 ++ .../src/controllers/AuthController.ts | 106 +++++ .../src/controllers/CurrencyController.ts | 148 +++++++ .../src/controllers/GroupController.ts | 75 ++++ .../src/controllers/LedgerController.ts | 357 ++++++++++++++++ .../src/controllers/UserController.ts | 141 +++++++ .../src/controllers/WebhookController.ts | 191 +++++++++ .../eCurrency-api/src/database/data-source.ts | 26 ++ .../src/database/entities/Currency.ts | 55 +++ .../src/database/entities/Group.ts | 77 ++++ .../src/database/entities/Ledger.ts | 82 ++++ .../src/database/entities/User.ts | 71 ++++ .../migrations/1765035161362-migration.ts | 82 ++++ .../migrations/1765039149572-migration.ts | 14 + .../migrations/1765044585971-migration.ts | 24 ++ platforms/eCurrency-api/src/index.ts | 112 +++++ .../eCurrency-api/src/middleware/auth.ts | 42 ++ .../src/services/CurrencyService.ts | 127 ++++++ .../src/services/GroupService.ts | 88 ++++ .../src/services/LedgerService.ts | 278 +++++++++++++ .../eCurrency-api/src/services/UserService.ts | 67 +++ platforms/eCurrency-api/src/types/express.ts | 13 + platforms/eCurrency-api/src/utils/jwt.ts | 30 ++ .../eCurrency-api/src/web3adapter/index.ts | 2 + .../mappings/currency.mapping.json | 17 + .../web3adapter/mappings/group.mapping.json | 25 ++ .../web3adapter/mappings/ledger.mapping.json | 18 + .../web3adapter/mappings/user.mapping.json | 23 ++ .../src/web3adapter/watchers/subscriber.ts | 389 ++++++++++++++++++ platforms/eCurrency-api/tsconfig.json | 25 ++ platforms/eCurrency/client/index.html | 14 + platforms/eCurrency/client/src/App.tsx | 40 ++ .../components/account-context-switcher.tsx | 83 ++++ .../src/components/auth/login-screen.tsx | 167 ++++++++ .../currency/add-currency-account-modal.tsx | 180 ++++++++ .../currency/create-currency-modal.tsx | 155 +++++++ .../currency/mint-currency-modal.tsx | 111 +++++ .../components/currency/transaction-card.tsx | 83 ++++ .../currency/transaction-detail-modal.tsx | 164 ++++++++ .../components/currency/transfer-modal.tsx | 378 +++++++++++++++++ .../src/components/ui/custom-number-input.tsx | 74 ++++ .../src/components/user-menu-dropdown.tsx | 115 ++++++ .../eCurrency/client/src/hooks/useAuth.ts | 2 + platforms/eCurrency/client/src/index.css | 57 +++ .../eCurrency/client/src/lib/apiClient.ts | 44 ++ .../eCurrency/client/src/lib/auth-context.tsx | 91 ++++ .../eCurrency/client/src/lib/authUtils.ts | 25 ++ platforms/eCurrency/client/src/lib/utils.ts | 12 + .../client/src/lib/utils/mobile-detection.ts | 14 + platforms/eCurrency/client/src/main.tsx | 11 + .../eCurrency/client/src/pages/auth-page.tsx | 5 + .../eCurrency/client/src/pages/currencies.tsx | 100 +++++ .../client/src/pages/currency-detail.tsx | 296 +++++++++++++ .../eCurrency/client/src/pages/dashboard.tsx | 328 +++++++++++++++ platforms/eCurrency/package.json | 49 +++ platforms/eCurrency/postcss.config.js | 7 + platforms/eCurrency/tailwind.config.ts | 60 +++ platforms/eCurrency/tsconfig.json | 23 ++ platforms/eCurrency/vite.config.ts | 29 ++ platforms/registry/src/index.ts | 25 +- pnpm-lock.yaml | 170 ++++++++ 61 files changed, 5646 insertions(+), 13 deletions(-) create mode 100644 platforms/eCurrency-api/package.json create mode 100644 platforms/eCurrency-api/src/controllers/AuthController.ts create mode 100644 platforms/eCurrency-api/src/controllers/CurrencyController.ts create mode 100644 platforms/eCurrency-api/src/controllers/GroupController.ts create mode 100644 platforms/eCurrency-api/src/controllers/LedgerController.ts create mode 100644 platforms/eCurrency-api/src/controllers/UserController.ts create mode 100644 platforms/eCurrency-api/src/controllers/WebhookController.ts create mode 100644 platforms/eCurrency-api/src/database/data-source.ts create mode 100644 platforms/eCurrency-api/src/database/entities/Currency.ts create mode 100644 platforms/eCurrency-api/src/database/entities/Group.ts create mode 100644 platforms/eCurrency-api/src/database/entities/Ledger.ts create mode 100644 platforms/eCurrency-api/src/database/entities/User.ts create mode 100644 platforms/eCurrency-api/src/database/migrations/1765035161362-migration.ts create mode 100644 platforms/eCurrency-api/src/database/migrations/1765039149572-migration.ts create mode 100644 platforms/eCurrency-api/src/database/migrations/1765044585971-migration.ts create mode 100644 platforms/eCurrency-api/src/index.ts create mode 100644 platforms/eCurrency-api/src/middleware/auth.ts create mode 100644 platforms/eCurrency-api/src/services/CurrencyService.ts create mode 100644 platforms/eCurrency-api/src/services/GroupService.ts create mode 100644 platforms/eCurrency-api/src/services/LedgerService.ts create mode 100644 platforms/eCurrency-api/src/services/UserService.ts create mode 100644 platforms/eCurrency-api/src/types/express.ts create mode 100644 platforms/eCurrency-api/src/utils/jwt.ts create mode 100644 platforms/eCurrency-api/src/web3adapter/index.ts create mode 100644 platforms/eCurrency-api/src/web3adapter/mappings/currency.mapping.json create mode 100644 platforms/eCurrency-api/src/web3adapter/mappings/group.mapping.json create mode 100644 platforms/eCurrency-api/src/web3adapter/mappings/ledger.mapping.json create mode 100644 platforms/eCurrency-api/src/web3adapter/mappings/user.mapping.json create mode 100644 platforms/eCurrency-api/src/web3adapter/watchers/subscriber.ts create mode 100644 platforms/eCurrency-api/tsconfig.json create mode 100644 platforms/eCurrency/client/index.html create mode 100644 platforms/eCurrency/client/src/App.tsx create mode 100644 platforms/eCurrency/client/src/components/account-context-switcher.tsx create mode 100644 platforms/eCurrency/client/src/components/auth/login-screen.tsx create mode 100644 platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx create mode 100644 platforms/eCurrency/client/src/components/currency/create-currency-modal.tsx create mode 100644 platforms/eCurrency/client/src/components/currency/mint-currency-modal.tsx create mode 100644 platforms/eCurrency/client/src/components/currency/transaction-card.tsx create mode 100644 platforms/eCurrency/client/src/components/currency/transaction-detail-modal.tsx create mode 100644 platforms/eCurrency/client/src/components/currency/transfer-modal.tsx create mode 100644 platforms/eCurrency/client/src/components/ui/custom-number-input.tsx create mode 100644 platforms/eCurrency/client/src/components/user-menu-dropdown.tsx create mode 100644 platforms/eCurrency/client/src/hooks/useAuth.ts create mode 100644 platforms/eCurrency/client/src/index.css create mode 100644 platforms/eCurrency/client/src/lib/apiClient.ts create mode 100644 platforms/eCurrency/client/src/lib/auth-context.tsx create mode 100644 platforms/eCurrency/client/src/lib/authUtils.ts create mode 100644 platforms/eCurrency/client/src/lib/utils.ts create mode 100644 platforms/eCurrency/client/src/lib/utils/mobile-detection.ts create mode 100644 platforms/eCurrency/client/src/main.tsx create mode 100644 platforms/eCurrency/client/src/pages/auth-page.tsx create mode 100644 platforms/eCurrency/client/src/pages/currencies.tsx create mode 100644 platforms/eCurrency/client/src/pages/currency-detail.tsx create mode 100644 platforms/eCurrency/client/src/pages/dashboard.tsx create mode 100644 platforms/eCurrency/package.json create mode 100644 platforms/eCurrency/postcss.config.js create mode 100644 platforms/eCurrency/tailwind.config.ts create mode 100644 platforms/eCurrency/tsconfig.json create mode 100644 platforms/eCurrency/vite.config.ts diff --git a/platforms/eCurrency-api/package.json b/platforms/eCurrency-api/package.json new file mode 100644 index 00000000..8c63a7ee --- /dev/null +++ b/platforms/eCurrency-api/package.json @@ -0,0 +1,42 @@ +{ + "name": "ecurrency-api", + "version": "1.0.0", + "description": "eCurrency Platform API", + "main": "src/index.ts", + "scripts": { + "start": "ts-node --project tsconfig.json src/index.ts", + "dev": "nodemon --exec \"npx ts-node\" src/index.ts", + "build": "tsc", + "typeorm": "typeorm-ts-node-commonjs", + "migration:generate": "typeorm-ts-node-commonjs migration:generate -d src/database/data-source.ts", + "migration:run": "typeorm-ts-node-commonjs migration:run -d src/database/data-source.ts", + "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/database/data-source.ts" + }, + "dependencies": { + "axios": "^1.6.7", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "pg": "^8.11.3", + "reflect-metadata": "^0.2.1", + "typeorm": "^0.3.24", + "uuid": "^9.0.1", + "web3-adapter": "link:../../infrastructure/web3-adapter" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.5", + "@types/node": "^20.11.24", + "@types/pg": "^8.11.2", + "@types/uuid": "^9.0.8", + "@typescript-eslint/eslint-plugin": "^7.0.1", + "@typescript-eslint/parser": "^7.0.1", + "eslint": "^8.56.0", + "nodemon": "^3.0.3", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} + diff --git a/platforms/eCurrency-api/src/controllers/AuthController.ts b/platforms/eCurrency-api/src/controllers/AuthController.ts new file mode 100644 index 00000000..545d1338 --- /dev/null +++ b/platforms/eCurrency-api/src/controllers/AuthController.ts @@ -0,0 +1,106 @@ +import { Request, Response } from "express"; +import { v4 as uuidv4 } from "uuid"; +import { UserService } from "../services/UserService"; +import { EventEmitter } from "events"; +import { signToken } from "../utils/jwt"; + +export class AuthController { + private userService: UserService; + private eventEmitter: EventEmitter; + + constructor() { + this.userService = new UserService(); + this.eventEmitter = new EventEmitter(); + } + + sseStream = async (req: Request, res: Response) => { + const { id } = req.params; + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Access-Control-Allow-Origin": "*", + }); + + const handler = (data: any) => { + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + + this.eventEmitter.on(id, handler); + + req.on("close", () => { + this.eventEmitter.off(id, handler); + res.end(); + }); + + req.on("error", (error) => { + console.error("SSE Error:", error); + this.eventEmitter.off(id, handler); + res.end(); + }); + }; + + getOffer = async (req: Request, res: Response) => { + const baseUrl = process.env.VITE_ECURRENCY_BASE_URL || "http://localhost:9888"; + const url = new URL( + "/api/auth", + baseUrl, + ).toString(); + const sessionId = uuidv4(); + const offer = `w3ds://auth?redirect=${url}&session=${sessionId}&platform=ecurrency`; + res.json({ offer, sessionId }); + }; + + login = async (req: Request, res: Response) => { + try { + const { ename, session, w3id, signature } = req.body; + + if (!ename) { + return res.status(400).json({ error: "ename is required" }); + } + + if (!session) { + return res.status(400).json({ error: "session is required" }); + } + + // Only find existing users - don't create new ones during auth + const user = await this.userService.findUser(ename); + + if (!user) { + // User doesn't exist - they need to be created via webhook first + return res.status(404).json({ + error: "User not found", + message: "User must be created via eVault webhook before authentication" + }); + } + + const token = signToken({ userId: user.id }); + + const data = { + user: { + id: user.id, + ename: user.ename, + name: user.name, + handle: user.handle, + description: user.description, + avatarUrl: user.avatarUrl, + bannerUrl: user.bannerUrl, + isVerified: user.isVerified, + isPrivate: user.isPrivate, + email: user.email, + emailVerified: user.emailVerified, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }, + token, + }; + this.eventEmitter.emit(session, data); + res.status(200).send(); + } catch (error) { + console.error("Error during login:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} + diff --git a/platforms/eCurrency-api/src/controllers/CurrencyController.ts b/platforms/eCurrency-api/src/controllers/CurrencyController.ts new file mode 100644 index 00000000..5b33c102 --- /dev/null +++ b/platforms/eCurrency-api/src/controllers/CurrencyController.ts @@ -0,0 +1,148 @@ +import { Request, Response } from "express"; +import { CurrencyService } from "../services/CurrencyService"; +import { AccountType } from "../database/entities/Ledger"; + +export class CurrencyController { + private currencyService: CurrencyService; + + constructor() { + this.currencyService = new CurrencyService(); + } + + createCurrency = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { name, description, groupId, allowNegative } = req.body; + + if (!name || !groupId) { + return res.status(400).json({ error: "Name and groupId are required" }); + } + + const currency = await this.currencyService.createCurrency( + name, + groupId, + req.user.id, + allowNegative || false, + description + ); + + res.status(201).json({ + id: currency.id, + name: currency.name, + description: currency.description, + ename: currency.ename, + groupId: currency.groupId, + allowNegative: currency.allowNegative, + createdBy: currency.createdBy, + createdAt: currency.createdAt, + updatedAt: currency.updatedAt, + }); + } catch (error: any) { + console.error("Error creating currency:", error); + if (error.message.includes("Only group admins")) { + return res.status(403).json({ error: error.message }); + } + res.status(500).json({ error: "Internal server error" }); + } + }; + + getAllCurrencies = async (req: Request, res: Response) => { + try { + const currencies = await this.currencyService.getAllCurrencies(); + res.json(currencies.map(currency => ({ + id: currency.id, + name: currency.name, + ename: currency.ename, + groupId: currency.groupId, + allowNegative: currency.allowNegative, + createdBy: currency.createdBy, + createdAt: currency.createdAt, + updatedAt: currency.updatedAt, + }))); + } catch (error) { + console.error("Error getting currencies:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + getCurrencyById = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const currency = await this.currencyService.getCurrencyById(id); + + if (!currency) { + return res.status(404).json({ error: "Currency not found" }); + } + + res.json({ + id: currency.id, + name: currency.name, + description: currency.description, + ename: currency.ename, + groupId: currency.groupId, + allowNegative: currency.allowNegative, + createdBy: currency.createdBy, + createdAt: currency.createdAt, + updatedAt: currency.updatedAt, + }); + } catch (error) { + console.error("Error getting currency:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + getCurrenciesByGroup = async (req: Request, res: Response) => { + try { + const { groupId } = req.params; + const currencies = await this.currencyService.getCurrenciesByGroup(groupId); + res.json(currencies.map(currency => ({ + id: currency.id, + name: currency.name, + description: currency.description, + ename: currency.ename, + groupId: currency.groupId, + allowNegative: currency.allowNegative, + createdBy: currency.createdBy, + createdAt: currency.createdAt, + updatedAt: currency.updatedAt, + }))); + } catch (error) { + console.error("Error getting currencies by group:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + mintCurrency = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const { amount, description } = req.body; + + if (!amount) { + return res.status(400).json({ error: "amount is required" }); + } + + await this.currencyService.mintCurrency( + id, + amount, + description, + req.user.id + ); + + res.status(200).json({ message: "Currency minted successfully" }); + } catch (error: any) { + console.error("Error minting currency:", error); + if (error.message.includes("Only group admins") || error.message.includes("not found") || error.message.includes("must be positive")) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Internal server error" }); + } + }; +} + diff --git a/platforms/eCurrency-api/src/controllers/GroupController.ts b/platforms/eCurrency-api/src/controllers/GroupController.ts new file mode 100644 index 00000000..ea7fccdc --- /dev/null +++ b/platforms/eCurrency-api/src/controllers/GroupController.ts @@ -0,0 +1,75 @@ +import { Request, Response } from "express"; +import { GroupService } from "../services/GroupService"; + +export class GroupController { + private groupService: GroupService; + + constructor() { + this.groupService = new GroupService(); + } + + search = async (req: Request, res: Response) => { + try { + const { q, limit } = req.query; + + if (!q || typeof q !== "string") { + return res.status(400).json({ error: "Query parameter 'q' is required" }); + } + + let limitNum = 10; + if (typeof limit === "string") { + const parsed = parseInt(limit, 10); + if (!Number.isNaN(parsed) && parsed > 0 && parsed <= 100) { + limitNum = parsed; + } + } + + const groups = await this.groupService.searchGroups(q, limitNum); + + res.json(groups.map(group => ({ + id: group.id, + name: group.name, + ename: group.ename, + description: group.description, + charter: group.charter, + createdAt: group.createdAt, + updatedAt: group.updatedAt, + }))); + } catch (error) { + console.error("Error searching groups:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + getUserGroups = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const groups = await this.groupService.getUserGroups(req.user.id); + + // Check which groups the user is admin of + const groupsWithAdminStatus = await Promise.all( + groups.map(async (group) => { + const isAdmin = await this.groupService.isGroupAdmin(group.id, req.user!.id); + return { + id: group.id, + name: group.name, + ename: group.ename, + description: group.description, + isAdmin, + createdAt: group.createdAt, + updatedAt: group.updatedAt, + }; + }) + ); + + res.json(groupsWithAdminStatus); + } catch (error) { + console.error("Error getting user groups:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} + diff --git a/platforms/eCurrency-api/src/controllers/LedgerController.ts b/platforms/eCurrency-api/src/controllers/LedgerController.ts new file mode 100644 index 00000000..70498bc9 --- /dev/null +++ b/platforms/eCurrency-api/src/controllers/LedgerController.ts @@ -0,0 +1,357 @@ +import { Request, Response } from "express"; +import { LedgerService } from "../services/LedgerService"; +import { AccountType } from "../database/entities/Ledger"; + +export class LedgerController { + private ledgerService: LedgerService; + + constructor() { + this.ledgerService = new LedgerService(); + } + + getBalance = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { currencyId, accountType, accountId } = req.query; + + // Support account context (user or group) + const finalAccountId = accountId ? (accountId as string) : req.user.id; + const finalAccountType = accountType ? (accountType as AccountType) : AccountType.USER; + + if (!Object.values(AccountType).includes(finalAccountType)) { + return res.status(400).json({ error: "Invalid accountType" }); + } + + if (currencyId) { + // Get balance for specific currency + const balance = await this.ledgerService.getAccountBalance( + currencyId as string, + finalAccountId, + finalAccountType + ); + res.json({ currencyId, balance }); + } else { + // Get all balances for account + const balances = await this.ledgerService.getAllBalancesForAccount( + finalAccountId, + finalAccountType + ); + res.json(balances.map(b => ({ + currency: { + id: b.currency.id, + name: b.currency.name, + ename: b.currency.ename, + }, + balance: b.balance, + }))); + } + } catch (error) { + console.error("Error getting balance:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + getBalanceByCurrencyId = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { currencyId } = req.params; + const balance = await this.ledgerService.getAccountBalance( + currencyId, + req.user.id, + AccountType.USER + ); + res.json({ currencyId, balance }); + } catch (error) { + console.error("Error getting balance:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + transfer = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { currencyId, fromAccountId, fromAccountType, toAccountId, toAccountType, amount, description } = req.body; + + if (!currencyId || !toAccountId || !toAccountType || !amount) { + return res.status(400).json({ error: "currencyId, toAccountId, toAccountType, and amount are required" }); + } + + if (!Object.values(AccountType).includes(toAccountType)) { + return res.status(400).json({ error: "Invalid toAccountType" }); + } + + if (amount <= 0) { + return res.status(400).json({ error: "Amount must be positive" }); + } + + // Use provided fromAccount or default to user + const finalFromAccountId = fromAccountId || req.user.id; + const finalFromAccountType = fromAccountType || AccountType.USER; + + if (!Object.values(AccountType).includes(finalFromAccountType)) { + return res.status(400).json({ error: "Invalid fromAccountType" }); + } + + // Verify user has permission to transfer from this account + if (finalFromAccountType === AccountType.GROUP) { + // TODO: Verify user is admin of the group + } else if (finalFromAccountId !== req.user.id) { + return res.status(403).json({ error: "You can only transfer from your own account" }); + } + + const result = await this.ledgerService.transfer( + currencyId, + finalFromAccountId, + finalFromAccountType, + toAccountId, + toAccountType, + amount, + description + ); + + res.json({ + message: "Transfer successful", + debit: { + id: result.debit.id, + amount: result.debit.amount, + balance: result.debit.balance, + createdAt: result.debit.createdAt, + }, + credit: { + id: result.credit.id, + amount: result.credit.amount, + balance: result.credit.balance, + createdAt: result.credit.createdAt, + }, + }); + } catch (error: any) { + console.error("Error transferring:", error); + if (error.message.includes("Insufficient balance") || error.message.includes("not found")) { + return res.status(400).json({ error: error.message }); + } + res.status(500).json({ error: "Internal server error" }); + } + }; + + getHistory = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + // Check if currencyId is in route params (from /history/:currencyId) + const currencyIdFromParams = (req.params as any).currencyId; + const { currencyId: currencyIdFromQuery, accountType, accountId, limit, offset } = req.query; + const currencyId = currencyIdFromParams || (currencyIdFromQuery as string | undefined); + const limitNum = limit ? parseInt(limit as string) : 50; + const offsetNum = offset ? parseInt(offset as string) : 0; + + // If accountType and accountId are provided, use them (for group accounts) + const finalAccountId = accountId ? (accountId as string) : req.user.id; + const finalAccountType = accountType ? (accountType as AccountType) : AccountType.USER; + + const history = await this.ledgerService.getTransactionHistory( + currencyId, + finalAccountId, + finalAccountType, + limitNum, + offsetNum + ); + + // Enrich transactions with sender/receiver info + const enrichedHistory = await Promise.all(history.map(async (entry) => { + const accountInfo = await this.ledgerService.getAccountInfo( + entry.accountId, + entry.accountType + ); + + // Get sender and receiver info from stored fields + let sender = null; + let receiver = null; + + if (entry.senderAccountId && entry.senderAccountType) { + sender = await this.ledgerService.getAccountInfo( + entry.senderAccountId, + entry.senderAccountType + ); + } + + if (entry.receiverAccountId && entry.receiverAccountType) { + receiver = await this.ledgerService.getAccountInfo( + entry.receiverAccountId, + entry.receiverAccountType + ); + } + + return { + id: entry.id, + currencyId: entry.currencyId, + accountId: entry.accountId, + accountType: entry.accountType, + amount: entry.amount, + type: entry.type, + description: entry.description, + balance: entry.balance, + createdAt: entry.createdAt, + sender: sender ? { + name: (sender as any).name || (sender as any).handle, + ename: (sender as any).ename, + } : null, + receiver: receiver ? { + name: (receiver as any).name || (receiver as any).handle, + ename: (receiver as any).ename, + } : null, + }; + })); + + res.json(enrichedHistory); + } catch (error) { + console.error("Error getting history:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + getTransactionById = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { id } = req.params; + const transaction = await this.ledgerService.getTransactionById(id); + + if (!transaction) { + return res.status(404).json({ error: "Transaction not found" }); + } + + // Get account info for this transaction + const accountInfo = await this.ledgerService.getAccountInfo( + transaction.accountId, + transaction.accountType + ); + + // Get sender and receiver info from stored fields + let sender = null; + let receiver = null; + + if (transaction.senderAccountId && transaction.senderAccountType) { + sender = await this.ledgerService.getAccountInfo( + transaction.senderAccountId, + transaction.senderAccountType + ); + } + + if (transaction.receiverAccountId && transaction.receiverAccountType) { + receiver = await this.ledgerService.getAccountInfo( + transaction.receiverAccountId, + transaction.receiverAccountType + ); + } + + res.json({ + id: transaction.id, + currencyId: transaction.currencyId, + currency: transaction.currency, + accountId: transaction.accountId, + accountType: transaction.accountType, + amount: transaction.amount, + type: transaction.type, + description: transaction.description, + balance: transaction.balance, + createdAt: transaction.createdAt, + sender: sender ? { + id: sender.id, + name: (sender as any).name || (sender as any).handle, + ename: (sender as any).ename, + } : null, + receiver: receiver ? { + id: receiver.id, + name: (receiver as any).name || (receiver as any).handle, + ename: (receiver as any).ename, + } : null, + }); + } catch (error) { + console.error("Error getting transaction:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + initializeAccount = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { currencyId } = req.body; + + if (!currencyId) { + return res.status(400).json({ error: "currencyId is required" }); + } + + await this.ledgerService.initializeAccount( + currencyId, + req.user.id, + AccountType.USER + ); + + res.json({ message: "Account initialized successfully" }); + } catch (error) { + console.error("Error initializing account:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + getAccountDetails = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { currencyId } = req.params; + const { accountType, accountId } = req.query; + + const finalAccountId = accountId ? (accountId as string) : req.user.id; + const finalAccountType = accountType ? (accountType as AccountType) : AccountType.USER; + + const balance = await this.ledgerService.getAccountBalance( + currencyId, + finalAccountId, + finalAccountType + ); + + const accountInfo = await this.ledgerService.getAccountInfo( + finalAccountId, + finalAccountType + ); + + // Account address is always the eName + const accountAddress = accountInfo ? ((accountInfo as any).ename || null) : null; + + res.json({ + currencyId, + accountId: finalAccountId, + accountType: finalAccountType, + balance, + accountInfo: accountInfo ? { + id: accountInfo.id, + name: (accountInfo as any).name || (accountInfo as any).handle, + ename: (accountInfo as any).ename, + } : null, + accountAddress, // This is the eName used for transfers + }); + } catch (error) { + console.error("Error getting account details:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} + diff --git a/platforms/eCurrency-api/src/controllers/UserController.ts b/platforms/eCurrency-api/src/controllers/UserController.ts new file mode 100644 index 00000000..8c92e93d --- /dev/null +++ b/platforms/eCurrency-api/src/controllers/UserController.ts @@ -0,0 +1,141 @@ +import { Request, Response } from "express"; +import { UserService } from "../services/UserService"; + +export class UserController { + private userService: UserService; + + constructor() { + this.userService = new UserService(); + } + + currentUser = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const user = await this.userService.getUserById(req.user.id); + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + res.json({ + id: user.id, + ename: user.ename, + name: user.name, + handle: user.handle, + description: user.description, + avatarUrl: user.avatarUrl, + bannerUrl: user.bannerUrl, + isVerified: user.isVerified, + isPrivate: user.isPrivate, + email: user.email, + emailVerified: user.emailVerified, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }); + } catch (error) { + console.error("Error getting current user:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + getProfileById = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const user = await this.userService.getUserById(id); + + if (!user) { + return res.status(404).json({ error: "User not found" }); + } + + res.json({ + id: user.id, + ename: user.ename, + name: user.name, + handle: user.handle, + description: user.description, + avatarUrl: user.avatarUrl, + bannerUrl: user.bannerUrl, + isVerified: user.isVerified, + isPrivate: user.isPrivate, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }); + } catch (error) { + console.error("Error getting user profile:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + search = async (req: Request, res: Response) => { + try { + const { q, limit } = req.query; + + if (!q || typeof q !== "string") { + return res.status(400).json({ error: "Query parameter 'q' is required" }); + } + + const limitNum = limit ? parseInt(limit as string) : 10; + const users = await this.userService.searchUsers(q, limitNum); + + res.json(users.map(user => ({ + id: user.id, + ename: user.ename, + name: user.name, + handle: user.handle, + description: user.description, + avatarUrl: user.avatarUrl, + bannerUrl: user.bannerUrl, + isVerified: user.isVerified, + isPrivate: user.isPrivate, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }))); + } catch (error) { + console.error("Error searching users:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + updateProfile = async (req: Request, res: Response) => { + try { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + + const { name, handle, description, avatarUrl, bannerUrl, isPrivate } = req.body; + + const updateData = { + name, + handle, + description, + avatarUrl, + bannerUrl, + isPrivate, + }; + + const updatedUser = await this.userService.updateUser(req.user.id, updateData); + + res.json({ + id: updatedUser.id, + ename: updatedUser.ename, + name: updatedUser.name, + handle: updatedUser.handle, + description: updatedUser.description, + avatarUrl: updatedUser.avatarUrl, + bannerUrl: updatedUser.bannerUrl, + isVerified: updatedUser.isVerified, + isPrivate: updatedUser.isPrivate, + email: updatedUser.email, + emailVerified: updatedUser.emailVerified, + createdAt: updatedUser.createdAt, + updatedAt: updatedUser.updatedAt, + }); + } catch (error) { + console.error("Error updating profile:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} + diff --git a/platforms/eCurrency-api/src/controllers/WebhookController.ts b/platforms/eCurrency-api/src/controllers/WebhookController.ts new file mode 100644 index 00000000..5b80e900 --- /dev/null +++ b/platforms/eCurrency-api/src/controllers/WebhookController.ts @@ -0,0 +1,191 @@ +import { Request, Response } from "express"; +import { UserService } from "../services/UserService"; +import { GroupService } from "../services/GroupService"; +import { adapter } from "../web3adapter/watchers/subscriber"; +import { User } from "../database/entities/User"; +import axios from "axios"; + +export class WebhookController { + userService: UserService; + groupService: GroupService; + adapter: typeof adapter; + + constructor() { + this.userService = new UserService(); + this.groupService = new GroupService(); + this.adapter = adapter; + } + + handleWebhook = async (req: Request, res: Response) => { + const globalId = req.body.id; + const schemaId = req.body.schemaId; + + try { + // Forward to ANCHR if configured + if (process.env.ANCHR_URL) { + try { + await axios.post( + new URL("ecurrency-api", process.env.ANCHR_URL).toString(), + req.body + ); + } catch (error) { + // Don't fail the webhook if ANCHR forwarding fails + } + } + + const mapping = Object.values(this.adapter.mapping).find( + (m: any) => m.schemaId === schemaId + ) as any; + + if (!mapping) { + throw new Error("No mapping found"); + } + + // Check if this globalId is already locked (being processed) + if (this.adapter.lockedIds.includes(globalId)) { + return res.status(200).send(); + } + + this.adapter.addToLockedIds(globalId); + + const local = await this.adapter.fromGlobal({ + data: req.body.data, + mapping, + }); + + let localId = await this.adapter.mappingDb.getLocalId(globalId); + let finalLocalId = localId; + + if (mapping.tableName === "users") { + if (localId) { + const user = await this.userService.getUserById(localId); + if (!user) throw new Error(); + + // Only update simple properties, not relationships + const updateData: Partial = { + name: req.body.data.displayName, + handle: local.data.username as string | undefined, + description: local.data.bio as string | undefined, + avatarUrl: local.data.avatarUrl as string | undefined, + bannerUrl: local.data.bannerUrl as string | undefined, + isVerified: local.data.isVerified as boolean | undefined, + isPrivate: local.data.isPrivate as boolean | undefined, + email: local.data.email as string | undefined, + emailVerified: local.data.emailVerified as boolean | undefined, + }; + + await this.userService.updateUser(user.id, updateData); + await this.adapter.mappingDb.storeMapping({ + localId: user.id, + globalId: req.body.id, + }); + this.adapter.addToLockedIds(user.id); + this.adapter.addToLockedIds(globalId); + finalLocalId = user.id; + } else { + const user = await this.userService.createBlankUser(req.body.w3id); + + // Update user with webhook data + await this.userService.updateUser(user.id, { + name: req.body.data.displayName, + handle: req.body.data.username, + description: req.body.data.bio, + avatarUrl: req.body.data.avatarUrl, + bannerUrl: req.body.data.bannerUrl, + isVerified: req.body.data.isVerified, + isPrivate: req.body.data.isPrivate, + }); + + await this.adapter.mappingDb.storeMapping({ + localId: user.id, + globalId: req.body.id, + }); + this.adapter.addToLockedIds(user.id); + this.adapter.addToLockedIds(globalId); + finalLocalId = user.id; + } + } else if (mapping.tableName === "groups") { + let participants: User[] = []; + if ( + local.data.participants && + Array.isArray(local.data.participants) + ) { + const participantPromises = local.data.participants.map( + async (ref: string) => { + if (ref && typeof ref === "string") { + const userId = ref.split("(")[1].split(")")[0]; + return await this.userService.getUserById(userId); + } + return null; + } + ); + + participants = ( + await Promise.all(participantPromises) + ).filter((user: User | null): user is User => user !== null); + } + + let adminIds = local?.data?.admins as string[] ?? [] + adminIds = adminIds.map((a) => a.includes("(") ? a.split("(")[1].split(")")[0]: a) + + if (localId) { + const group = await this.groupService.getGroupById(localId); + if (!group) { + return res.status(500).send(); + } + + group.name = local.data.name as string; + group.description = local.data.description as string; + group.owner = local.data.owner as string; + group.admins = adminIds.map(id => ({ id } as User)); + group.participants = participants; + group.charter = local.data.charter as string; + group.ename = local.data.ename as string + + this.adapter.addToLockedIds(localId); + await this.groupService.groupRepository.save(group); + finalLocalId = group.id; + } else { + // Check if a group with the same name and description already exists + // This prevents duplicate group creation from junction table webhooks + const existingGroup = await this.groupService.groupRepository.findOne({ + where: { + name: local.data.name as string, + description: local.data.description as string + } + }); + + if (existingGroup) { + this.adapter.addToLockedIds(existingGroup.id); + await this.adapter.mappingDb.storeMapping({ + localId: existingGroup.id, + globalId: req.body.id, + }); + finalLocalId = existingGroup.id; + } else { + const group = await this.groupService.createGroup( + local.data.name as string, + local.data.description as string, + local.data.owner as string, + adminIds, + participants.map(p => p.id), + local.data.charter as string | undefined, + ); + this.adapter.addToLockedIds(group.id); + await this.adapter.mappingDb.storeMapping({ + localId: group.id, + globalId: req.body.id, + }); + finalLocalId = group.id; + } + } + } + + res.status(200).send(); + } catch (e) { + console.error("Webhook error:", e); + res.status(500).send(); + } + }; +} + diff --git a/platforms/eCurrency-api/src/database/data-source.ts b/platforms/eCurrency-api/src/database/data-source.ts new file mode 100644 index 00000000..19a609b3 --- /dev/null +++ b/platforms/eCurrency-api/src/database/data-source.ts @@ -0,0 +1,26 @@ +import "reflect-metadata"; +import path from "path"; +import { config } from "dotenv"; +import { DataSource, type DataSourceOptions } from "typeorm"; +import { User } from "./entities/User"; +import { Group } from "./entities/Group"; +import { Currency } from "./entities/Currency"; +import { Ledger } from "./entities/Ledger"; +import { PostgresSubscriber } from "../web3adapter/watchers/subscriber"; + +// Use absolute path for better CLI compatibility +const envPath = path.resolve(__dirname, "../../../../.env"); +config({ path: envPath }); + +export const dataSourceOptions: DataSourceOptions = { + type: "postgres", + url: process.env.ECURRENCY_DATABASE_URL, + synchronize: false, // Auto-sync in development + entities: [User, Group, Currency, Ledger], + migrations: [path.join(__dirname, "migrations", "*.ts")], + logging: process.env.NODE_ENV === "development", + subscribers: [PostgresSubscriber], +}; + +export const AppDataSource = new DataSource(dataSourceOptions); + diff --git a/platforms/eCurrency-api/src/database/entities/Currency.ts b/platforms/eCurrency-api/src/database/entities/Currency.ts new file mode 100644 index 00000000..385f786a --- /dev/null +++ b/platforms/eCurrency-api/src/database/entities/Currency.ts @@ -0,0 +1,55 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + OneToMany, +} from "typeorm"; +import { Group } from "./Group"; +import { User } from "./User"; +import { Ledger } from "./Ledger"; + +@Entity("currencies") +export class Currency { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + name!: string; + + @Column({ nullable: true }) + description!: string; + + @Column({ unique: true }) + ename!: string; // UUID prefixed with @ + + @Column() + groupId!: string; + + @ManyToOne(() => Group) + @JoinColumn({ name: "groupId" }) + group!: Group; + + @Column({ default: false }) + allowNegative!: boolean; + + @Column() + createdBy!: string; + + @ManyToOne(() => User) + @JoinColumn({ name: "createdBy" }) + creator!: User; + + @OneToMany(() => Ledger, (ledger) => ledger.currency) + ledgerEntries!: Ledger[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + diff --git a/platforms/eCurrency-api/src/database/entities/Group.ts b/platforms/eCurrency-api/src/database/entities/Group.ts new file mode 100644 index 00000000..98eaaa05 --- /dev/null +++ b/platforms/eCurrency-api/src/database/entities/Group.ts @@ -0,0 +1,77 @@ +import { + Entity, + CreateDateColumn, + UpdateDateColumn, + PrimaryGeneratedColumn, + Column, + ManyToMany, + JoinTable, +} from "typeorm"; +import { User } from "./User"; + +@Entity() +export class Group { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ nullable: true }) + name!: string; + + @Column({ nullable: true }) + description!: string; + + @Column({ nullable: true }) + owner!: string; + + @Column({ type: "text", nullable: true }) + charter!: string; // Markdown content for the group charter + + @Column({ default: false }) + isPrivate!: boolean; + + @Column({ default: "public" }) + visibility!: "public" | "private" | "restricted"; + + @ManyToMany(() => User) + @JoinTable({ + name: "group_members", + joinColumn: { name: "group_id", referencedColumnName: "id" }, + inverseJoinColumn: { name: "user_id", referencedColumnName: "id" } + }) + members!: User[]; + + @ManyToMany(() => User) + @JoinTable({ + name: "group_admins", + joinColumn: { name: "group_id", referencedColumnName: "id" }, + inverseJoinColumn: { name: "user_id", referencedColumnName: "id" } + }) + admins!: User[]; + + @ManyToMany(() => User) + @JoinTable({ + name: "group_participants", + joinColumn: { name: "group_id", referencedColumnName: "id" }, + inverseJoinColumn: { name: "user_id", referencedColumnName: "id" } + }) + participants!: User[]; + + @Column({ nullable: true}) + ename!: string + + @Column({ nullable: true }) + avatarUrl!: string; + + @Column({ nullable: true }) + bannerUrl!: string; + + @Column({ type: "json", nullable: true }) + originalMatchParticipants!: string[]; // Store user IDs from the original match + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} + diff --git a/platforms/eCurrency-api/src/database/entities/Ledger.ts b/platforms/eCurrency-api/src/database/entities/Ledger.ts new file mode 100644 index 00000000..67b3a6d3 --- /dev/null +++ b/platforms/eCurrency-api/src/database/entities/Ledger.ts @@ -0,0 +1,82 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from "typeorm"; +import { Currency } from "./Currency"; + +export enum AccountType { + USER = "user", + GROUP = "group", +} + +export enum LedgerType { + DEBIT = "debit", + CREDIT = "credit", +} + +@Entity("ledger") +@Index(["currencyId", "accountId", "accountType"]) +export class Ledger { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + currencyId!: string; + + @ManyToOne(() => Currency) + @JoinColumn({ name: "currencyId" }) + currency!: Currency; + + @Column() + accountId!: string; // Can be User or Group ID + + @Column({ + type: "enum", + enum: AccountType, + }) + accountType!: AccountType; + + @Column("decimal", { precision: 18, scale: 2 }) + amount!: number; // Positive or negative + + @Column({ + type: "enum", + enum: LedgerType, + }) + type!: LedgerType; + + @Column({ nullable: true }) + description!: string; + + @Column({ nullable: true }) + senderAccountId!: string; // The account that sent the money + + @Column({ + type: "enum", + enum: AccountType, + nullable: true, + }) + senderAccountType!: AccountType | null; + + @Column({ nullable: true }) + receiverAccountId!: string; // The account that received the money + + @Column({ + type: "enum", + enum: AccountType, + nullable: true, + }) + receiverAccountType!: AccountType | null; + + @Column("decimal", { precision: 18, scale: 2 }) + balance!: number; // Running balance after this entry + + @CreateDateColumn() + createdAt!: Date; +} + diff --git a/platforms/eCurrency-api/src/database/entities/User.ts b/platforms/eCurrency-api/src/database/entities/User.ts new file mode 100644 index 00000000..b9830f47 --- /dev/null +++ b/platforms/eCurrency-api/src/database/entities/User.ts @@ -0,0 +1,71 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToMany, + JoinTable, +} from "typeorm"; + +@Entity("users") +export class User { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ nullable: true }) + handle!: string; + + @Column({ nullable: true }) + name!: string; + + @Column({ nullable: true }) + description!: string; + + @Column({ nullable: true }) + avatarUrl!: string; + + @Column({ nullable: true }) + bannerUrl!: string; + + @Column({ nullable: true }) + ename!: string; + + @Column({ default: false }) + isVerified!: boolean; + + @Column({ default: false }) + isPrivate!: boolean; + + @Column("varchar", { name: "email", length: 255, nullable: true }) + email!: string; + + @Column("boolean", { name: "emailVerified", default: false }) + emailVerified!: boolean; + + @ManyToMany(() => User) + @JoinTable({ + name: "user_followers", + joinColumn: { name: "user_id", referencedColumnName: "id" }, + inverseJoinColumn: { name: "follower_id", referencedColumnName: "id" }, + }) + followers!: User[]; + + @ManyToMany(() => User) + @JoinTable({ + name: "user_following", + joinColumn: { name: "user_id", referencedColumnName: "id" }, + inverseJoinColumn: { name: "following_id", referencedColumnName: "id" }, + }) + following!: User[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; + + @Column({ default: false }) + isArchived!: boolean; +} + diff --git a/platforms/eCurrency-api/src/database/migrations/1765035161362-migration.ts b/platforms/eCurrency-api/src/database/migrations/1765035161362-migration.ts new file mode 100644 index 00000000..d4607d69 --- /dev/null +++ b/platforms/eCurrency-api/src/database/migrations/1765035161362-migration.ts @@ -0,0 +1,82 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1765035161362 implements MigrationInterface { + name = 'Migration1765035161362' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "handle" character varying, "name" character varying, "description" character varying, "avatarUrl" character varying, "bannerUrl" character varying, "ename" character varying, "isVerified" boolean NOT NULL DEFAULT false, "isPrivate" boolean NOT NULL DEFAULT false, "email" character varying(255), "emailVerified" boolean NOT NULL DEFAULT false, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), "isArchived" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "group" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying, "description" character varying, "owner" character varying, "charter" text, "isPrivate" boolean NOT NULL DEFAULT false, "visibility" character varying NOT NULL DEFAULT 'public', "ename" character varying, "avatarUrl" character varying, "bannerUrl" character varying, "originalMatchParticipants" json, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_256aa0fda9b1de1a73ee0b7106b" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TYPE "public"."ledger_accounttype_enum" AS ENUM('user', 'group')`); + await queryRunner.query(`CREATE TYPE "public"."ledger_type_enum" AS ENUM('debit', 'credit')`); + await queryRunner.query(`CREATE TABLE "ledger" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "currencyId" uuid NOT NULL, "accountId" character varying NOT NULL, "accountType" "public"."ledger_accounttype_enum" NOT NULL, "amount" numeric(18,2) NOT NULL, "type" "public"."ledger_type_enum" NOT NULL, "description" character varying, "balance" numeric(18,2) NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_7a322e9157e5f42a16750ba2a20" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_2f972ad154e26f8b4bb6584237" ON "ledger" ("currencyId", "accountId", "accountType") `); + await queryRunner.query(`CREATE TABLE "currencies" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "ename" character varying NOT NULL, "groupId" uuid NOT NULL, "allowNegative" boolean NOT NULL DEFAULT false, "createdBy" uuid NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_0dbe684cc54a3dd2cf652272944" UNIQUE ("ename"), CONSTRAINT "PK_d528c54860c4182db13548e08c4" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "user_followers" ("user_id" uuid NOT NULL, "follower_id" uuid NOT NULL, CONSTRAINT "PK_d7b47e785d7dbc74b2f22f30045" PRIMARY KEY ("user_id", "follower_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_a59d62cda8101214445e295cdc" ON "user_followers" ("user_id") `); + await queryRunner.query(`CREATE INDEX "IDX_da722d93356ae3119d6be40d98" ON "user_followers" ("follower_id") `); + await queryRunner.query(`CREATE TABLE "user_following" ("user_id" uuid NOT NULL, "following_id" uuid NOT NULL, CONSTRAINT "PK_5d7e9a83ee6f9b806d569068a30" PRIMARY KEY ("user_id", "following_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_a28a2c27629ac06a41720d01c3" ON "user_following" ("user_id") `); + await queryRunner.query(`CREATE INDEX "IDX_94e1183284db3e697031eb7775" ON "user_following" ("following_id") `); + await queryRunner.query(`CREATE TABLE "group_members" ("group_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_f5939ee0ad233ad35e03f5c65c1" PRIMARY KEY ("group_id", "user_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_2c840df5db52dc6b4a1b0b69c6" ON "group_members" ("group_id") `); + await queryRunner.query(`CREATE INDEX "IDX_20a555b299f75843aa53ff8b0e" ON "group_members" ("user_id") `); + await queryRunner.query(`CREATE TABLE "group_admins" ("group_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_a63ab4ea34529a63cdd55eed88d" PRIMARY KEY ("group_id", "user_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_0ecd81bfecc31d4f804ece20ef" ON "group_admins" ("group_id") `); + await queryRunner.query(`CREATE INDEX "IDX_29bb650b1c5b1639dfb089f39a" ON "group_admins" ("user_id") `); + await queryRunner.query(`CREATE TABLE "group_participants" ("group_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_92021b85af6470d6b405e12f312" PRIMARY KEY ("group_id", "user_id"))`); + await queryRunner.query(`CREATE INDEX "IDX_e61f897ae7a7df4b56595adaae" ON "group_participants" ("group_id") `); + await queryRunner.query(`CREATE INDEX "IDX_bb1d0ab0d82e0a62fa55b7e841" ON "group_participants" ("user_id") `); + await queryRunner.query(`ALTER TABLE "ledger" ADD CONSTRAINT "FK_f5647f02aca88fd9579ff1b6df5" FOREIGN KEY ("currencyId") REFERENCES "currencies"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "currencies" ADD CONSTRAINT "FK_5569fd280cb4482a3abc22a1306" FOREIGN KEY ("groupId") REFERENCES "group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "currencies" ADD CONSTRAINT "FK_4da6e54889b58f6a808d09f55f1" FOREIGN KEY ("createdBy") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_followers" ADD CONSTRAINT "FK_a59d62cda8101214445e295cdc8" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "user_followers" ADD CONSTRAINT "FK_da722d93356ae3119d6be40d988" FOREIGN KEY ("follower_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "user_following" ADD CONSTRAINT "FK_a28a2c27629ac06a41720d01c30" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "user_following" ADD CONSTRAINT "FK_94e1183284db3e697031eb7775d" FOREIGN KEY ("following_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_members" ADD CONSTRAINT "FK_2c840df5db52dc6b4a1b0b69c6e" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_members" ADD CONSTRAINT "FK_20a555b299f75843aa53ff8b0ee" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_admins" ADD CONSTRAINT "FK_0ecd81bfecc31d4f804ece20efc" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_admins" ADD CONSTRAINT "FK_29bb650b1c5b1639dfb089f39a7" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_participants" ADD CONSTRAINT "FK_e61f897ae7a7df4b56595adaae7" FOREIGN KEY ("group_id") REFERENCES "group"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "group_participants" ADD CONSTRAINT "FK_bb1d0ab0d82e0a62fa55b7e8411" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "group_participants" DROP CONSTRAINT "FK_bb1d0ab0d82e0a62fa55b7e8411"`); + await queryRunner.query(`ALTER TABLE "group_participants" DROP CONSTRAINT "FK_e61f897ae7a7df4b56595adaae7"`); + await queryRunner.query(`ALTER TABLE "group_admins" DROP CONSTRAINT "FK_29bb650b1c5b1639dfb089f39a7"`); + await queryRunner.query(`ALTER TABLE "group_admins" DROP CONSTRAINT "FK_0ecd81bfecc31d4f804ece20efc"`); + await queryRunner.query(`ALTER TABLE "group_members" DROP CONSTRAINT "FK_20a555b299f75843aa53ff8b0ee"`); + await queryRunner.query(`ALTER TABLE "group_members" DROP CONSTRAINT "FK_2c840df5db52dc6b4a1b0b69c6e"`); + await queryRunner.query(`ALTER TABLE "user_following" DROP CONSTRAINT "FK_94e1183284db3e697031eb7775d"`); + await queryRunner.query(`ALTER TABLE "user_following" DROP CONSTRAINT "FK_a28a2c27629ac06a41720d01c30"`); + await queryRunner.query(`ALTER TABLE "user_followers" DROP CONSTRAINT "FK_da722d93356ae3119d6be40d988"`); + await queryRunner.query(`ALTER TABLE "user_followers" DROP CONSTRAINT "FK_a59d62cda8101214445e295cdc8"`); + await queryRunner.query(`ALTER TABLE "currencies" DROP CONSTRAINT "FK_4da6e54889b58f6a808d09f55f1"`); + await queryRunner.query(`ALTER TABLE "currencies" DROP CONSTRAINT "FK_5569fd280cb4482a3abc22a1306"`); + await queryRunner.query(`ALTER TABLE "ledger" DROP CONSTRAINT "FK_f5647f02aca88fd9579ff1b6df5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_bb1d0ab0d82e0a62fa55b7e841"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e61f897ae7a7df4b56595adaae"`); + await queryRunner.query(`DROP TABLE "group_participants"`); + await queryRunner.query(`DROP INDEX "public"."IDX_29bb650b1c5b1639dfb089f39a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_0ecd81bfecc31d4f804ece20ef"`); + await queryRunner.query(`DROP TABLE "group_admins"`); + await queryRunner.query(`DROP INDEX "public"."IDX_20a555b299f75843aa53ff8b0e"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2c840df5db52dc6b4a1b0b69c6"`); + await queryRunner.query(`DROP TABLE "group_members"`); + await queryRunner.query(`DROP INDEX "public"."IDX_94e1183284db3e697031eb7775"`); + await queryRunner.query(`DROP INDEX "public"."IDX_a28a2c27629ac06a41720d01c3"`); + await queryRunner.query(`DROP TABLE "user_following"`); + await queryRunner.query(`DROP INDEX "public"."IDX_da722d93356ae3119d6be40d98"`); + await queryRunner.query(`DROP INDEX "public"."IDX_a59d62cda8101214445e295cdc"`); + await queryRunner.query(`DROP TABLE "user_followers"`); + await queryRunner.query(`DROP TABLE "currencies"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2f972ad154e26f8b4bb6584237"`); + await queryRunner.query(`DROP TABLE "ledger"`); + await queryRunner.query(`DROP TYPE "public"."ledger_type_enum"`); + await queryRunner.query(`DROP TYPE "public"."ledger_accounttype_enum"`); + await queryRunner.query(`DROP TABLE "group"`); + await queryRunner.query(`DROP TABLE "users"`); + } + +} diff --git a/platforms/eCurrency-api/src/database/migrations/1765039149572-migration.ts b/platforms/eCurrency-api/src/database/migrations/1765039149572-migration.ts new file mode 100644 index 00000000..24225716 --- /dev/null +++ b/platforms/eCurrency-api/src/database/migrations/1765039149572-migration.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1765039149572 implements MigrationInterface { + name = 'Migration1765039149572' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "currencies" ADD "description" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "currencies" DROP COLUMN "description"`); + } + +} diff --git a/platforms/eCurrency-api/src/database/migrations/1765044585971-migration.ts b/platforms/eCurrency-api/src/database/migrations/1765044585971-migration.ts new file mode 100644 index 00000000..b9cf4e4c --- /dev/null +++ b/platforms/eCurrency-api/src/database/migrations/1765044585971-migration.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1765044585971 implements MigrationInterface { + name = 'Migration1765044585971' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "ledger" ADD "senderAccountId" character varying`); + await queryRunner.query(`CREATE TYPE "public"."ledger_senderaccounttype_enum" AS ENUM('user', 'group')`); + await queryRunner.query(`ALTER TABLE "ledger" ADD "senderAccountType" "public"."ledger_senderaccounttype_enum"`); + await queryRunner.query(`ALTER TABLE "ledger" ADD "receiverAccountId" character varying`); + await queryRunner.query(`CREATE TYPE "public"."ledger_receiveraccounttype_enum" AS ENUM('user', 'group')`); + await queryRunner.query(`ALTER TABLE "ledger" ADD "receiverAccountType" "public"."ledger_receiveraccounttype_enum"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "ledger" DROP COLUMN "receiverAccountType"`); + await queryRunner.query(`DROP TYPE "public"."ledger_receiveraccounttype_enum"`); + await queryRunner.query(`ALTER TABLE "ledger" DROP COLUMN "receiverAccountId"`); + await queryRunner.query(`ALTER TABLE "ledger" DROP COLUMN "senderAccountType"`); + await queryRunner.query(`DROP TYPE "public"."ledger_senderaccounttype_enum"`); + await queryRunner.query(`ALTER TABLE "ledger" DROP COLUMN "senderAccountId"`); + } + +} diff --git a/platforms/eCurrency-api/src/index.ts b/platforms/eCurrency-api/src/index.ts new file mode 100644 index 00000000..5d049cab --- /dev/null +++ b/platforms/eCurrency-api/src/index.ts @@ -0,0 +1,112 @@ +import "reflect-metadata"; +import path from "path"; +import cors from "cors"; +import { config } from "dotenv"; +import express from "express"; +import "./types/express"; +import { AppDataSource } from "./database/data-source"; +import { UserController } from "./controllers/UserController"; +import { AuthController } from "./controllers/AuthController"; +import { WebhookController } from "./controllers/WebhookController"; +import { GroupController } from "./controllers/GroupController"; +import { CurrencyController } from "./controllers/CurrencyController"; +import { LedgerController } from "./controllers/LedgerController"; +import { authMiddleware, authGuard } from "./middleware/auth"; +import { adapter } from "./web3adapter/watchers/subscriber"; + +config({ path: path.resolve(__dirname, "../../../.env") }); + +const app = express(); +const port = process.env.PORT || 8989; + +// Initialize database connection and adapter +AppDataSource.initialize() + .then(async () => { + console.log("Database connection established"); + console.log("Web3 adapter initialized"); + }) + .catch((error: unknown) => { + console.error("Error during initialization:", error); + process.exit(1); + }); + +// Middleware +app.use( + cors({ + origin: "*", + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "X-Webhook-Signature", + "X-Webhook-Timestamp", + ], + credentials: true, + }), +); +app.use(express.json({ limit: "50mb" })); +app.use(express.urlencoded({ limit: "50mb", extended: true })); + +// Controllers +const userController = new UserController(); +const authController = new AuthController(); +const webhookController = new WebhookController(); +const groupController = new GroupController(); +const currencyController = new CurrencyController(); +const ledgerController = new LedgerController(); + +// Health check endpoint +app.get("/api/health", (req: express.Request, res: express.Response) => { + res.json({ + status: "ok", + timestamp: new Date().toISOString(), + services: { + database: AppDataSource.isInitialized ? "connected" : "disconnected", + web3adapter: "ready" + } + }); +}); + +// Public routes (no auth required) +app.get("/api/auth/offer", authController.getOffer); +app.post("/api/auth", authController.login); +app.get("/api/auth/sessions/:id", authController.sseStream); + +// Webhook route (no auth required) +app.post("/api/webhook", webhookController.handleWebhook); + +// Protected routes (auth required) +app.use(authMiddleware); // Apply auth middleware to all routes below + +// User routes +app.get("/api/users/me", authGuard, userController.currentUser); +app.get("/api/users/search", userController.search); +app.get("/api/users/:id", authGuard, userController.getProfileById); +app.patch("/api/users", authGuard, userController.updateProfile); + +// Group routes +app.get("/api/groups/search", groupController.search); +app.get("/api/groups/my", authGuard, groupController.getUserGroups); + +// Currency routes +app.post("/api/currencies", authGuard, currencyController.createCurrency); +app.get("/api/currencies", currencyController.getAllCurrencies); +app.get("/api/currencies/:id", currencyController.getCurrencyById); +app.get("/api/currencies/group/:groupId", currencyController.getCurrenciesByGroup); +app.post("/api/currencies/:id/mint", authGuard, currencyController.mintCurrency); + +// Ledger routes +app.get("/api/ledger/balance", authGuard, ledgerController.getBalance); +app.get("/api/ledger/balance/:currencyId", authGuard, ledgerController.getBalanceByCurrencyId); +app.post("/api/ledger/transfer", authGuard, ledgerController.transfer); +app.get("/api/ledger/history", authGuard, ledgerController.getHistory); +app.get("/api/ledger/history/:currencyId", authGuard, ledgerController.getHistory); +app.get("/api/ledger/transaction/:id", authGuard, ledgerController.getTransactionById); +app.get("/api/ledger/account-details/:currencyId", authGuard, ledgerController.getAccountDetails); +app.post("/api/ledger/initialize", authGuard, ledgerController.initializeAccount); + +// Start server +app.listen(port, () => { + console.log(`eCurrency API server running on port ${port}`); +}); + diff --git a/platforms/eCurrency-api/src/middleware/auth.ts b/platforms/eCurrency-api/src/middleware/auth.ts new file mode 100644 index 00000000..dcd6a7d6 --- /dev/null +++ b/platforms/eCurrency-api/src/middleware/auth.ts @@ -0,0 +1,42 @@ +import type { NextFunction, Request, Response } from "express"; +import { AppDataSource } from "../database/data-source"; +import { User } from "../database/entities/User"; +import { verifyToken, AuthTokenPayload } from "../utils/jwt"; + +export const authMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + try { + const authHeader = req.headers.authorization; + + if (!authHeader?.startsWith("Bearer ")) { + return next(); + } + + const token = authHeader.split(" ")[1]; + const decoded: AuthTokenPayload = verifyToken(token); + + const userRepository = AppDataSource.getRepository(User); + const user = await userRepository.findOneBy({ id: decoded.userId }); + + if (!user) { + return res.status(401).json({ error: "User not found" }); + } + + req.user = user; + next(); + } catch (error) { + console.error("Auth middleware error:", error); + res.status(401).json({ error: "Invalid token" }); + } +}; + +export const authGuard = (req: Request, res: Response, next: NextFunction) => { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + next(); +}; + diff --git a/platforms/eCurrency-api/src/services/CurrencyService.ts b/platforms/eCurrency-api/src/services/CurrencyService.ts new file mode 100644 index 00000000..afcb931e --- /dev/null +++ b/platforms/eCurrency-api/src/services/CurrencyService.ts @@ -0,0 +1,127 @@ +import { Repository } from "typeorm"; +import { v4 as uuidv4 } from "uuid"; +import { AppDataSource } from "../database/data-source"; +import { Currency } from "../database/entities/Currency"; +import { GroupService } from "./GroupService"; +import { LedgerService } from "./LedgerService"; +import { AccountType, LedgerType } from "../database/entities/Ledger"; + +export class CurrencyService { + currencyRepository: Repository; + groupService: GroupService; + ledgerService: LedgerService; + + constructor() { + this.currencyRepository = AppDataSource.getRepository(Currency); + this.groupService = new GroupService(); + this.ledgerService = new LedgerService(); + } + + async createCurrency( + name: string, + groupId: string, + createdBy: string, + allowNegative: boolean = false, + description?: string + ): Promise { + // Verify user is group admin + const isAdmin = await this.groupService.isGroupAdmin(groupId, createdBy); + if (!isAdmin) { + throw new Error("Only group admins can create currencies"); + } + + // Generate eName (UUID with @ prefix) + const ename = `@${uuidv4()}`; + + const currency = this.currencyRepository.create({ + name, + description, + ename, + groupId, + createdBy, + allowNegative, + }); + + const savedCurrency = await this.currencyRepository.save(currency); + + // Initialize ledger account for the group + await this.ledgerService.initializeAccount( + savedCurrency.id, + groupId, + AccountType.GROUP + ); + + return savedCurrency; + } + + async getCurrencyById(id: string): Promise { + return await this.currencyRepository.findOne({ + where: { id }, + relations: ["group", "creator"] + }); + } + + async getCurrencyByEname(ename: string): Promise { + const cleanEname = ename.startsWith('@') ? ename.slice(1) : ename; + return await this.currencyRepository.findOne({ + where: { ename: cleanEname }, + relations: ["group", "creator"] + }); + } + + async getCurrenciesByGroup(groupId: string): Promise { + return await this.currencyRepository.find({ + where: { groupId }, + relations: ["group", "creator"] + }); + } + + async getAllCurrencies(): Promise { + return await this.currencyRepository.find({ + relations: ["group", "creator"] + }); + } + + async mintCurrency( + currencyId: string, + amount: number, + description?: string, + mintedBy?: string + ): Promise { + // Verify user is group admin + const currency = await this.getCurrencyById(currencyId); + if (!currency) { + throw new Error("Currency not found"); + } + + if (mintedBy) { + const isAdmin = await this.groupService.isGroupAdmin(currency.groupId, mintedBy); + if (!isAdmin) { + throw new Error("Only group admins can mint currency"); + } + } + + if (amount <= 0) { + throw new Error("Mint amount must be positive"); + } + + // Always mint to the group account + // Initialize group account if it doesn't exist + await this.ledgerService.initializeAccount( + currencyId, + currency.groupId, + AccountType.GROUP + ); + + // Add credit entry to group account + await this.ledgerService.addLedgerEntry( + currencyId, + currency.groupId, + AccountType.GROUP, + amount, + LedgerType.CREDIT, + description || `Minted ${amount} ${currency.name}` + ); + } +} + diff --git a/platforms/eCurrency-api/src/services/GroupService.ts b/platforms/eCurrency-api/src/services/GroupService.ts new file mode 100644 index 00000000..663cb16d --- /dev/null +++ b/platforms/eCurrency-api/src/services/GroupService.ts @@ -0,0 +1,88 @@ +import { Repository, In } from "typeorm"; +import { AppDataSource } from "../database/data-source"; +import { Group } from "../database/entities/Group"; +import { User } from "../database/entities/User"; + +export class GroupService { + groupRepository: Repository; + userRepository: Repository; + + constructor() { + this.groupRepository = AppDataSource.getRepository(Group); + this.userRepository = AppDataSource.getRepository(User); + } + + async getGroupById(id: string): Promise { + return await this.groupRepository.findOne({ + where: { id }, + relations: ["members", "admins", "participants"] + }); + } + + async createGroup( + name: string, + description: string, + owner: string, + adminIds: string[] = [], + participantIds: string[] = [], + charter?: string + ): Promise { + const group = this.groupRepository.create({ + name, + description, + owner, + charter, + }); + + // Add admins + if (adminIds.length > 0) { + const admins = await this.userRepository.findBy({ id: In(adminIds) }); + group.admins = admins; + } + + // Add participants + if (participantIds.length > 0) { + const participants = await this.userRepository.findBy({ id: In(participantIds) }); + group.participants = participants; + } + + return await this.groupRepository.save(group); + } + + async updateGroup(id: string, updateData: Partial): Promise { + await this.groupRepository.update(id, updateData); + const updatedGroup = await this.groupRepository.findOneBy({ id }); + if (!updatedGroup) { + throw new Error("Group not found after update"); + } + return updatedGroup; + } + + async getUserGroups(userId: string): Promise { + return await this.groupRepository + .createQueryBuilder("group") + .leftJoinAndSelect("group.members", "members") + .leftJoinAndSelect("group.admins", "admins") + .leftJoinAndSelect("group.participants", "participants") + .where("members.id = :userId OR admins.id = :userId OR participants.id = :userId", { userId }) + .getMany(); + } + + async searchGroups(query: string, limit: number = 10): Promise { + return await this.groupRepository + .createQueryBuilder("group") + .where("group.name ILIKE :query OR group.description ILIKE :query", { query: `%${query}%` }) + .limit(limit) + .getMany(); + } + + async isGroupAdmin(groupId: string, userId: string): Promise { + const group = await this.groupRepository.findOne({ + where: { id: groupId }, + relations: ["admins"] + }); + if (!group) return false; + return group.admins.some(admin => admin.id === userId); + } +} + diff --git a/platforms/eCurrency-api/src/services/LedgerService.ts b/platforms/eCurrency-api/src/services/LedgerService.ts new file mode 100644 index 00000000..6e6315dd --- /dev/null +++ b/platforms/eCurrency-api/src/services/LedgerService.ts @@ -0,0 +1,278 @@ +import { Repository, LessThanOrEqual, DeepPartial } from "typeorm"; +import { AppDataSource } from "../database/data-source"; +import { Ledger, AccountType, LedgerType } from "../database/entities/Ledger"; +import { Currency } from "../database/entities/Currency"; +import { User } from "../database/entities/User"; +import { Group } from "../database/entities/Group"; + +export class LedgerService { + ledgerRepository: Repository; + currencyRepository: Repository; + + constructor() { + this.ledgerRepository = AppDataSource.getRepository(Ledger); + this.currencyRepository = AppDataSource.getRepository(Currency); + } + + async getAccountBalance(currencyId: string, accountId: string, accountType: AccountType): Promise { + const latestEntry = await this.ledgerRepository.findOne({ + where: { + currencyId, + accountId, + accountType, + }, + order: { + createdAt: "DESC" + } + }); + + return latestEntry ? Number(latestEntry.balance) : 0; + } + + async addLedgerEntry( + currencyId: string, + accountId: string, + accountType: AccountType, + amount: number, + type: LedgerType, + description?: string, + senderAccountId?: string, + senderAccountType?: AccountType, + receiverAccountId?: string, + receiverAccountType?: AccountType + ): Promise { + // Get current balance + const currentBalance = await this.getAccountBalance(currencyId, accountId, accountType); + + // Calculate new balance + const newBalance = type === LedgerType.CREDIT + ? currentBalance + amount + : currentBalance - amount; + + const entryData: DeepPartial = { + currencyId, + accountId, + accountType, + amount: type === LedgerType.CREDIT ? amount : -amount, + type, + description: description ?? undefined, + senderAccountId: senderAccountId ?? undefined, + senderAccountType: senderAccountType ?? undefined, + receiverAccountId: receiverAccountId ?? undefined, + receiverAccountType: receiverAccountType ?? undefined, + balance: newBalance, + }; + + const entry = this.ledgerRepository.create(entryData); + + const saved = await this.ledgerRepository.save(entry); + return Array.isArray(saved) ? saved[0] : saved; + } + + async transfer( + currencyId: string, + fromAccountId: string, + fromAccountType: AccountType, + toAccountId: string, + toAccountType: AccountType, + amount: number, + description?: string + ): Promise<{ debit: Ledger; credit: Ledger }> { + // Prevent self transfers + if (fromAccountId === toAccountId && fromAccountType === toAccountType) { + throw new Error("Cannot transfer to the same account"); + } + + // Get currency to check allowNegative + const currency = await this.currencyRepository.findOne({ where: { id: currencyId } }); + if (!currency) { + throw new Error("Currency not found"); + } + + // Only check balance if negative balances are not allowed + if (!currency.allowNegative) { + const currentBalance = await this.getAccountBalance(currencyId, fromAccountId, fromAccountType); + if (currentBalance < amount) { + throw new Error("Insufficient balance. This currency does not allow negative balances."); + } + } + + // Create debit entry (from sender's account) + // Both entries have the same sender and receiver info + const debit = await this.addLedgerEntry( + currencyId, + fromAccountId, + fromAccountType, + amount, + LedgerType.DEBIT, + description || `Transfer to ${toAccountType}:${toAccountId}`, + fromAccountId, // sender + fromAccountType, // sender type + toAccountId, // receiver + toAccountType // receiver type + ); + + // Create credit entry (to receiver's account) + // Both entries have the same sender and receiver info + const credit = await this.addLedgerEntry( + currencyId, + toAccountId, + toAccountType, + amount, + LedgerType.CREDIT, + description || `Transfer from ${fromAccountType}:${fromAccountId}`, + fromAccountId, // sender + fromAccountType, // sender type + toAccountId, // receiver + toAccountType // receiver type + ); + + return { debit, credit }; + } + + async getTransactionHistory( + currencyId?: string, + accountId?: string, + accountType?: AccountType, + limit: number = 50, + offset: number = 0 + ): Promise { + const queryBuilder = this.ledgerRepository.createQueryBuilder("ledger") + .leftJoinAndSelect("ledger.currency", "currency") + .orderBy("ledger.createdAt", "DESC") + .limit(limit) + .offset(offset); + + if (currencyId) { + queryBuilder.where("ledger.currencyId = :currencyId", { currencyId }); + } + + if (accountId && accountType) { + if (currencyId) { + queryBuilder.andWhere("ledger.accountId = :accountId", { accountId }); + queryBuilder.andWhere("ledger.accountType = :accountType", { accountType }); + } else { + queryBuilder.where("ledger.accountId = :accountId", { accountId }); + queryBuilder.andWhere("ledger.accountType = :accountType", { accountType }); + } + } + + return await queryBuilder.getMany(); + } + + async initializeAccount( + currencyId: string, + accountId: string, + accountType: AccountType + ): Promise { + // Check if account already exists + const existing = await this.ledgerRepository.findOne({ + where: { + currencyId, + accountId, + accountType, + } + }); + + if (existing) { + // Account already initialized + return existing; + } + + // Create initial entry with zero balance + return await this.addLedgerEntry( + currencyId, + accountId, + accountType, + 0, + LedgerType.CREDIT, + "Account initialized" + ); + } + + async getAllBalancesForAccount(accountId: string, accountType: AccountType): Promise> { + // Get all unique currencies for this account + const entries = await this.ledgerRepository + .createQueryBuilder("ledger") + .leftJoinAndSelect("ledger.currency", "currency") + .where("ledger.accountId = :accountId", { accountId }) + .andWhere("ledger.accountType = :accountType", { accountType }) + .getMany(); + + // Group by currency and get latest balance for each + const currencyMap = new Map(); + + for (const entry of entries) { + const currencyId = entry.currencyId; + const currentBalance = await this.getAccountBalance(currencyId, accountId, accountType); + + if (!currencyMap.has(currencyId)) { + currencyMap.set(currencyId, { + currency: entry.currency, + balance: currentBalance + }); + } + } + + return Array.from(currencyMap.values()); + } + + async getTransactionById(id: string): Promise { + return await this.ledgerRepository.findOne({ + where: { id }, + relations: ["currency"] + }); + } + + async findRelatedTransaction(transactionId: string): Promise { + const transaction = await this.getTransactionById(transactionId); + if (!transaction) return null; + + // Find transactions with same currency, same absolute amount, opposite type, within 10 seconds + const oppositeType = transaction.type === LedgerType.DEBIT ? LedgerType.CREDIT : LedgerType.DEBIT; + const timeWindowEnd = new Date(transaction.createdAt.getTime() + 10000); + const timeWindowStart = new Date(transaction.createdAt.getTime() - 10000); + const transactionAmount = Math.abs(Number(transaction.amount)); + + // Try to find by exact amount match first + let related = await this.ledgerRepository + .createQueryBuilder("ledger") + .where("ledger.currencyId = :currencyId", { currencyId: transaction.currencyId }) + .andWhere("ledger.type = :oppositeType", { oppositeType }) + .andWhere("ABS(ledger.amount) = :amount", { amount: transactionAmount }) + .andWhere("ledger.createdAt >= :timeWindowStart", { timeWindowStart }) + .andWhere("ledger.createdAt <= :timeWindowEnd", { timeWindowEnd }) + .andWhere("ledger.id != :transactionId", { transactionId }) + .orderBy("ABS(EXTRACT(EPOCH FROM (ledger.createdAt - :transactionTime)))", "ASC") + .setParameter("transactionTime", transaction.createdAt) + .getOne(); + + // If not found, try a broader search with just currency, type, and time window + if (!related) { + related = await this.ledgerRepository + .createQueryBuilder("ledger") + .where("ledger.currencyId = :currencyId", { currencyId: transaction.currencyId }) + .andWhere("ledger.type = :oppositeType", { oppositeType }) + .andWhere("ledger.createdAt >= :timeWindowStart", { timeWindowStart }) + .andWhere("ledger.createdAt <= :timeWindowEnd", { timeWindowEnd }) + .andWhere("ledger.id != :transactionId", { transactionId }) + .orderBy("ABS(EXTRACT(EPOCH FROM (ledger.createdAt - :transactionTime)))", "ASC") + .setParameter("transactionTime", transaction.createdAt) + .limit(1) + .getOne(); + } + + return related; + } + + async getAccountInfo(accountId: string, accountType: AccountType): Promise { + if (accountType === AccountType.USER) { + const userRepository = AppDataSource.getRepository(User); + return await userRepository.findOne({ where: { id: accountId } }); + } else { + const groupRepository = AppDataSource.getRepository(Group); + return await groupRepository.findOne({ where: { id: accountId } }); + } + } +} + diff --git a/platforms/eCurrency-api/src/services/UserService.ts b/platforms/eCurrency-api/src/services/UserService.ts new file mode 100644 index 00000000..c680df34 --- /dev/null +++ b/platforms/eCurrency-api/src/services/UserService.ts @@ -0,0 +1,67 @@ +import { Repository } from "typeorm"; +import { AppDataSource } from "../database/data-source"; +import { User } from "../database/entities/User"; + +export class UserService { + userRepository: Repository; + + constructor() { + this.userRepository = AppDataSource.getRepository(User); + } + + async findUser(ename: string): Promise { + // Only find user, don't create - users should only be created via webhooks + return this.getUserByEname(ename); + } + + async getUserByEname(ename: string): Promise { + // Strip @ prefix if present for database lookup + const cleanEname = this.stripEnamePrefix(ename); + return this.userRepository.findOne({ + where: { ename: cleanEname }, + }); + } + + async getUserById(id: string): Promise { + return await this.userRepository.findOne({ + where: { id }, + relations: ["followers", "following"] + }); + } + + async createBlankUser(w3id: string): Promise { + // Strip @ prefix if present before storing + const cleanEname = this.stripEnamePrefix(w3id); + const user = this.userRepository.create({ + ename: cleanEname, + }); + return await this.userRepository.save(user); + } + + async updateUser(id: string, updateData: Partial): Promise { + await this.userRepository.update(id, updateData); + const updatedUser = await this.userRepository.findOneBy({ id }); + if (!updatedUser) { + throw new Error("User not found after update"); + } + return updatedUser; + } + + async searchUsers(query: string, limit: number = 10): Promise { + return await this.userRepository + .createQueryBuilder("user") + .where("user.name ILIKE :query OR user.handle ILIKE :query", { query: `%${query}%` }) + .limit(limit) + .getMany(); + } + + /** + * Strips the @ prefix from ename if present + * @param ename - The ename with or without @ prefix + * @returns The ename without @ prefix + */ + private stripEnamePrefix(ename: string): string { + return ename.startsWith('@') ? ename.slice(1) : ename; + } +} + diff --git a/platforms/eCurrency-api/src/types/express.ts b/platforms/eCurrency-api/src/types/express.ts new file mode 100644 index 00000000..ccfd9071 --- /dev/null +++ b/platforms/eCurrency-api/src/types/express.ts @@ -0,0 +1,13 @@ +import { User } from "../database/entities/User"; + +declare global { + namespace Express { + interface Request { + user?: User; + } + } +} + +// Export empty object to make this a module +export {}; + diff --git a/platforms/eCurrency-api/src/utils/jwt.ts b/platforms/eCurrency-api/src/utils/jwt.ts new file mode 100644 index 00000000..2bd21e3b --- /dev/null +++ b/platforms/eCurrency-api/src/utils/jwt.ts @@ -0,0 +1,30 @@ +import jwt, { JwtPayload } from "jsonwebtoken"; + +// Fail fast if JWT_SECRET is missing +if (!process.env.JWT_SECRET) { + throw new Error("JWT_SECRET environment variable is required but was not provided. Please set JWT_SECRET in your environment configuration."); +} + +const JWT_SECRET = process.env.JWT_SECRET; + +export interface AuthTokenPayload { + userId: string; +} + +export const signToken = (payload: AuthTokenPayload): string => { + return jwt.sign(payload, JWT_SECRET, { expiresIn: "7d" }); +}; + +export const verifyToken = (token: string): AuthTokenPayload => { + const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload & AuthTokenPayload; + + // Validate that the decoded token has the required userId field + if (!decoded.userId || typeof decoded.userId !== 'string') { + throw new Error("Invalid token: missing or invalid userId"); + } + + return { + userId: decoded.userId + }; +}; + diff --git a/platforms/eCurrency-api/src/web3adapter/index.ts b/platforms/eCurrency-api/src/web3adapter/index.ts new file mode 100644 index 00000000..04b2d7af --- /dev/null +++ b/platforms/eCurrency-api/src/web3adapter/index.ts @@ -0,0 +1,2 @@ +export { adapter } from "./watchers/subscriber"; + diff --git a/platforms/eCurrency-api/src/web3adapter/mappings/currency.mapping.json b/platforms/eCurrency-api/src/web3adapter/mappings/currency.mapping.json new file mode 100644 index 00000000..888e9be4 --- /dev/null +++ b/platforms/eCurrency-api/src/web3adapter/mappings/currency.mapping.json @@ -0,0 +1,17 @@ +{ + "tableName": "currencies", + "schemaId": "550e8400-e29b-41d4-a716-446655440004", + "ownerEnamePath": "ename", + "ownedJunctionTables": [], + "localToUniversalMap": { + "name": "name", + "ename": "ename", + "groupId": "groupId", + "allowNegative": "allowNegative", + "createdBy": "createdBy", + "createdAt": "createdAt", + "updatedAt": "updatedAt" + }, + "readOnly": false +} + diff --git a/platforms/eCurrency-api/src/web3adapter/mappings/group.mapping.json b/platforms/eCurrency-api/src/web3adapter/mappings/group.mapping.json new file mode 100644 index 00000000..691cddbc --- /dev/null +++ b/platforms/eCurrency-api/src/web3adapter/mappings/group.mapping.json @@ -0,0 +1,25 @@ +{ + "tableName": "groups", + "schemaId": "550e8400-e29b-41d4-a716-446655440003", + "ownerEnamePath": "users(participants[].ename)", + "ownedJunctionTables": ["group_participants"], + "localToUniversalMap": { + "name": "name", + "description": "description", + "owner": "owner", + "admins": "users(admins[].id),admins", + "charter": "charter", + "ename": "ename", + "participants": "users(participants[].id),participantIds", + "members": "users(members[].id),memberIds", + "originalMatchParticipants": "originalMatchParticipants", + "isPrivate": "isPrivate", + "visibility": "visibility", + "avatarUrl": "avatarUrl", + "bannerUrl": "bannerUrl", + "createdAt": "createdAt", + "updatedAt": "updatedAt" + }, + "readOnly": false +} + diff --git a/platforms/eCurrency-api/src/web3adapter/mappings/ledger.mapping.json b/platforms/eCurrency-api/src/web3adapter/mappings/ledger.mapping.json new file mode 100644 index 00000000..7c7e473b --- /dev/null +++ b/platforms/eCurrency-api/src/web3adapter/mappings/ledger.mapping.json @@ -0,0 +1,18 @@ +{ + "tableName": "ledger", + "schemaId": "550e8400-e29b-41d4-a716-446655440005", + "ownerEnamePath": "currency.ename", + "ownedJunctionTables": [], + "localToUniversalMap": { + "currencyId": "currencyId", + "accountId": "accountId", + "accountType": "accountType", + "amount": "amount", + "type": "type", + "description": "description", + "balance": "balance", + "createdAt": "createdAt" + }, + "readOnly": false +} + diff --git a/platforms/eCurrency-api/src/web3adapter/mappings/user.mapping.json b/platforms/eCurrency-api/src/web3adapter/mappings/user.mapping.json new file mode 100644 index 00000000..6daef2d7 --- /dev/null +++ b/platforms/eCurrency-api/src/web3adapter/mappings/user.mapping.json @@ -0,0 +1,23 @@ +{ + "tableName": "users", + "schemaId": "550e8400-e29b-41d4-a716-446655440000", + "ownerEnamePath": "ename", + "ownedJunctionTables": ["user_followers", "user_following"], + "localToUniversalMap": { + "handle": "username", + "name": "displayName", + "description": "bio", + "avatarUrl": "avatarUrl", + "bannerUrl": "bannerUrl", + "ename": "ename", + "isVerified": "isVerified", + "isPrivate": "isPrivate", + "createdAt": "createdAt", + "updatedAt": "updatedAt", + "isArchived": "isArchived", + "followers": "followers", + "following": "following" + }, + "readOnly": true +} + diff --git a/platforms/eCurrency-api/src/web3adapter/watchers/subscriber.ts b/platforms/eCurrency-api/src/web3adapter/watchers/subscriber.ts new file mode 100644 index 00000000..0f45909e --- /dev/null +++ b/platforms/eCurrency-api/src/web3adapter/watchers/subscriber.ts @@ -0,0 +1,389 @@ +import { + EventSubscriber, + EntitySubscriberInterface, + InsertEvent, + UpdateEvent, + RemoveEvent, + ObjectLiteral, +} from "typeorm"; +import { Web3Adapter } from "web3-adapter"; +import path from "path"; +import dotenv from "dotenv"; +import { AppDataSource } from "../../database/data-source"; + +dotenv.config({ path: path.resolve(__dirname, "../../../../../.env") }); + +export const adapter = new Web3Adapter({ + schemasPath: path.resolve(__dirname, "../mappings/"), + dbPath: path.resolve(process.env.ECURRENCY_MAPPING_DB_PATH as string), + registryUrl: process.env.PUBLIC_REGISTRY_URL as string, + platform: process.env.VITE_ECURRENCY_BASE_URL as string, +}); + +// Map of junction tables to their parent entities +const JUNCTION_TABLE_MAP = { + user_followers: { entity: "User", idField: "user_id" }, + user_following: { entity: "User", idField: "user_id" }, + group_participants: { entity: "Group", idField: "group_id" }, +}; + +@EventSubscriber() +export class PostgresSubscriber implements EntitySubscriberInterface { + static { + console.log("🔧 PostgresSubscriber class is being loaded"); + } + private adapter: Web3Adapter; + private junctionTableDebounceMap: Map = new Map(); + + constructor() { + console.log("🚀 PostgresSubscriber constructor called - subscriber is being instantiated"); + this.adapter = adapter; + } + + /** + * Called before entity insertion. + */ + beforeInsert(event: InsertEvent) { + + } + + async enrichEntity(entity: any, tableName: string, tableTarget: any) { + try { + const enrichedEntity = { ...entity }; + return this.entityToPlain(enrichedEntity); + } catch (error) { + console.error("Error loading relations:", error); + return this.entityToPlain(entity); + } + } + + /** + * Called after entity insertion. + */ + async afterInsert(event: InsertEvent) { + let entity = event.entity; + if (entity) { + entity = (await this.enrichEntity( + entity, + event.metadata.tableName, + event.metadata.target + )) as ObjectLiteral; + } + + this.handleChange( + // @ts-ignore + entity ?? event.entityId, + event.metadata.tableName.endsWith("s") + ? event.metadata.tableName + : event.metadata.tableName + "s" + ); + } + + /** + * Called before entity update. + */ + beforeUpdate(event: UpdateEvent) { + // Handle any pre-update processing if needed + } + + /** + * Called after entity update. + */ + async afterUpdate(event: UpdateEvent) { + // For updates, we need to reload the full entity since event.entity only contains changed fields + let entity = event.entity; + + // Try different ways to get the entity ID + let entityId = event.entity?.id || event.databaseEntity?.id; + + if (!entityId && event.entity) { + // If we have the entity but no ID, try to extract it from the entity object + const entityKeys = Object.keys(event.entity); + + // Look for common ID field names + entityId = event.entity.id || event.entity.Id || event.entity.ID || event.entity._id; + } + + if (entityId) { + // Reload the full entity from the database + const repository = AppDataSource.getRepository(event.metadata.target); + const entityName = typeof event.metadata.target === 'function' + ? event.metadata.target.name + : event.metadata.target; + + const fullEntity = await repository.findOne({ + where: { id: entityId }, + relations: this.getRelationsForEntity(entityName) + }); + + if (fullEntity) { + entity = (await this.enrichEntity( + fullEntity, + event.metadata.tableName, + event.metadata.target + )) as ObjectLiteral; + } + } + + this.handleChange( + // @ts-ignore + entity ?? event.entityId, + event.metadata.tableName.endsWith("s") + ? event.metadata.tableName + : event.metadata.tableName + "s" + ); + } + + /** + * Called before entity removal. + */ + beforeRemove(event: RemoveEvent) { + // Handle any pre-remove processing if needed + } + + /** + * Called after entity removal. + */ + async afterRemove(event: RemoveEvent) { + this.handleChange( + // @ts-ignore + event.entityId, + event.metadata.tableName.endsWith("s") + ? event.metadata.tableName + : event.metadata.tableName + "s" + ); + } + + /** + * Handle entity changes and send to web3adapter + */ + private async handleChange(entity: any, tableName: string): Promise { + console.log(`🔍 handleChange called for: ${tableName}, entityId: ${entity?.id}`); + + // Handle junction table changes + // @ts-ignore + const junctionInfo = JUNCTION_TABLE_MAP[tableName]; + if (junctionInfo) { + console.log(`🔗 Processing junction table change for: ${tableName}`); + await this.handleJunctionTableChange(entity, junctionInfo); + return; + } + + // Handle regular entity changes with debouncing for groups + const data = this.entityToPlain(entity); + if (!data.id) return; + + // Add debouncing for group entities to prevent duplicate webhooks + if (tableName === "groups") { + const debounceKey = `group:${data.id}`; + console.log(`🔍 Group debounce key: ${debounceKey}`); + + // Clear existing timeout for this group + if (this.junctionTableDebounceMap.has(debounceKey)) { + console.log(`🔍 Clearing existing group timeout for: ${debounceKey}`); + clearTimeout(this.junctionTableDebounceMap.get(debounceKey)!); + } + + // Set new timeout + const timeoutId = setTimeout(async () => { + try { + console.log(`🔍 Executing debounced group webhook for: ${debounceKey}`); + await this.sendGroupWebhook(data); + this.junctionTableDebounceMap.delete(debounceKey); + console.log(`🔍 Completed group webhook for: ${debounceKey}`); + } catch (error) { + console.error("Error in group timeout:", error); + this.junctionTableDebounceMap.delete(debounceKey); + } + }, 3_000); + + // Store the timeout ID + this.junctionTableDebounceMap.set(debounceKey, timeoutId); + return; + } + + try { + setTimeout(async () => { + let globalId = await this.adapter.mappingDb.getGlobalId( + entity.id + ); + globalId = globalId ?? ""; + + if (this.adapter.lockedIds.includes(globalId)) { + return; + } + + // Check if this entity was recently created by a webhook + if (this.adapter.lockedIds.includes(entity.id)) { + return; + } + + const envelope = await this.adapter.handleChange({ + data, + tableName: tableName.toLowerCase(), + }); + }, 3_000); + } catch (error) { + console.error(`Error processing change for ${tableName}:`, error); + } + } + + /** + * Handle changes in junction tables by converting them to parent entity changes + */ + private async handleJunctionTableChange( + entity: any, + junctionInfo: { entity: string; idField: string } + ): Promise { + try { + const parentId = entity[junctionInfo.idField]; + if (!parentId) { + console.error("No parent ID found in junction table change"); + return; + } + + const repository = AppDataSource.getRepository(junctionInfo.entity); + const parentEntity = await repository.findOne({ + where: { id: parentId }, + relations: this.getRelationsForEntity(junctionInfo.entity), + }); + + if (!parentEntity) { + console.error(`Parent entity not found: ${parentId}`); + return; + } + + // Use debouncing to prevent multiple webhook packets for the same group + const debounceKey = `${junctionInfo.entity}:${parentId}`; + + console.log(`🔗 Junction table debounce key: ${debounceKey}`); + + // Clear existing timeout for this group + if (this.junctionTableDebounceMap.has(debounceKey)) { + console.log(`🔗 Clearing existing timeout for: ${debounceKey}`); + clearTimeout(this.junctionTableDebounceMap.get(debounceKey)!); + } + + // Set new timeout + const timeoutId = setTimeout(async () => { + try { + console.log(`🔗 Executing debounced webhook for: ${debounceKey}`); + let globalId = await this.adapter.mappingDb.getGlobalId( + entity.id + ); + globalId = globalId ?? ""; + + if (this.adapter.lockedIds.includes(globalId)) { + console.log(`🔗 GlobalId ${globalId} is locked, skipping`); + return; + } + + const tableName = `${junctionInfo.entity.toLowerCase()}s`; + console.log(`🔗 Sending webhook packet for group: ${parentId}, tableName: ${tableName}`); + await this.adapter.handleChange({ + data: this.entityToPlain(parentEntity), + tableName, + }); + + // Remove from debounce map after processing + this.junctionTableDebounceMap.delete(debounceKey); + console.log(`🔗 Completed webhook for: ${debounceKey}`); + } catch (error) { + console.error("Error in junction table timeout:", error); + this.junctionTableDebounceMap.delete(debounceKey); + } + }, 3_000); + + // Store the timeout ID for potential cancellation + this.junctionTableDebounceMap.set(debounceKey, timeoutId); + } catch (error) { + console.error("Error handling junction table change:", error); + } + } + + /** + * Send webhook for group entity + */ + private async sendGroupWebhook(data: any): Promise { + try { + let globalId = await this.adapter.mappingDb.getGlobalId(data.id); + globalId = globalId ?? ""; + + if (this.adapter.lockedIds.includes(globalId)) { + console.log(`🔍 Group globalId ${globalId} is locked, skipping`); + return; + } + + console.log(`🔍 Sending group webhook for: ${data.id}, tableName: groups`); + await this.adapter.handleChange({ + data, + tableName: "groups", + }); + } catch (error) { + console.error("Error sending group webhook:", error); + } + } + + /** + * Get the relations that should be loaded for each entity type + */ + private getRelationsForEntity(entityName: string): string[] { + switch (entityName) { + case "User": + return ["followers", "following"]; + case "Group": + return ["participants", "admins", "members"]; + case "Currency": + return ["group", "creator"]; + case "Ledger": + return ["currency"]; + default: + return []; + } + } + + /** + * Convert TypeORM entity to plain object + */ + private entityToPlain(entity: any): any { + if (!entity) return {}; + + // If it's already a plain object, return it + if (typeof entity !== "object" || entity === null) { + return entity; + } + + // Handle Date objects + if (entity instanceof Date) { + return entity.toISOString(); + } + + // Handle arrays + if (Array.isArray(entity)) { + return entity.map((item) => this.entityToPlain(item)); + } + + // Convert entity to plain object + const plain: Record = {}; + for (const [key, value] of Object.entries(entity)) { + // Skip private properties and methods + if (key.startsWith("_")) continue; + + // Handle nested objects and arrays + if (value && typeof value === "object") { + if (Array.isArray(value)) { + plain[key] = value.map((item) => this.entityToPlain(item)); + } else if (value instanceof Date) { + plain[key] = value.toISOString(); + } else { + plain[key] = this.entityToPlain(value); + } + } else { + plain[key] = value; + } + } + + return plain; + } +} + diff --git a/platforms/eCurrency-api/tsconfig.json b/platforms/eCurrency-api/tsconfig.json new file mode 100644 index 00000000..055c2ef1 --- /dev/null +++ b/platforms/eCurrency-api/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "commonjs", + "moduleResolution": "node", + "rootDir": "./src", + "baseUrl": "./src", + "outDir": "./dist", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} + diff --git a/platforms/eCurrency/client/index.html b/platforms/eCurrency/client/index.html new file mode 100644 index 00000000..9aa8402c --- /dev/null +++ b/platforms/eCurrency/client/index.html @@ -0,0 +1,14 @@ + + + + + + + eCurrency + + +
+ + + + diff --git a/platforms/eCurrency/client/src/App.tsx b/platforms/eCurrency/client/src/App.tsx new file mode 100644 index 00000000..15b1bd6f --- /dev/null +++ b/platforms/eCurrency/client/src/App.tsx @@ -0,0 +1,40 @@ +import { Switch, Route } from "wouter"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { AuthProvider } from "./lib/auth-context"; +import { useAuth } from "./hooks/useAuth"; +import AuthPage from "./pages/auth-page"; +import Dashboard from "./pages/dashboard"; +import Currencies from "./pages/currencies"; +import CurrencyDetail from "./pages/currency-detail"; + +const queryClient = new QueryClient(); + +function Router() { + const { isAuthenticated, isLoading } = useAuth(); + + // Show auth page if loading or not authenticated + if (isLoading || !isAuthenticated) { + return ; + } + + return ( + + + + + + + ); +} + +function App() { + return ( + + + + + + ); +} + +export default App; diff --git a/platforms/eCurrency/client/src/components/account-context-switcher.tsx b/platforms/eCurrency/client/src/components/account-context-switcher.tsx new file mode 100644 index 00000000..ece5a4a1 --- /dev/null +++ b/platforms/eCurrency/client/src/components/account-context-switcher.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { ChevronDown } from "lucide-react"; +import { useAuth } from "@/hooks/useAuth"; +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; + +interface AccountContextSwitcherProps { + value: { type: "user" | "group"; id: string } | null; + onChange: (context: { type: "user" | "group"; id: string } | null) => void; +} + +export default function AccountContextSwitcher({ value, onChange }: AccountContextSwitcherProps) { + const { user } = useAuth(); + const [isOpen, setIsOpen] = useState(false); + + const { data: groups } = useQuery({ + queryKey: ["userGroups"], + queryFn: async () => { + const response = await apiClient.get("/api/groups/my"); + return response.data; + }, + enabled: !!user, + }); + + const adminGroups = groups?.filter((g: any) => g.isAdmin) || []; + + if (!user || adminGroups.length === 0) { + return null; + } + + const currentLabel = value?.type === "user" + ? "My Account" + : adminGroups.find((g: any) => g.id === value?.id)?.name || "My Account"; + + return ( +
+ + + {isOpen && ( + <> +
setIsOpen(false)} + /> +
+ + {adminGroups.map((group: any) => ( + + ))} +
+ + )} +
+ ); +} + diff --git a/platforms/eCurrency/client/src/components/auth/login-screen.tsx b/platforms/eCurrency/client/src/components/auth/login-screen.tsx new file mode 100644 index 00000000..be9f3a0c --- /dev/null +++ b/platforms/eCurrency/client/src/components/auth/login-screen.tsx @@ -0,0 +1,167 @@ +import React, { useEffect, useState } from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { useAuth } from "@/hooks/useAuth"; +import { apiClient } from "@/lib/apiClient"; +import { isMobileDevice, getDeepLinkUrl, getAppStoreLink } from "@/lib/utils/mobile-detection"; +import { Wallet } from "lucide-react"; + +export function LoginScreen() { + const { login } = useAuth(); + const [qrCode, setQrCode] = useState(""); + const [sessionId, setSessionId] = useState(""); + const [isConnecting, setIsConnecting] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const getAuthOffer = async () => { + try { + console.log("🔍 Getting auth offer from:", apiClient.defaults.baseURL); + const response = await apiClient.get("/api/auth/offer"); + console.log("✅ Auth offer response:", response.data); + setQrCode(response.data.offer); + setSessionId(response.data.sessionId); + setIsLoading(false); + } catch (error: unknown) { + console.error("❌ Failed to get auth offer:", error); + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { response?: { data?: unknown; status?: number } }; + console.error("❌ Error details:", axiosError.response?.data); + console.error("❌ Error status:", axiosError.response?.status); + } + setIsLoading(false); + } + }; + + getAuthOffer(); + }, []); + + useEffect(() => { + if (!sessionId) return; + + const apiBaseUrl = import.meta.env.VITE_ECURRENCY_API_URL || "http://localhost:8989"; + const eventSource = new EventSource( + `${apiBaseUrl}/api/auth/sessions/${sessionId}` + ); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.user && data.token) { + setIsConnecting(true); + // Store the token and user ID directly + localStorage.setItem("ecurrency_token", data.token); + localStorage.setItem("ecurrency_user", JSON.stringify(data.user)); + // Redirect to dashboard + window.location.href = "/dashboard"; + } + } catch (error) { + console.error("Error parsing SSE data:", error); + } + }; + + eventSource.onerror = (error) => { + console.error("SSE Error:", error); + eventSource.close(); + }; + + return () => { + eventSource.close(); + }; + }, [sessionId, login]); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isConnecting) { + return ( +
+
+
+

Authenticating...

+
+
+ ); + } + + return ( +
+
+
+
+ +
+

eCurrency

+
+

+ Manage your currencies in the MetaState +

+
+ +
+
+

+ Scan the QR code using your eID App to login +

+
+ + {qrCode && ( +
+ {isMobileDevice() ? ( +
+ + Login with eID Wallet + +
+ Click the button to open your eID wallet app +
+
+ ) : ( +
+ +
+ )} +
+ )} + +
+

+ The {isMobileDevice() ? "button" : "code"} is valid for 60 seconds + Please refresh the page if it expires +

+
+ +
+ You are entering eCurrency - a multi-currency management platform built on the Web 3.0 Data Space (W3DS) + architecture. This system is designed around the principle + of data-platform separation, where all your personal content + is stored in your own sovereign eVault, not on centralised + servers. +
+ + + W3DS Logo + +
+
+ ); +} + diff --git a/platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx b/platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx new file mode 100644 index 00000000..a721956b --- /dev/null +++ b/platforms/eCurrency/client/src/components/currency/add-currency-account-modal.tsx @@ -0,0 +1,180 @@ +import { useState, useMemo } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { X, Search, ChevronDown } from "lucide-react"; +import { formatEName } from "@/lib/utils"; + +interface AddCurrencyAccountModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function AddCurrencyAccountModal({ open, onOpenChange }: AddCurrencyAccountModalProps) { + const [currencyId, setCurrencyId] = useState(""); + const [searchQuery, setSearchQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + const queryClient = useQueryClient(); + + const { data: currencies } = useQuery({ + queryKey: ["currencies"], + queryFn: async () => { + const response = await apiClient.get("/api/currencies"); + return response.data; + }, + }); + + const { data: balances } = useQuery({ + queryKey: ["balances"], + queryFn: async () => { + const response = await apiClient.get("/api/ledger/balance"); + return response.data; + }, + }); + + // Filter out currencies the user already has an account for + const existingCurrencyIds = balances?.map((b: any) => b.currency.id) || []; + const availableCurrencies = currencies?.filter((c: any) => !existingCurrencyIds.includes(c.id)) || []; + + // Filter currencies based on search query + const filteredCurrencies = useMemo(() => { + if (!searchQuery.trim()) return availableCurrencies; + const query = searchQuery.toLowerCase(); + return availableCurrencies.filter((currency: any) => + currency.name.toLowerCase().includes(query) || + currency.ename.toLowerCase().includes(query) + ); + }, [availableCurrencies, searchQuery]); + + const selectedCurrency = availableCurrencies.find((c: any) => c.id === currencyId); + + const initializeMutation = useMutation({ + mutationFn: async (currencyId: string) => { + const response = await apiClient.post("/api/ledger/initialize", { currencyId }); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["balances"] }); + setCurrencyId(""); + setSearchQuery(""); + setIsOpen(false); + onOpenChange(false); + }, + }); + + // Close dropdown when clicking outside + const handleClickOutside = (e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('.currency-selector')) return; + setIsOpen(false); + }; + + if (!open) return null; + + return ( +
+
+
+

Add Currency Account

+ +
+ +
{ + e.preventDefault(); + if (currencyId) { + initializeMutation.mutate(currencyId); + } + }} + className="space-y-4" + > +
+ +
+
setIsOpen(!isOpen)} + className="w-full px-3 py-2 border rounded-md cursor-pointer flex items-center justify-between bg-white" + > + + {selectedCurrency + ? `${selectedCurrency.name} (${formatEName(selectedCurrency.ename)})` + : "Search or select a currency"} + + +
+ + {isOpen && ( +
+
+
+ + setSearchQuery(e.target.value)} + placeholder="Search currencies..." + className="w-full pl-8 pr-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" + autoFocus + onClick={(e) => e.stopPropagation()} + /> +
+
+
+ {filteredCurrencies.length > 0 ? ( + filteredCurrencies.map((currency: any) => ( + + )) + ) : ( +
+ {searchQuery ? "No currencies found" : "No available currencies"} +
+ )} +
+
+ )} +
+ {availableCurrencies.length === 0 && ( +

+ You already have accounts for all available currencies. +

+ )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/platforms/eCurrency/client/src/components/currency/create-currency-modal.tsx b/platforms/eCurrency/client/src/components/currency/create-currency-modal.tsx new file mode 100644 index 00000000..6020cb91 --- /dev/null +++ b/platforms/eCurrency/client/src/components/currency/create-currency-modal.tsx @@ -0,0 +1,155 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiClient } from "@/lib/apiClient"; +import { X } from "lucide-react"; + +interface CreateCurrencyModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + groups: Array<{ id: string; name: string; isAdmin: boolean }>; +} + +export default function CreateCurrencyModal({ open, onOpenChange, groups }: CreateCurrencyModalProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [groupId, setGroupId] = useState(""); + const [allowNegative, setAllowNegative] = useState(false); + const queryClient = useQueryClient(); + + const adminGroups = groups.filter(g => g.isAdmin); + + const createMutation = useMutation({ + mutationFn: async (data: { name: string; description?: string; groupId: string; allowNegative: boolean }) => { + const response = await apiClient.post("/api/currencies", data); + return response.data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["currencies"] }); + queryClient.invalidateQueries({ queryKey: ["balances"] }); + setName(""); + setDescription(""); + setGroupId(""); + setAllowNegative(false); + onOpenChange(false); + }, + }); + + if (!open) return null; + + return ( +
+
+
+

Create Currency

+ +
+ +
{ + e.preventDefault(); + if (name && groupId) { + createMutation.mutate({ name, description, groupId, allowNegative }); + } + }} + className="space-y-4" + > +
+ + setName(e.target.value)} + className="w-full px-3 py-2 border rounded-md" + placeholder="e.g., USD, EUR, Credits" + required + /> +
+ +
+ +