diff --git a/platforms/eReputation-api/README.md b/platforms/eReputation-api/README.md new file mode 100644 index 00000000..c7663d0d --- /dev/null +++ b/platforms/eReputation-api/README.md @@ -0,0 +1,97 @@ +# eReputation API + +This is the API server for the eReputation platform, implementing authentication and web3 integration similar to the dreamSync platform. + +## Features + +- **Authentication**: JWT-based authentication with eVault integration +- **User Management**: User profiles, search, and updates +- **Group Management**: Group creation and management +- **Web3 Integration**: Web3 adapter for blockchain integration +- **Webhook Support**: Handles webhooks from eVault for user and group updates + +## Setup + +1. Install dependencies: + +```bash +npm install +``` + +2. Set up environment variables in `.env` (in project root): + +```env +# Database +EREPUTATION_DATABASE_URL=postgresql://username:password@localhost:5432/ereputation + +# JWT +JWT_SECRET=your-secret-key + +# Web3 Adapter +EREPUTATION_MAPPING_DB_PATH=./ereputation-mapping.db +PUBLIC_REGISTRY_URL=http://localhost:3000 +VITE_EREPUTATION_BASE_URL=http://localhost:8765 + +# Optional: ANCHR forwarding +ANCHR_URL=http://localhost:3001 + +# Optional: Server port +PORT=8765 +NODE_ENV=development +``` + +3. Run the development server: + +```bash +npm run dev +``` + +## API Endpoints + +### Authentication + +- `GET /api/auth/offer` - Get authentication offer +- `POST /api/auth` - Login with eVault credentials +- `GET /api/auth/sessions/:id` - SSE stream for auth sessions + +### Users + +- `GET /api/users/me` - Get current user profile +- `GET /api/users/search?q=query` - Search users +- `GET /api/users/:id` - Get user profile by ID +- `PATCH /api/users` - Update current user profile + +### Webhooks + +- `POST /api/webhook` - Handle eVault webhooks + +### Health + +- `GET /api/health` - Health check endpoint + +## Architecture + +The API follows the same patterns as dreamSync: + +1. **Entities**: User and Group models with TypeORM +2. **Services**: Business logic for users and groups +3. **Controllers**: HTTP request handlers +4. **Middleware**: Authentication and authorization +5. **Web3 Adapter**: Integration with blockchain/web3 systems +6. **Webhooks**: Handle external updates from eVault + +## Database + +Uses PostgreSQL with TypeORM for data persistence. The database schema includes: + +- `users` table with profile information +- `groups` table for group management +- Junction tables for relationships (followers, group members, etc.) + +## Web3 Integration + +Integrates with the web3-adapter infrastructure to: + +- Sync user data with eVault +- Handle group updates +- Process webhook events from the blockchain diff --git a/platforms/eReputation-api/package.json b/platforms/eReputation-api/package.json new file mode 100644 index 00000000..e249de60 --- /dev/null +++ b/platforms/eReputation-api/package.json @@ -0,0 +1,42 @@ +{ + "name": "ereputation-api", + "version": "1.0.0", + "description": "eReputation 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", + "openai": "^4.20.1", + "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/eReputation-api/src/controllers/AuthController.ts b/platforms/eReputation-api/src/controllers/AuthController.ts new file mode 100644 index 00000000..0f97e845 --- /dev/null +++ b/platforms/eReputation-api/src/controllers/AuthController.ts @@ -0,0 +1,105 @@ +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_EREPUTATION_BASE_URL || "http://localhost:8765"; + const url = new URL( + "/api/auth", + baseUrl, + ).toString(); + const sessionId = uuidv4(); + const offer = `w3ds://auth?redirect=${url}&session=${sessionId}&platform=ereputation`; + 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/eReputation-api/src/controllers/CalculationController.ts b/platforms/eReputation-api/src/controllers/CalculationController.ts new file mode 100644 index 00000000..d3066765 --- /dev/null +++ b/platforms/eReputation-api/src/controllers/CalculationController.ts @@ -0,0 +1,107 @@ +import { Request, Response } from "express"; +import { CalculationService } from "../services/CalculationService"; +import { authGuard } from "../middleware/auth"; + +export class CalculationController { + private calculationService: CalculationService; + + constructor() { + this.calculationService = new CalculationService(); + } + + calculateReputation = async (req: Request, res: Response) => { + try { + const { targetType, targetId, targetName, userValues } = req.body; + const calculatorId = req.user!.id; + + if (!targetType || !targetId || !targetName || !userValues) { + return res.status(400).json({ error: "Missing required fields" }); + } + + // Create calculation record + const calculation = await this.calculationService.createCalculation({ + targetType, + targetId, + targetName, + userValues, + calculatorId + }); + + // Calculate reputation synchronously + const result = await this.calculationService.calculateReputation(calculation.id); + + const details = result.calculationDetails ? JSON.parse(result.calculationDetails) : {}; + + res.json({ + score: result.calculatedScore?.toString() || "0", + analysis: details.explanation || "No analysis available", + targetName: result.targetName, + calculationId: result.id + }); + } catch (error) { + console.error("Error calculating reputation:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + getCalculationResult = async (req: Request, res: Response) => { + try { + const { calculationId } = req.params; + const userId = req.user!.id; + + const calculation = await this.calculationService.getCalculationById(calculationId); + + if (!calculation) { + return res.status(404).json({ error: "Calculation not found" }); + } + + // Check if user is authorized to view this calculation + if (calculation.calculatorId !== userId) { + return res.status(403).json({ error: "Not authorized to view this calculation" }); + } + + const details = calculation.calculationDetails ? JSON.parse(calculation.calculationDetails) : {}; + + res.json({ + id: calculation.id, + targetType: calculation.targetType, + targetName: calculation.targetName, + userValues: calculation.userValues, + calculatedScore: calculation.calculatedScore, + status: calculation.status, + details: details, + createdAt: calculation.createdAt, + updatedAt: calculation.updatedAt + }); + } catch (error) { + console.error("Error getting calculation result:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + getUserCalculations = async (req: Request, res: Response) => { + try { + const userId = req.user!.id; + const calculations = await this.calculationService.getUserCalculations(userId); + + res.json({ + calculations: calculations.map(calc => { + const details = calc.calculationDetails ? JSON.parse(calc.calculationDetails) : {}; + return { + id: calc.id, + targetType: calc.targetType, + targetName: calc.targetName, + calculatedScore: calc.calculatedScore, + status: calc.status, + details: details, + createdAt: calc.createdAt, + updatedAt: calc.updatedAt + }; + }) + }); + } catch (error) { + console.error("Error getting user calculations:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} diff --git a/platforms/eReputation-api/src/controllers/DashboardController.ts b/platforms/eReputation-api/src/controllers/DashboardController.ts new file mode 100644 index 00000000..0e457a17 --- /dev/null +++ b/platforms/eReputation-api/src/controllers/DashboardController.ts @@ -0,0 +1,118 @@ +import { Request, Response } from "express"; +import { ReferenceService } from "../services/ReferenceService"; +import { CalculationService } from "../services/CalculationService"; +import { authGuard } from "../middleware/auth"; + +export class DashboardController { + private referenceService: ReferenceService; + private calculationService: CalculationService; + + constructor() { + this.referenceService = new ReferenceService(); + this.calculationService = new CalculationService(); + } + + getStats = async (req: Request, res: Response) => { + try { + const userId = req.user!.id; + + // Get total references received by this user + const receivedReferences = await this.referenceService.getReferencesForTarget("user", userId); + + res.json({ + totalReferences: receivedReferences.length.toString() + }); + } catch (error) { + console.error("Error getting dashboard stats:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + getActivities = async (req: Request, res: Response) => { + try { + const userId = req.user!.id; + const page = parseInt(req.query.page as string) || 1; + const limit = parseInt(req.query.limit as string) || 10; + const offset = (page - 1) * limit; + + // Get user's sent references + const sentReferences = await this.referenceService.getUserReferences(userId); + + // Get user's calculations + const calculations = await this.calculationService.getUserCalculations(userId); + + // Get references received by this user + const receivedReferences = await this.referenceService.getReferencesForTarget("user", userId); + + // Combine and format activities + const activities: any[] = []; + + // Add sent references + sentReferences.forEach(ref => { + activities.push({ + id: `ref-sent-${ref.id}`, + type: 'reference', + activity: 'Reference Provided', + target: ref.targetName, + targetType: ref.targetType, + date: ref.createdAt, + status: ref.status === 'revoked' ? 'Revoked' : 'Signed', + data: ref + }); + }); + + // Add received references + receivedReferences.forEach(ref => { + activities.push({ + id: `ref-received-${ref.id}`, + type: 'reference', + activity: 'Reference Received', + target: ref.author.ename || ref.author.name, + targetType: 'user', + date: ref.createdAt, + status: ref.status === 'revoked' ? 'Revoked' : 'Signed', + data: ref + }); + }); + + // Add calculations + calculations.forEach(calc => { + activities.push({ + id: `calc-${calc.id}`, + type: 'calculation', + activity: calc.targetType === 'user' ? 'User Evaluation' : + calc.targetType === 'group' ? 'Group Evaluation' : 'Platform Analysis', + target: calc.targetName, + targetType: calc.targetType, + date: calc.createdAt, + status: calc.status, + result: calc.calculatedScore ? `Score: ${calc.calculatedScore}/5` : 'Calculating...', + data: calc + }); + }); + + // Sort by date (newest first) + activities.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + // Paginate + const total = activities.length; + const totalPages = Math.ceil(total / limit); + const paginatedActivities = activities.slice(offset, offset + limit); + + res.json({ + activities: paginatedActivities, + pagination: { + page, + limit, + total, + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1 + } + }); + } catch (error) { + console.error("Error getting dashboard activities:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} diff --git a/platforms/eReputation-api/src/controllers/GroupController.ts b/platforms/eReputation-api/src/controllers/GroupController.ts new file mode 100644 index 00000000..172ac311 --- /dev/null +++ b/platforms/eReputation-api/src/controllers/GroupController.ts @@ -0,0 +1,35 @@ +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" }); + } + + const limitNum = limit ? parseInt(limit as string) : 10; + const groups = await this.groupService.searchGroups(q, limitNum); + + res.json(groups.map(group => ({ + id: group.id, + name: group.name, + 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" }); + } + }; +} diff --git a/platforms/eReputation-api/src/controllers/PlatformController.ts b/platforms/eReputation-api/src/controllers/PlatformController.ts new file mode 100644 index 00000000..aeaf1977 --- /dev/null +++ b/platforms/eReputation-api/src/controllers/PlatformController.ts @@ -0,0 +1,58 @@ +import { Request, Response } from "express"; +import { PlatformService } from "../services/PlatformService"; + +export class PlatformController { + private platformService: PlatformService; + + constructor() { + this.platformService = new PlatformService(); + } + + getPlatforms = async (req: Request, res: Response) => { + try { + const platforms = await this.platformService.getActivePlatforms(); + + res.json({ + platforms: platforms.map(platform => ({ + id: platform.id, + name: platform.name, + description: platform.description, + category: platform.category, + logoUrl: platform.logoUrl, + url: platform.url, + type: "platform" + })) + }); + } catch (error) { + console.error("Error getting platforms:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + searchPlatforms = async (req: Request, res: Response) => { + try { + const { q } = req.query; + + if (!q || typeof q !== 'string') { + return res.status(400).json({ error: "Query parameter 'q' is required" }); + } + + const platforms = await this.platformService.searchPlatforms(q); + + res.json({ + platforms: platforms.map(platform => ({ + id: platform.id, + name: platform.name, + description: platform.description, + category: platform.category, + logoUrl: platform.logoUrl, + url: platform.url, + type: "platform" + })) + }); + } catch (error) { + console.error("Error searching platforms:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} diff --git a/platforms/eReputation-api/src/controllers/ReferenceController.ts b/platforms/eReputation-api/src/controllers/ReferenceController.ts new file mode 100644 index 00000000..90a711d2 --- /dev/null +++ b/platforms/eReputation-api/src/controllers/ReferenceController.ts @@ -0,0 +1,126 @@ +import { Request, Response } from "express"; +import { ReferenceService } from "../services/ReferenceService"; +import { authGuard } from "../middleware/auth"; + +export class ReferenceController { + private referenceService: ReferenceService; + + constructor() { + this.referenceService = new ReferenceService(); + } + + createReference = async (req: Request, res: Response) => { + try { + const { targetType, targetId, targetName, content, referenceType, numericScore } = req.body; + const authorId = req.user!.id; + + if (!targetType || !targetId || !targetName || !content) { + return res.status(400).json({ error: "Missing required fields" }); + } + + if (numericScore && (numericScore < 1 || numericScore > 5)) { + return res.status(400).json({ error: "Numeric score must be between 1 and 5" }); + } + + const reference = await this.referenceService.createReference({ + targetType, + targetId, + targetName, + content, + referenceType: referenceType || "general", + numericScore, + authorId + }); + + res.status(201).json({ + message: "Reference created successfully", + reference: { + id: reference.id, + targetType: reference.targetType, + targetName: reference.targetName, + content: reference.content, + numericScore: reference.numericScore, + status: reference.status, + createdAt: reference.createdAt + } + }); + } catch (error) { + console.error("Error creating reference:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + getReferencesForTarget = async (req: Request, res: Response) => { + try { + const { targetType, targetId } = req.params; + + const references = await this.referenceService.getReferencesForTarget(targetType, targetId); + + res.json({ + references: references.map(ref => ({ + id: ref.id, + content: ref.content, + numericScore: ref.numericScore, + referenceType: ref.referenceType, + status: ref.status, + author: { + id: ref.author.id, + ename: ref.author.ename, + name: ref.author.name + }, + createdAt: ref.createdAt + })) + }); + } catch (error) { + console.error("Error getting references:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + getUserReferences = async (req: Request, res: Response) => { + try { + const userId = req.user!.id; + const references = await this.referenceService.getUserReferences(userId); + + res.json({ + references: references.map(ref => ({ + id: ref.id, + targetType: ref.targetType, + targetName: ref.targetName, + content: ref.content, + numericScore: ref.numericScore, + referenceType: ref.referenceType, + status: ref.status, + createdAt: ref.createdAt + })) + }); + } catch (error) { + console.error("Error getting user references:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + + revokeReference = async (req: Request, res: Response) => { + try { + const { referenceId } = req.params; + const userId = req.user!.id; + + const reference = await this.referenceService.revokeReference(referenceId, userId); + + if (!reference) { + return res.status(404).json({ error: "Reference not found or not authorized" }); + } + + res.json({ + message: "Reference revoked successfully", + reference: { + id: reference.id, + status: reference.status + } + }); + } catch (error) { + console.error("Error revoking reference:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} diff --git a/platforms/eReputation-api/src/controllers/UserController.ts b/platforms/eReputation-api/src/controllers/UserController.ts new file mode 100644 index 00000000..0ef5a839 --- /dev/null +++ b/platforms/eReputation-api/src/controllers/UserController.ts @@ -0,0 +1,140 @@ +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/eReputation-api/src/controllers/WebhookController.ts b/platforms/eReputation-api/src/controllers/WebhookController.ts new file mode 100644 index 00000000..a6e0c6e7 --- /dev/null +++ b/platforms/eReputation-api/src/controllers/WebhookController.ts @@ -0,0 +1,219 @@ +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 { Group } from "../database/entities/Group"; +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 { + console.log("🔔 eReputation Webhook received:", { + globalId, + schemaId, + tableName: req.body.data?.tableName + }); + + // Forward to ANCHR if configured + if (process.env.ANCHR_URL) { + try { + await axios.post( + new URL("ereputation-api", process.env.ANCHR_URL).toString(), + req.body + ); + } catch (error) { + console.error("Failed to forward to ANCHR:", 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; + + console.log("Found mapping:", mapping?.tableName); + console.log("Available mappings:", Object.keys(this.adapter.mapping)); + + if (!mapping) { + console.error("No mapping found for schemaId:", schemaId); + throw new Error("No mapping found"); + } + + // Check if this globalId is already locked (being processed) + if (this.adapter.lockedIds.includes(globalId)) { + console.log("GlobalId already locked, skipping:", 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); + console.log("Local ID for globalId", globalId, ":", localId); + let finalLocalId = localId; // Track the final local ID for completion + + 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") { + console.log("Processing group with data:", local.data); + + let participants: User[] = []; + if ( + local.data.participants && + Array.isArray(local.data.participants) + ) { + console.log("Processing participants:", local.data.participants); + const participantPromises = local.data.participants.map( + async (ref: string) => { + if (ref && typeof ref === "string") { + const userId = ref.split("(")[1].split(")")[0]; + console.log("Extracted userId:", userId); + return await this.userService.getUserById(userId); + } + return null; + } + ); + + participants = ( + await Promise.all(participantPromises) + ).filter((user: User | null): user is User => user !== null); + console.log("Found participants:", participants.length); + } + + let adminIds = local?.data?.admins as string[] ?? [] + adminIds = adminIds.map((a) => a.includes("(") ? a.split("(")[1].split(")")[0]: a) + + if (localId) { + console.log("Updating existing group with localId:", localId); + const group = await this.groupService.getGroupById(localId); + if (!group) { + console.error("Group not found for localId:", localId); + 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); + console.log("Updated group:", group.id); + 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) { + console.log("⏭️ Group with same name/description already exists, updating mapping instead"); + this.adapter.addToLockedIds(existingGroup.id); + await this.adapter.mappingDb.storeMapping({ + localId: existingGroup.id, + globalId: req.body.id, + }); + console.log("Stored mapping for existing group:", existingGroup.id, "->", req.body.id); + finalLocalId = existingGroup.id; + } else { + console.log("Creating new group"); + 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, + ); + console.log("Created group with ID:", group.id); + console.log(group) + this.adapter.addToLockedIds(group.id); + await this.adapter.mappingDb.storeMapping({ + localId: group.id, + globalId: req.body.id, + }); + console.log("Stored mapping for group:", group.id, "->", req.body.id); + finalLocalId = group.id; + } + } + } + + console.log(`✅ Webhook completed successfully`); + res.status(200).send(); + } catch (e) { + console.error("eReputation Webhook error:", e); + res.status(500).send(); + } + }; +} diff --git a/platforms/eReputation-api/src/database/data-source.ts b/platforms/eReputation-api/src/database/data-source.ts new file mode 100644 index 00000000..269c0a6e --- /dev/null +++ b/platforms/eReputation-api/src/database/data-source.ts @@ -0,0 +1,25 @@ +import "reflect-metadata"; +import path from "node:path"; +import { config } from "dotenv"; +import { DataSource, type DataSourceOptions } from "typeorm"; +import { User } from "./entities/User"; +import { Group } from "./entities/Group"; +import { Reference } from "./entities/Reference"; +import { Calculation } from "./entities/Calculation"; +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.EREPUTATION_DATABASE_URL, + synchronize: false, // Auto-sync in development + entities: [User, Group, Reference, Calculation], + migrations: [path.join(__dirname, "migrations", "*.ts")], + logging: process.env.NODE_ENV === "development", + subscribers: [PostgresSubscriber], +}; + +export const AppDataSource = new DataSource(dataSourceOptions); \ No newline at end of file diff --git a/platforms/eReputation-api/src/database/entities/Calculation.ts b/platforms/eReputation-api/src/database/entities/Calculation.ts new file mode 100644 index 00000000..808dedc8 --- /dev/null +++ b/platforms/eReputation-api/src/database/entities/Calculation.ts @@ -0,0 +1,42 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from "typeorm"; +import { User } from "./User"; + +@Entity("calculations") +export class Calculation { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + targetType!: string; // "user", "group", "platform" + + @Column() + targetId!: string; + + @Column() + targetName!: string; + + @Column("text") + userValues!: string; // What the user values + + @Column("float") + calculatedScore!: number; // Final calculated score (1-5) + + @Column("text", { nullable: true }) + calculationDetails!: string; // JSON string with calculation breakdown + + @Column() + calculatorId!: string; // Who calculated this + + @ManyToOne(() => User) + @JoinColumn({ name: "calculatorId" }) + calculator!: User; + + @Column() + status!: string; // "complete", "processing", "error" + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/platforms/eReputation-api/src/database/entities/Group.ts b/platforms/eReputation-api/src/database/entities/Group.ts new file mode 100644 index 00000000..b25ffa49 --- /dev/null +++ b/platforms/eReputation-api/src/database/entities/Group.ts @@ -0,0 +1,77 @@ +import { + Entity, + CreateDateColumn, + UpdateDateColumn, + PrimaryGeneratedColumn, + Column, + ManyToMany, + OneToMany, + 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/eReputation-api/src/database/entities/Reference.ts b/platforms/eReputation-api/src/database/entities/Reference.ts new file mode 100644 index 00000000..94452123 --- /dev/null +++ b/platforms/eReputation-api/src/database/entities/Reference.ts @@ -0,0 +1,42 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from "typeorm"; +import { User } from "./User"; + +@Entity("references") +export class Reference { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + targetType!: string; // "user", "group", "platform" + + @Column() + targetId!: string; + + @Column() + targetName!: string; + + @Column("text") + content!: string; + + @Column() + referenceType!: string; // "general", "professional", etc. + + @Column("int", { nullable: true }) + numericScore?: number; // Optional 1-5 score + + @Column() + authorId!: string; + + @ManyToOne(() => User) + @JoinColumn({ name: "authorId" }) + author!: User; + + @Column({ nullable: true }) + status!: string; // "signed", "revoked" + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/platforms/eReputation-api/src/database/entities/User.ts b/platforms/eReputation-api/src/database/entities/User.ts new file mode 100644 index 00000000..e9c13225 --- /dev/null +++ b/platforms/eReputation-api/src/database/entities/User.ts @@ -0,0 +1,71 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + OneToMany, + 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/eReputation-api/src/database/migrations/1761316350100-migration.ts b/platforms/eReputation-api/src/database/migrations/1761316350100-migration.ts new file mode 100644 index 00000000..b4414e2a --- /dev/null +++ b/platforms/eReputation-api/src/database/migrations/1761316350100-migration.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1761316350100 implements MigrationInterface { + name = 'Migration1761316350100' + + 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 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 "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(`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 "group"`); + await queryRunner.query(`DROP TABLE "users"`); + } + +} diff --git a/platforms/eReputation-api/src/database/migrations/1761324658339-migration.ts b/platforms/eReputation-api/src/database/migrations/1761324658339-migration.ts new file mode 100644 index 00000000..10eeb80e --- /dev/null +++ b/platforms/eReputation-api/src/database/migrations/1761324658339-migration.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Migration1761324658339 implements MigrationInterface { + name = 'Migration1761324658339' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "references" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "targetType" character varying NOT NULL, "targetId" character varying NOT NULL, "targetName" character varying NOT NULL, "content" text NOT NULL, "referenceType" character varying NOT NULL, "numericScore" integer, "authorId" uuid NOT NULL, "status" character varying, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_795ec632ca1153bf5ec99d656e5" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "calculations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "targetType" character varying NOT NULL, "targetId" character varying NOT NULL, "targetName" character varying NOT NULL, "userValues" text NOT NULL, "calculatedScore" double precision NOT NULL, "calculationDetails" text, "calculatorId" uuid NOT NULL, "status" character varying NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_a57a12855a44935db91c2533b71" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "references" ADD CONSTRAINT "FK_98fc39160edc395b34d1960ed87" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "calculations" ADD CONSTRAINT "FK_9bc689175cd305ff8403d64b4a6" FOREIGN KEY ("calculatorId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "calculations" DROP CONSTRAINT "FK_9bc689175cd305ff8403d64b4a6"`); + await queryRunner.query(`ALTER TABLE "references" DROP CONSTRAINT "FK_98fc39160edc395b34d1960ed87"`); + await queryRunner.query(`DROP TABLE "calculations"`); + await queryRunner.query(`DROP TABLE "references"`); + } + +} diff --git a/platforms/eReputation-api/src/index.ts b/platforms/eReputation-api/src/index.ts new file mode 100644 index 00000000..8269f06e --- /dev/null +++ b/platforms/eReputation-api/src/index.ts @@ -0,0 +1,116 @@ +import "reflect-metadata"; +import path from "node: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 { ReferenceController } from "./controllers/ReferenceController"; +import { CalculationController } from "./controllers/CalculationController"; +import { PlatformController } from "./controllers/PlatformController"; +import { GroupController } from "./controllers/GroupController"; +import { DashboardController } from "./controllers/DashboardController"; +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 || 8765; + +// 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 referenceController = new ReferenceController(); +const calculationController = new CalculationController(); +const platformController = new PlatformController(); +const groupController = new GroupController(); +const dashboardController = new DashboardController(); + +// Health check endpoint +app.get("/api/health", (req, res) => { + 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); + +// Platform routes (public) +app.get("/api/platforms", platformController.getPlatforms); +app.get("/api/platforms/search", platformController.searchPlatforms); + +// 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); + +// Dashboard routes +app.get("/api/dashboard/stats", authGuard, dashboardController.getStats); +app.get("/api/dashboard/activities", authGuard, dashboardController.getActivities); + +// Reference routes +app.post("/api/references", authGuard, referenceController.createReference); +app.get("/api/references/target/:targetType/:targetId", referenceController.getReferencesForTarget); +app.get("/api/references/my", authGuard, referenceController.getUserReferences); +app.patch("/api/references/:referenceId/revoke", authGuard, referenceController.revokeReference); + +// Calculation routes +app.post("/api/reputation/calculate", authGuard, calculationController.calculateReputation); +app.get("/api/reputation/calculations/:calculationId", authGuard, calculationController.getCalculationResult); +app.get("/api/reputation/calculations/my", authGuard, calculationController.getUserCalculations); + +// Start server +app.listen(port, () => { + console.log(`eReputation API server running on port ${port}`); +}); diff --git a/platforms/eReputation-api/src/middleware/auth.ts b/platforms/eReputation-api/src/middleware/auth.ts new file mode 100644 index 00000000..ec615e41 --- /dev/null +++ b/platforms/eReputation-api/src/middleware/auth.ts @@ -0,0 +1,45 @@ +import type { NextFunction, Request, Response } from "express"; +import { AppDataSource } from "../database/data-source"; +import { User } from "../database/entities/User"; +import { verifyToken } 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 = verifyToken(token) as { userId: string }; + + if (!decoded?.userId) { + return res.status(401).json({ error: "Invalid 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/eReputation-api/src/services/CalculationService.ts b/platforms/eReputation-api/src/services/CalculationService.ts new file mode 100644 index 00000000..c89f03a9 --- /dev/null +++ b/platforms/eReputation-api/src/services/CalculationService.ts @@ -0,0 +1,168 @@ +import { Repository } from "typeorm"; +import { AppDataSource } from "../database/data-source"; +import { Calculation } from "../database/entities/Calculation"; +import { Reference } from "../database/entities/Reference"; +import OpenAI from "openai"; + +export class CalculationService { + calculationRepository: Repository; + referenceRepository: Repository; + private openai: OpenAI; + + constructor() { + this.calculationRepository = AppDataSource.getRepository(Calculation); + this.referenceRepository = AppDataSource.getRepository(Reference); + + if (!process.env.OPENAI_API_KEY) { + throw new Error("OPENAI_API_KEY environment variable is required"); + } + + this.openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, + }); + } + + async createCalculation(data: { + targetType: string; + targetId: string; + targetName: string; + userValues: string; + calculatorId: string; + }): Promise { + const calculation = this.calculationRepository.create({ + ...data, + status: "processing", + calculatedScore: 0 + }); + return await this.calculationRepository.save(calculation); + } + + async calculateReputation(calculationId: string): Promise { + const calculation = await this.calculationRepository.findOne({ + where: { id: calculationId } + }); + + if (!calculation) { + throw new Error("Calculation not found"); + } + + try { + // Get all references for this target + const references = await this.referenceRepository.find({ + where: { + targetType: calculation.targetType, + targetId: calculation.targetId, + status: "signed" // Only include signed references + }, + relations: ["author"] + }); + + if (references.length === 0) { + calculation.calculatedScore = 0; + calculation.status = "complete"; + calculation.calculationDetails = JSON.stringify({ + message: "No references found for this target", + referencesCount: 0 + }); + return await this.calculationRepository.save(calculation); + } + + // Prepare data for OpenAI + const referencesData = references.map(ref => ({ + content: ref.content, + numericScore: ref.numericScore, + author: ref.author.ename || ref.author.name || "Anonymous" + })); + + const prompt = this.buildPrompt(calculation.userValues, referencesData, calculation.targetName); + + const response = await this.openai.chat.completions.create({ + model: "gpt-4", + messages: [ + { + role: "system", + content: "You are an expert reputation analyst. You analyze references and calculate reputation scores based on user values. Always respond with a valid JSON object containing a score (1-5) and explanation." + }, + { + role: "user", + content: prompt + } + ], + temperature: 0.3, + max_tokens: 1000 + }); + + const result = JSON.parse(response.choices[0].message.content || "{}"); + + calculation.calculatedScore = Math.max(1, Math.min(5, result.score || 0)); + calculation.status = "complete"; + calculation.calculationDetails = JSON.stringify({ + explanation: result.explanation || "No explanation provided", + referencesCount: references.length, + referencesAnalyzed: referencesData.length, + userValues: calculation.userValues + }); + + return await this.calculationRepository.save(calculation); + + } catch (error) { + console.error("Error calculating reputation:", error); + calculation.status = "error"; + calculation.calculationDetails = JSON.stringify({ + error: error instanceof Error ? error.message : "Unknown error" + }); + return await this.calculationRepository.save(calculation); + } + } + + private buildPrompt(userValues: string, references: any[], targetName: string): string { + return ` +You are analyzing the reputation of "${targetName}" based on the following user values and references. + +USER VALUES (what the evaluator cares about): +${userValues} + +REFERENCES (what others have said about ${targetName}): +${references.map(ref => ` +Reference from ${ref.author} (Score: ${ref.numericScore}/5): +"${ref.content}" +`).join('\n')} + +TASK: +Based on the user's values and the references provided, calculate a reputation score from 1-5 for ${targetName}. + +IMPORTANT: The score must be between 1 and 5 (inclusive). Use the full range: +- 1 = Very poor reputation +- 2 = Poor reputation +- 3 = Average reputation +- 4 = Good reputation +- 5 = Excellent reputation + +Consider: +1. How well the references align with what the user values +2. The quality and consistency of the references +3. The numeric scores given by the reference authors +4. Whether the references address the user's specific values + +Respond with a JSON object in this exact format: +{ + "score": , + "explanation": "" +} + `.trim(); + } + + async getCalculationById(id: string): Promise { + return await this.calculationRepository.findOne({ + where: { id }, + relations: ["calculator"] + }); + } + + async getUserCalculations(calculatorId: string): Promise { + return await this.calculationRepository.find({ + where: { calculatorId }, + order: { createdAt: "DESC" } + }); + } +} diff --git a/platforms/eReputation-api/src/services/GroupService.ts b/platforms/eReputation-api/src/services/GroupService.ts new file mode 100644 index 00000000..cca55222 --- /dev/null +++ b/platforms/eReputation-api/src/services/GroupService.ts @@ -0,0 +1,78 @@ +import { Repository } 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.findByIds(adminIds); + group.admins = admins; + } + + // Add participants + if (participantIds.length > 0) { + const participants = await this.userRepository.findByIds(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(); + } +} diff --git a/platforms/eReputation-api/src/services/PlatformService.ts b/platforms/eReputation-api/src/services/PlatformService.ts new file mode 100644 index 00000000..c6b323ef --- /dev/null +++ b/platforms/eReputation-api/src/services/PlatformService.ts @@ -0,0 +1,157 @@ +import axios from "axios"; + +export interface Platform { + id: string; + name: string; + description: string; + category: string; + logoUrl?: string; + url?: string; + appStoreUrl?: string; + playStoreUrl?: string; +} + +export class PlatformService { + private marketplaceData: Platform[] = [ + { + id: "eid-wallet", + name: "eID for W3DS", + description: "Secure digital identity wallet for W3DS. Maintain sovereign control over your digital identity.", + category: "Identity", + logoUrl: "/eid-w3ds.png", + appStoreUrl: "https://apps.apple.com/in/app/eid-for-w3ds/id6747748667", + playStoreUrl: "https://play.google.com/store/apps/details?id=foundation.metastate.eid_wallet" + }, + { + id: "blabsy", + name: "Blabsy", + description: "Micro blogging first style application for sharing thoughts across the W3DS ecosystem.", + category: "Social", + logoUrl: "/blabsy.svg", + url: "http://localhost:4444" + }, + { + id: "pictique", + name: "Pictique", + description: "Photo sharing first style application for sharing moments across the W3DS ecosystem.", + category: "Social", + logoUrl: "/pictique.svg", + url: "http://localhost:1111" + }, + { + id: "evoting", + name: "eVoting", + description: "Secure, transparent, and verifiable electronic voting platform with cryptographic guarantees.", + category: "Governance", + logoUrl: "/evoting.png", + url: "http://localhost:7777" + }, + { + id: "group-charter", + name: "Charter Manager", + description: "Define rules, manage memberships, and ensure transparent governance for your communities.", + category: "Governance", + logoUrl: "/charter.png", + url: "http://localhost:5555" + }, + { + id: "dreamsync", + name: "DreamSync", + description: "Individual discovery platform, find people of interest across the W3DS ecosystem.", + category: "Wellness", + logoUrl: undefined, + url: "https://dreamsync.w3ds.metastate.foundation" + } + ]; + + async getActivePlatforms(): Promise { + try { + const registryUrl = process.env.PUBLIC_REGISTRY_URL || "http://localhost:3000"; + const response = await axios.get(`${registryUrl}/platforms`); + + if (response.data && Array.isArray(response.data)) { + // Map registry URLs to marketplace data + return response.data.map((url: string) => { + const platformId = this.extractPlatformIdFromUrl(url); + const marketplacePlatform = this.marketplaceData.find(p => p.id === platformId); + + if (marketplacePlatform) { + return { + ...marketplacePlatform, + url: url // Use the actual registry URL + }; + } + + // Fallback for unknown platforms + return { + id: platformId, + name: platformId.charAt(0).toUpperCase() + platformId.slice(1), + description: `Platform at ${url}`, + category: "Unknown", + url: url + }; + }); + } + + // Fallback to marketplace data if registry is not available + console.warn("Registry not available, using marketplace data"); + return this.marketplaceData; + + } catch (error) { + console.error("Error fetching platforms from registry:", error); + // Fallback to marketplace data + return this.marketplaceData; + } + } + + async searchPlatforms(query: string): Promise { + const platforms = await this.getActivePlatforms(); + const lowercaseQuery = query.toLowerCase(); + + return platforms.filter(platform => + platform.name.toLowerCase().includes(lowercaseQuery) || + platform.description.toLowerCase().includes(lowercaseQuery) || + platform.category.toLowerCase().includes(lowercaseQuery) + ); + } + + private extractPlatformIdFromUrl(url: string): string { + try { + const urlObj = new URL(url); + const hostname = urlObj.hostname; + + // Handle localhost URLs for testing (including local network IPs) + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname.startsWith('192.168.') || hostname.startsWith('10.') || hostname.startsWith('172.')) { + const port = urlObj.port; + // Map localhost ports to platform IDs + switch (port) { + case '4444': return 'blabsy'; + case '1111': return 'pictique'; + case '5555': return 'group-charter'; + case '7777': return 'evoting'; + case '8765': return 'ereputation'; + default: return `platform-${port}`; + } + } + + // Extract from production URLs + if (hostname.includes('blabsy')) return 'blabsy'; + if (hostname.includes('pictique')) return 'pictique'; + if (hostname.includes('evoting')) return 'evoting'; + if (hostname.includes('charter')) return 'group-charter'; + if (hostname.includes('dreamsync')) return 'dreamsync'; + if (hostname.includes('ereputation')) return 'ereputation'; + + // Fallback: use subdomain or path + const parts = hostname.split('.'); + if (parts.length > 0) { + return parts[0]; + } + + return 'unknown-platform'; + } catch (error) { + console.error("Error extracting platform ID from URL:", url, error); + return 'unknown-platform'; + } + } +} diff --git a/platforms/eReputation-api/src/services/ReferenceService.ts b/platforms/eReputation-api/src/services/ReferenceService.ts new file mode 100644 index 00000000..ff328691 --- /dev/null +++ b/platforms/eReputation-api/src/services/ReferenceService.ts @@ -0,0 +1,55 @@ +import { Repository } from "typeorm"; +import { AppDataSource } from "../database/data-source"; +import { Reference } from "../database/entities/Reference"; + +export class ReferenceService { + referenceRepository: Repository; + + constructor() { + this.referenceRepository = AppDataSource.getRepository(Reference); + } + + async createReference(data: { + targetType: string; + targetId: string; + targetName: string; + content: string; + referenceType: string; + numericScore?: number; + authorId: string; + }): Promise { + const reference = this.referenceRepository.create({ + ...data, + status: "signed" + }); + return await this.referenceRepository.save(reference); + } + + async getReferencesForTarget(targetType: string, targetId: string): Promise { + return await this.referenceRepository.find({ + where: { targetType, targetId }, + relations: ["author"], + order: { createdAt: "DESC" } + }); + } + + async getUserReferences(authorId: string): Promise { + return await this.referenceRepository.find({ + where: { authorId }, + order: { createdAt: "DESC" } + }); + } + + async revokeReference(referenceId: string, authorId: string): Promise { + const reference = await this.referenceRepository.findOne({ + where: { id: referenceId, authorId } + }); + + if (!reference) { + return null; + } + + reference.status = "revoked"; + return await this.referenceRepository.save(reference); + } +} diff --git a/platforms/eReputation-api/src/services/UserService.ts b/platforms/eReputation-api/src/services/UserService.ts new file mode 100644 index 00000000..898ff5e2 --- /dev/null +++ b/platforms/eReputation-api/src/services/UserService.ts @@ -0,0 +1,66 @@ +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/eReputation-api/src/types/express.ts b/platforms/eReputation-api/src/types/express.ts new file mode 100644 index 00000000..fd43b899 --- /dev/null +++ b/platforms/eReputation-api/src/types/express.ts @@ -0,0 +1,12 @@ +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/eReputation-api/src/utils/jwt.ts b/platforms/eReputation-api/src/utils/jwt.ts new file mode 100644 index 00000000..9c9ef686 --- /dev/null +++ b/platforms/eReputation-api/src/utils/jwt.ts @@ -0,0 +1,11 @@ +import jwt from "jsonwebtoken"; + +const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key"; + +export const signToken = (payload: { userId: string }): string => { + return jwt.sign(payload, JWT_SECRET, { expiresIn: "7d" }); +}; + +export const verifyToken = (token: string): any => { + return jwt.verify(token, JWT_SECRET); +}; diff --git a/platforms/eReputation-api/src/web3adapter/index.ts b/platforms/eReputation-api/src/web3adapter/index.ts new file mode 100644 index 00000000..9414ffa4 --- /dev/null +++ b/platforms/eReputation-api/src/web3adapter/index.ts @@ -0,0 +1 @@ +export { adapter } from "./watchers/subscriber"; diff --git a/platforms/eReputation-api/src/web3adapter/mappings/group.mapping.json b/platforms/eReputation-api/src/web3adapter/mappings/group.mapping.json new file mode 100644 index 00000000..4790736e --- /dev/null +++ b/platforms/eReputation-api/src/web3adapter/mappings/group.mapping.json @@ -0,0 +1,24 @@ +{ + "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/eReputation-api/src/web3adapter/mappings/user.mapping.json b/platforms/eReputation-api/src/web3adapter/mappings/user.mapping.json new file mode 100644 index 00000000..ee718b6a --- /dev/null +++ b/platforms/eReputation-api/src/web3adapter/mappings/user.mapping.json @@ -0,0 +1,22 @@ +{ + "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/eReputation-api/src/web3adapter/watchers/subscriber.ts b/platforms/eReputation-api/src/web3adapter/watchers/subscriber.ts new file mode 100644 index 00000000..89fca1c1 --- /dev/null +++ b/platforms/eReputation-api/src/web3adapter/watchers/subscriber.ts @@ -0,0 +1,393 @@ +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.EREPUTATION_MAPPING_DB_PATH as string), + registryUrl: process.env.PUBLIC_REGISTRY_URL as string, + platform: process.env.VITE_EREPUTATION_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 }; + + // Handle author enrichment (for backward compatibility) + if (entity.author) { + const author = await AppDataSource.getRepository( + "User" + ).findOne({ where: { id: entity.author.id } }); + enrichedEntity.author = author; + } + + 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"]; + 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/eReputation-api/tsconfig.json b/platforms/eReputation-api/tsconfig.json new file mode 100644 index 00000000..755b23e9 --- /dev/null +++ b/platforms/eReputation-api/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "module": "commonjs", + "moduleResolution": "node", + "rootDir": "./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/eReputation/client/src/App.tsx b/platforms/eReputation/client/src/App.tsx index 2f8a1fcb..3e30d84e 100644 --- a/platforms/eReputation/client/src/App.tsx +++ b/platforms/eReputation/client/src/App.tsx @@ -3,10 +3,11 @@ import { queryClient } from "./lib/queryClient"; import { QueryClientProvider } from "@tanstack/react-query"; import { Toaster } from "@/components/ui/toaster"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { AuthProvider } from "@/lib/auth-context"; import { useAuth } from "@/hooks/useAuth"; import AuthPage from "@/pages/auth-page"; import Dashboard from "@/pages/dashboard"; -import References from "@/pages/references"; +import References from "@/pages/references-page"; import NotFound from "@/pages/not-found"; function Router() { @@ -29,12 +30,14 @@ function Router() { function App() { return ( - - - - + + + + + + ); } -export default App; +export default App; \ No newline at end of file diff --git a/platforms/eReputation/client/src/components/auth/login-screen.tsx b/platforms/eReputation/client/src/components/auth/login-screen.tsx new file mode 100644 index 00000000..8130596d --- /dev/null +++ b/platforms/eReputation/client/src/components/auth/login-screen.tsx @@ -0,0 +1,162 @@ +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 { Star } 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) { + console.error("❌ Failed to get auth offer:", error); + console.error("❌ Error details:", error.response?.data); + console.error("❌ Error status:", error.response?.status); + setIsLoading(false); + } + }; + + getAuthOffer(); + }, []); + + useEffect(() => { + if (!sessionId) return; + + const eventSource = new EventSource( + `${import.meta.env.VITE_EREPUTATION_BASE_URL || "http://localhost:8765"}/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("ereputation_token", data.token); + localStorage.setItem("ereputation_user_id", data.user.id); + // Redirect to home page + window.location.href = "/"; + } + } 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 ( +
+
+
+
+ +
+

eReputation

+
+

+ Manage your reputation 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 eReputation - a social reputation 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 + +
+
+ ); +} \ No newline at end of file diff --git a/platforms/eReputation/client/src/components/auth/protected-route.tsx b/platforms/eReputation/client/src/components/auth/protected-route.tsx new file mode 100644 index 00000000..dbe35a06 --- /dev/null +++ b/platforms/eReputation/client/src/components/auth/protected-route.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { Navigate, useLocation } from "wouter"; +import { useAuth } from "@/hooks/useAuth"; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +export function ProtectedRoute({ children }: ProtectedRouteProps) { + const { isAuthenticated, isLoading } = useAuth(); + const [location] = useLocation(); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} diff --git a/platforms/eReputation/client/src/components/modals/other-calculation-modal.tsx b/platforms/eReputation/client/src/components/modals/other-calculation-modal.tsx index 2e2eacf2..d03b62ca 100644 --- a/platforms/eReputation/client/src/components/modals/other-calculation-modal.tsx +++ b/platforms/eReputation/client/src/components/modals/other-calculation-modal.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; import { useDebouncedCallback } from 'use-debounce'; -import { apiRequest } from "@/lib/queryClient"; +import { apiClient } from "@/lib/apiClient"; import { useToast } from "@/hooks/use-toast"; import { isUnauthorizedError } from "@/lib/authUtils"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; @@ -10,6 +10,7 @@ import { Progress } from "@/components/ui/progress"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import ViewReputationModal from "./view-reputation-modal"; @@ -101,6 +102,7 @@ export default function OtherCalculationModal({ open, onOpenChange }: OtherCalcu const [progress, setProgress] = useState(0); const [showViewModal, setShowViewModal] = useState(false); const [reputationResult, setReputationResult] = useState(null); + const [userValues, setUserValues] = useState(""); const { toast } = useToast(); const queryClient = useQueryClient(); @@ -116,31 +118,40 @@ export default function OtherCalculationModal({ open, onOpenChange }: OtherCalcu } }, [isCalculating, currentStep]); - const debouncedSearch = useDebouncedCallback((query: string) => { - if (query.length >= 2) { - refetch(); - } - }, 300); - const { data: searchResults = [], refetch } = useQuery({ - queryKey: ['/api/search', targetType, searchQuery], - queryFn: () => { + const { data: searchResults = [], refetch, isLoading: isSearching } = useQuery({ + queryKey: ['search', targetType, searchQuery], + queryFn: async () => { if (!targetType || searchQuery.length < 2) return []; - const endpoint = `/api/search/${targetType}s?q=${encodeURIComponent(searchQuery)}`; - return fetch(endpoint, { credentials: "include" }).then(res => res.json()); + + if (targetType === 'platform') { + // Search platforms using the new platform endpoint + const response = await apiClient.get(`/api/platforms/search?q=${encodeURIComponent(searchQuery)}`); + return response.data.platforms || response.data || []; + } else if (targetType === 'user') { + // Search users using existing endpoint + const response = await apiClient.get(`/api/users/search?q=${encodeURIComponent(searchQuery)}`); + return response.data; + } else if (targetType === 'group') { + // Search groups using new endpoint + const response = await apiClient.get(`/api/groups/search?q=${encodeURIComponent(searchQuery)}`); + return response.data; + } + return []; }, - enabled: false, + enabled: targetType !== "" && searchQuery.length >= 2, }); const calculateMutation = useMutation({ mutationFn: async () => { - const response = await apiRequest("POST", "/api/reputation/calculate", { + const response = await apiClient.post("/api/reputation/calculate", { targetType: targetType, targetId: selectedTarget?.id || '', targetName: selectedTarget?.name || selectedTarget?.title || 'Unknown', + userValues: userValues.trim(), variables: ALL_VARIABLES }); - return response.json(); + return response.data; }, onError: (error) => { setIsCalculating(false); @@ -190,14 +201,11 @@ export default function OtherCalculationModal({ open, onOpenChange }: OtherCalcu setCurrentStep(0); setProgress(0); setReputationResult(null); + setUserValues(""); }; const handleSearchChange = (value: string) => { setSearchQuery(value); - // Trigger search if query is long enough - if (value.length >= 2) { - debouncedSearch(value); - } // Don't automatically set selected target, let user pick from results if (!value.trim()) { setSelectedTarget(null); @@ -228,6 +236,15 @@ export default function OtherCalculationModal({ open, onOpenChange }: OtherCalcu return; } + if (!userValues.trim()) { + toast({ + title: "Missing Values", + description: "Please describe what qualities you value in people", + variant: "destructive", + }); + return; + } + setIsCalculating(true); setCurrentStep(0); setProgress(0); @@ -409,49 +426,44 @@ export default function OtherCalculationModal({ open, onOpenChange }: OtherCalcu {/* Search Results Dropdown - Absolute positioned overlay */} - {searchQuery.length >= 2 && searchResults.length > 0 && !selectedTarget && ( + {searchQuery.length >= 2 && !selectedTarget && (
- {searchResults.map((result: any, index: number) => ( -
- - ))} -
- )} - - {/* Manual Entry Option */} - {searchQuery.length >= 2 && !selectedTarget && ( -
- + )) + ) : ( +
+ No {targetType}s found for "{searchQuery}"
- + )}
)} @@ -481,6 +493,23 @@ export default function OtherCalculationModal({ open, onOpenChange }: OtherCalcu )} + + {/* User Values Input */} +
+ +