From 133cd502e5f78b55e96bf592b2dbd7dd01f12498 Mon Sep 17 00:00:00 2001 From: SoSweetHam Date: Sun, 31 May 2026 11:49:05 +0530 Subject: [PATCH 1/2] refactor(profile-editor): rebuild backend on web3-adapter for 2-way sync Replaces the bespoke eVault-direct backend with the standard web3-adapter platform pattern, matching pictique/blabsy/dreamsync. - Local Postgres is the source of truth (users + professional_profiles). PostgresSubscriber pushes local writes to the eVault via toGlobal; POST /api/webhook ingests inbound changes via fromGlobal; AaaS is the delivery transport. lockedIds prevents echo loops. - Base identity (name/avatar/banner) syncs 2-way with all platforms via the User ontology; professional profile + the isPublic gate sync with dreamsync. - Files go to eVault blob storage via __file / EVaultClient.uploadFile (no file-manager, no bytea); the client stores the returned public URL (~20-line change: render the URL directly, drop the asset-proxy route). - Retains auth (offer/SSE/login), zod validation, and AaaS subscription bootstrap. Drops the hand-rolled GraphQL sync, search index, and bytea. The previous backend is removed; a local read-only copy was kept under reference/ and is intentionally not committed (deprecated). Co-Authored-By: Claude Opus 4.8 --- .env.example | 12 + platforms/profile-editor/api/nodemon.json | 6 + platforms/profile-editor/api/package.json | 20 +- platforms/profile-editor/api/src/aaas.ts | 55 ++ .../api/src/controllers/AuthController.ts | 134 ----- .../src/controllers/DiscoveryController.ts | 49 -- .../src/controllers/FileProxyController.ts | 69 --- .../api/src/controllers/ProfileController.ts | 318 ----------- .../api/src/controllers/WebhookController.ts | 157 ------ .../api/src/controllers/auth.ts | 91 +++ .../api/src/controllers/discover.ts | 110 ++++ .../api/src/controllers/files.ts | 49 ++ .../api/src/controllers/profile.ts | 185 ++++++ .../api/src/controllers/webhook.ts | 63 +++ .../api/src/database/data-source.ts | 32 -- .../api/src/database/entities/Session.ts | 29 - .../api/src/database/entities/User.ts | 55 -- .../migrations/1773143278029-InitialSchema.ts | 18 - ...1775600000000-RenameAvatarBannerColumns.ts | 15 - platforms/profile-editor/api/src/db.ts | 25 + .../api/src/entities/ProfessionalProfile.ts | 72 +++ .../profile-editor/api/src/entities/User.ts | 55 ++ platforms/profile-editor/api/src/env.ts | 38 ++ platforms/profile-editor/api/src/index.ts | 185 +++--- .../profile-editor/api/src/middleware/auth.ts | 52 +- .../api/src/migrations/1700000000000-Init.ts | 59 ++ platforms/profile-editor/api/src/schemas.ts | 83 +++ .../api/src/services/EVaultProfileService.ts | 525 ------------------ .../api/src/services/EVaultSyncService.ts | 133 ----- .../services/ProfessionalProfileService.ts | 60 ++ .../api/src/services/RegistryService.ts | 78 --- .../api/src/services/UserSearchService.ts | 181 ------ .../api/src/services/UserService.ts | 55 ++ .../profile-editor/api/src/types/express.d.ts | 18 +- .../profile-editor/api/src/types/profile.ts | 121 ++-- .../api/src/utils/file-proxy.ts | 235 -------- .../professional-profile.mapping.json | 34 +- .../web3adapter/mappings/user.mapping.json | 34 +- .../src/web3adapter/watchers/subscriber.ts | 212 +++---- platforms/profile-editor/api/tsconfig.json | 5 +- .../profile/DocumentsSection.svelte | 18 +- .../lib/components/profile/ProfileCard.svelte | 12 +- .../components/profile/ProfileHeader.svelte | 24 +- .../client/src/lib/utils/file-manager.ts | 10 +- pnpm-lock.yaml | 85 ++- 45 files changed, 1393 insertions(+), 2483 deletions(-) create mode 100644 platforms/profile-editor/api/nodemon.json create mode 100644 platforms/profile-editor/api/src/aaas.ts delete mode 100644 platforms/profile-editor/api/src/controllers/AuthController.ts delete mode 100644 platforms/profile-editor/api/src/controllers/DiscoveryController.ts delete mode 100644 platforms/profile-editor/api/src/controllers/FileProxyController.ts delete mode 100644 platforms/profile-editor/api/src/controllers/ProfileController.ts delete mode 100644 platforms/profile-editor/api/src/controllers/WebhookController.ts create mode 100644 platforms/profile-editor/api/src/controllers/auth.ts create mode 100644 platforms/profile-editor/api/src/controllers/discover.ts create mode 100644 platforms/profile-editor/api/src/controllers/files.ts create mode 100644 platforms/profile-editor/api/src/controllers/profile.ts create mode 100644 platforms/profile-editor/api/src/controllers/webhook.ts delete mode 100644 platforms/profile-editor/api/src/database/data-source.ts delete mode 100644 platforms/profile-editor/api/src/database/entities/Session.ts delete mode 100644 platforms/profile-editor/api/src/database/entities/User.ts delete mode 100644 platforms/profile-editor/api/src/database/migrations/1773143278029-InitialSchema.ts delete mode 100644 platforms/profile-editor/api/src/database/migrations/1775600000000-RenameAvatarBannerColumns.ts create mode 100644 platforms/profile-editor/api/src/db.ts create mode 100644 platforms/profile-editor/api/src/entities/ProfessionalProfile.ts create mode 100644 platforms/profile-editor/api/src/entities/User.ts create mode 100644 platforms/profile-editor/api/src/env.ts create mode 100644 platforms/profile-editor/api/src/migrations/1700000000000-Init.ts create mode 100644 platforms/profile-editor/api/src/schemas.ts delete mode 100644 platforms/profile-editor/api/src/services/EVaultProfileService.ts delete mode 100644 platforms/profile-editor/api/src/services/EVaultSyncService.ts create mode 100644 platforms/profile-editor/api/src/services/ProfessionalProfileService.ts delete mode 100644 platforms/profile-editor/api/src/services/RegistryService.ts delete mode 100644 platforms/profile-editor/api/src/services/UserSearchService.ts create mode 100644 platforms/profile-editor/api/src/services/UserService.ts delete mode 100644 platforms/profile-editor/api/src/utils/file-proxy.ts diff --git a/.env.example b/.env.example index b78105e49..115e448f7 100644 --- a/.env.example +++ b/.env.example @@ -97,6 +97,11 @@ PUBLIC_PROVISIONER_SHARED_SECRET="your-provisioner-shared-secret" PUBLIC_ESIGNER_BASE_URL="http://localhost:3004" PUBLIC_FILE_MANAGER_BASE_URL="http://localhost:3005" PUBLIC_PROFILE_EDITOR_BASE_URL=http://localhost:3007 +PROFILE_EDITOR_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/profile_editor +PROFILE_EDITOR_JWT_SECRET="secret" +# web3-adapter SQLite id-map directory (localId <-> eVault globalId) +PROFILE_EDITOR_MAPPING_DB_PATH="/path/to/profile-editor/mapping/db" +# PROFILE_EDITOR_API_PORT=3007 # optional, defaults to 3007 DREAMSYNC_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dreamsync VITE_DREAMSYNC_BASE_URL="http://localhost:8888" @@ -140,6 +145,13 @@ AWARENESS_PUBLIC_URL="http://localhost:4100" AWARENESS_INGEST_SECRET="replace-with-a-strong-secret" # Where evault-core forwards every awareness packet AWARENESS_SERVICE_URL="http://localhost:4100" +# Consumer API key (aaas_...) the profile-editor backend uses to register its +# webhook subscription and poll AaaS. Issued from the AaaS portal after approval. +AWARENESS_API_KEY="" +# Public URL AaaS should POST awareness packets to. Override for local dev +# against prod AaaS — set it to your Cloudflare tunnel, e.g. +# https://xyz.trycloudflare.com/api/webhook. Defaults to {base}/api/webhook. +AWARENESS_WEBHOOK_URL="" # Comma-separated eNames allowed to act as AaaS portal admins AAAS_ADMIN_ENAMES="" diff --git a/platforms/profile-editor/api/nodemon.json b/platforms/profile-editor/api/nodemon.json new file mode 100644 index 000000000..cc8c3486b --- /dev/null +++ b/platforms/profile-editor/api/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["src"], + "ext": "ts,json", + "ignore": ["src/**/*.spec.ts"], + "exec": "ts-node src/index.ts" +} diff --git a/platforms/profile-editor/api/package.json b/platforms/profile-editor/api/package.json index 2320e88a5..69125dd8c 100644 --- a/platforms/profile-editor/api/package.json +++ b/platforms/profile-editor/api/package.json @@ -1,16 +1,16 @@ { "name": "profile-editor-api", "version": "1.0.0", - "description": "Profile Editor API for the w3ds ecosystem", + "description": "Profile Editor API for the w3ds ecosystem (AaaS-based)", "main": "src/index.ts", "scripts": { "start": "ts-node src/index.ts", "dev": "nodemon --exec ts-node src/index.ts", - "build": "tsc && cp -r src/web3adapter/mappings dist/web3adapter/", + "build": "tsc", "typeorm": "typeorm-ts-node-commonjs", - "migration:generate": "bash -c 'read -p \"Migration name: \" name && npx typeorm-ts-node-commonjs migration:generate src/database/migrations/$name -d src/database/data-source.ts'", - "migration:run": "npm run typeorm migration:run -- -d src/database/data-source.ts", - "migration:revert": "npm run typeorm migration:revert -- -d src/database/data-source.ts" + "migration:generate": "bash -c 'read -p \"Migration name: \" name && npx typeorm-ts-node-commonjs migration:generate src/migrations/$name -d src/db.ts'", + "migration:run": "npm run typeorm migration:run -- -d src/db.ts", + "migration:revert": "npm run typeorm migration:revert -- -d src/db.ts" }, "dependencies": { "@metastate-foundation/auth": "workspace:*", @@ -18,25 +18,19 @@ "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.2", - "form-data": "^4.0.5", - "graphql-request": "^6.1.0", - "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", "pg": "^8.20.0", "reflect-metadata": "^0.2.2", - "signature-validator": "workspace:*", - "web3-adapter": "workspace:*", "typeorm": "^0.3.28", - "uuid": "^9.0.1" + "web3-adapter": "workspace:*", + "zod": "^3.23.8" }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", - "@types/jsonwebtoken": "^9.0.10", "@types/multer": "^2.1.0", "@types/node": "^20.11.24", "@types/pg": "^8.18.0", - "@types/uuid": "^9.0.8", "nodemon": "^3.0.3", "ts-node": "^10.9.2", "typescript": "^5.3.3" diff --git a/platforms/profile-editor/api/src/aaas.ts b/platforms/profile-editor/api/src/aaas.ts new file mode 100644 index 000000000..bbef742bd --- /dev/null +++ b/platforms/profile-editor/api/src/aaas.ts @@ -0,0 +1,55 @@ +import axios from "axios"; +import { env } from "./env"; + +const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; +const PROFESSIONAL_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440009"; + +/** + * Registers our `/api/webhook` as an AaaS subscription for the user and + * professional-profile ontologies (idempotent — skips if one already targets + * us). Logs and continues on failure so a down AaaS never blocks startup. + */ +export async function registerSubscriptionOnStartup(): Promise { + if (!env.awarenessApiKey) { + console.warn( + "[aaas] AWARENESS_API_KEY not set — skipping subscription registration", + ); + return; + } + + const targetUrl = + env.awarenessWebhookUrl || + `${env.baseUrl.replace(/\/$/, "")}/api/webhook`; + const headers = { Authorization: `Bearer ${env.awarenessApiKey}` }; + const base = env.awarenessServiceUrl.replace(/\/$/, ""); + + try { + const { data } = await axios.get<{ + subscriptions: Array<{ targetUrl: string }>; + }>(`${base}/api/subscriptions`, { headers, timeout: 10000 }); + + if (data.subscriptions?.some((s) => s.targetUrl === targetUrl)) { + console.log("[aaas] subscription already registered"); + return; + } + + await axios.post( + `${base}/api/subscriptions`, + { + targetUrl, + ontologyFilter: [USER_ONTOLOGY, PROFESSIONAL_PROFILE_ONTOLOGY], + evaultFilter: [], + }, + { headers, timeout: 10000 }, + ); + console.log(`[aaas] subscription registered -> ${targetUrl}`); + } catch (error) { + const message = axios.isAxiosError(error) + ? (error.response?.data?.error ?? error.message) + : (error as Error).message; + console.error( + "[aaas] subscription registration failed (continuing):", + message, + ); + } +} diff --git a/platforms/profile-editor/api/src/controllers/AuthController.ts b/platforms/profile-editor/api/src/controllers/AuthController.ts deleted file mode 100644 index 47b5bd56f..000000000 --- a/platforms/profile-editor/api/src/controllers/AuthController.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Request, Response } from "express"; -import { EventEmitter } from "events"; -import { - buildAuthOffer, - signToken, - verifyLoginSignature, -} from "@metastate-foundation/auth"; -import { registerSession } from "../middleware/auth"; -import type { EVaultProfileService } from "../services/EVaultProfileService"; - -const JWT_SECRET = process.env.PROFILE_EDITOR_JWT_SECRET!; - -export class AuthController { - private eventEmitter: EventEmitter; - private evaultService: EVaultProfileService; - - constructor(evaultService: EVaultProfileService) { - this.eventEmitter = new EventEmitter(); - this.evaultService = evaultService; - } - - 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); - - const heartbeatInterval = setInterval(() => { - try { - res.write(`: heartbeat\n\n`); - } catch { - clearInterval(heartbeatInterval); - } - }, 30000); - - req.on("close", () => { - clearInterval(heartbeatInterval); - this.eventEmitter.off(id, handler); - res.end(); - }); - - req.on("error", () => { - clearInterval(heartbeatInterval); - this.eventEmitter.off(id, handler); - res.end(); - }); - }; - - getOffer = async (_req: Request, res: Response) => { - const baseUrl = process.env.PUBLIC_PROFILE_EDITOR_BASE_URL; - if (!baseUrl) { - return res - .status(500) - .json({ error: "PUBLIC_PROFILE_EDITOR_BASE_URL not configured" }); - } - - const offer = buildAuthOffer({ - baseUrl, - platform: "profile-editor", - }); - - res.json({ uri: offer.uri }); - }; - - login = async (req: Request, res: Response) => { - try { - const { ename, session, 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" }); - } - if (!signature) { - return res.status(400).json({ error: "signature is required" }); - } - - const registryBaseUrl = process.env.PUBLIC_REGISTRY_URL; - if (!registryBaseUrl) { - return res - .status(500) - .json({ error: "Server configuration error" }); - } - - const result = await verifyLoginSignature({ - eName: ename, - signature, - session, - registryBaseUrl, - }); - - if (!result.valid) { - return res.status(401).json({ - error: "Invalid signature", - message: result.error, - }); - } - - const userId = ename; - const token = signToken({ userId }, JWT_SECRET); - await registerSession(userId, ename, token); - - let name: string | undefined; - try { - const profile = await this.evaultService.getProfile(ename); - name = profile.name ?? ename; - } catch { - name = ename; - } - - const data = { - user: { id: userId, ename, name }, - token, - }; - - this.eventEmitter.emit(session, data); - res.status(200).json(data); - } catch (error) { - console.error("Error during login:", error); - res.status(500).json({ error: "Internal server error" }); - } - }; -} diff --git a/platforms/profile-editor/api/src/controllers/DiscoveryController.ts b/platforms/profile-editor/api/src/controllers/DiscoveryController.ts deleted file mode 100644 index e6fe5bc04..000000000 --- a/platforms/profile-editor/api/src/controllers/DiscoveryController.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Request, Response } from "express"; -import { UserSearchService } from "../services/UserSearchService"; - -export class DiscoveryController { - private userSearchService: UserSearchService; - - constructor() { - this.userSearchService = new UserSearchService(); - } - - discover = async (req: Request, res: Response) => { - try { - const { q, page, limit, sortBy } = req.query; - - const pageNum = Math.max(1, parseInt(page as string) || 1); - const limitNum = Math.min( - 100, - Math.max(1, parseInt(limit as string) || 12), - ); - - const query = ((q as string) ?? "").trim(); - - const results = query - ? await this.userSearchService.searchUsers( - query, - pageNum, - limitNum, - (sortBy as string) || "relevance", - ) - : await this.userSearchService.listPublicUsers( - pageNum, - limitNum, - ); - - res.setHeader( - "Cache-Control", - "no-store, no-cache, must-revalidate, proxy-revalidate", - ); - res.setHeader("Pragma", "no-cache"); - res.setHeader("Expires", "0"); - res.json(results); - } catch (error: any) { - console.error("Discovery error:", error.message); - res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - res.status(500).json({ error: "Search service unavailable" }); - } - }; -} diff --git a/platforms/profile-editor/api/src/controllers/FileProxyController.ts b/platforms/profile-editor/api/src/controllers/FileProxyController.ts deleted file mode 100644 index f8a8510bb..000000000 --- a/platforms/profile-editor/api/src/controllers/FileProxyController.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Request, Response, RequestHandler } from "express"; -import multer from "multer"; -import FormData from "form-data"; -import axios from "axios"; -import jwt from "jsonwebtoken"; - -const upload = multer({ - limits: { fileSize: 100 * 1024 * 1024 }, - storage: multer.memoryStorage(), -}); - -function mintFmToken(userId: string): string { - const secret = process.env.FILE_MANAGER_JWT_SECRET; - if (!secret) throw new Error("FILE_MANAGER_JWT_SECRET not configured"); - return jwt.sign({ userId }, secret, { expiresIn: "1h" }); -} - -function getFmBaseUrl(): string { - const url = process.env.PUBLIC_FILE_MANAGER_BASE_URL; - if (!url) throw new Error("PUBLIC_FILE_MANAGER_BASE_URL not configured"); - return url; -} - -async function handleUpload(req: Request, res: Response): Promise { - try { - if (!req.file) { - res.status(400).json({ error: "No file provided" }); - return; - } - - const userId = req.user?.ename; - if (!userId) { - res.status(401).json({ error: "Authentication required" }); - return; - } - - const token = mintFmToken(userId); - const form = new FormData(); - form.append("file", req.file.buffer, { - filename: req.file.originalname, - contentType: req.file.mimetype, - }); - - const response = await axios.post( - `${getFmBaseUrl()}/api/files`, - form, - { - headers: { - ...form.getHeaders(), - Authorization: `Bearer ${token}`, - }, - maxContentLength: Infinity, - maxBodyLength: Infinity, - }, - ); - - res.json(response.data); - } catch (error: any) { - console.error("File proxy error:", error?.response?.data ?? error.message); - const status = error?.response?.status ?? 500; - const message = error?.response?.data?.error ?? "Failed to upload file"; - res.status(status).json({ error: message }); - } -} - -export const fileProxyUpload: RequestHandler[] = [ - upload.single("file") as RequestHandler, - handleUpload as RequestHandler, -]; diff --git a/platforms/profile-editor/api/src/controllers/ProfileController.ts b/platforms/profile-editor/api/src/controllers/ProfileController.ts deleted file mode 100644 index 27b38b256..000000000 --- a/platforms/profile-editor/api/src/controllers/ProfileController.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { Request, Response } from "express"; -import { EVaultProfileService } from "../services/EVaultProfileService"; -import type { EVaultSyncService } from "../services/EVaultSyncService"; -import type { - ProfileUpdatePayload, - ProfessionalProfile, - WorkExperience, - Education, - SocialLink, -} from "../types/profile"; - -export class ProfileController { - private evaultService: EVaultProfileService; - private syncService?: EVaultSyncService; - - constructor(evaultService: EVaultProfileService) { - this.evaultService = evaultService; - } - - setSyncService(syncService: EVaultSyncService) { - this.syncService = syncService; - } - - /** TEMPORARY: allow `?as=ename` query param to impersonate another user for testing. */ - private resolveEname(req: Request): string | undefined { - const override = req.query.as as string | undefined; - if (override) { - console.warn(`[profile] ADMIN OVERRIDE: acting as ${override} (real user: ${req.user?.ename})`); - return override; - } - return req.user?.ename; - } - - /** - * Non-blocking update: reads from eVault, merges, returns the merged - * profile immediately, fires the eVault write in the background. - */ - private async optimisticUpdate( - ename: string, - data: Partial, - res: Response, - ) { - console.log(`[controller] optimisticUpdate ${ename}: keys=[${Object.keys(data).join(",")}] avatar=${(data as any).avatar ?? "N/A"} banner=${(data as any).banner ?? "N/A"}`); - const { profile, persisted } = await this.evaultService.prepareUpdate(ename, data); - console.log(`[controller] optimisticUpdate ${ename}: returning avatar=${profile.professional.avatar ?? "NONE"} banner=${profile.professional.banner ?? "NONE"}`); - // Fire eVault write in background — don't block the response - persisted - .then(() => { - console.log(`[controller] bg write ${ename}: SUCCESS`); - }) - .catch((err) => { - console.error(`[controller] bg write ${ename}: FAILED:`, err.message); - }); - this.syncService?.syncUserToSearchDb(profile); - res.json(profile); - } - - getProfile = async (req: Request, res: Response) => { - try { - const ename = this.resolveEname(req); - if (!ename) { - return res.status(401).json({ error: "Authentication required" }); - } - - const isOwnProfile = !req.query.as && req.user?.ename === ename; - const profile = isOwnProfile - ? await this.evaultService.getProfile(ename) - : await this.evaultService.getFreshProfile(ename); - res.json(profile); - } catch (error: any) { - console.error("Error fetching profile:", error.message); - res.status(500).json({ error: "Failed to fetch profile" }); - } - }; - - updateProfile = async (req: Request, res: Response) => { - try { - const ename = this.resolveEname(req); - if (!ename) { - return res.status(401).json({ error: "Authentication required" }); - } - - const payload: ProfileUpdatePayload = req.body; - console.log(`[profile] PATCH ${ename}:`, Object.keys(payload)); - - await this.optimisticUpdate(ename, payload, res); - } catch (error: any) { - console.error(`[profile] PATCH failed:`, error.message); - res.status(500).json({ error: "Failed to update profile" }); - } - }; - - updateWorkExperience = async (req: Request, res: Response) => { - try { - const ename = this.resolveEname(req); - if (!ename) { - return res.status(401).json({ error: "Authentication required" }); - } - - const workExperience: WorkExperience[] = req.body; - if (!Array.isArray(workExperience)) { - return res - .status(400) - .json({ error: "Body must be an array of work experience entries" }); - } - - await this.optimisticUpdate(ename, { workExperience }, res); - } catch (error: any) { - console.error(`[profile] work-experience failed:`, error.message); - res.status(500).json({ error: "Failed to update work experience" }); - } - }; - - updateEducation = async (req: Request, res: Response) => { - try { - const ename = this.resolveEname(req); - if (!ename) { - return res.status(401).json({ error: "Authentication required" }); - } - - const education: Education[] = req.body; - if (!Array.isArray(education)) { - return res - .status(400) - .json({ error: "Body must be an array of education entries" }); - } - - console.log(`[profile] education ${ename}: ${education.length} entries`); - await this.optimisticUpdate(ename, { education }, res); - } catch (error: any) { - console.error(`[profile] education failed:`, error.message, error.stack); - res.status(500).json({ error: "Failed to update education" }); - } - }; - - updateSkills = async (req: Request, res: Response) => { - try { - const ename = this.resolveEname(req); - if (!ename) { - return res.status(401).json({ error: "Authentication required" }); - } - - const skills: string[] = req.body; - if (!Array.isArray(skills)) { - return res - .status(400) - .json({ error: "Body must be an array of skill strings" }); - } - - await this.optimisticUpdate(ename, { skills }, res); - } catch (error: any) { - console.error(`[profile] skills failed:`, error.message); - res.status(500).json({ error: "Failed to update skills" }); - } - }; - - updateSocialLinks = async (req: Request, res: Response) => { - try { - const ename = this.resolveEname(req); - if (!ename) { - return res.status(401).json({ error: "Authentication required" }); - } - - const socialLinks: SocialLink[] = req.body; - if (!Array.isArray(socialLinks)) { - return res - .status(400) - .json({ error: "Body must be an array of social link entries" }); - } - - await this.optimisticUpdate(ename, { socialLinks }, res); - } catch (error: any) { - console.error(`[profile] social-links failed:`, error.message); - res.status(500).json({ error: "Failed to update social links" }); - } - }; - - getPublicProfile = async (req: Request, res: Response) => { - try { - const { ename } = req.params; - if (!ename) { - return res.status(400).json({ error: "ename is required" }); - } - - const profile = await this.evaultService.getPublicProfile(ename); - if (!profile) { - return res.status(403).json({ error: "This profile is private" }); - } - - res.json(profile); - } catch (error: any) { - console.error("Error fetching public profile:", error.message); - res.status(500).json({ error: "Failed to fetch profile" }); - } - }; - - private canAccessAsset( - profile: { professional: { isPublic?: boolean }; ename: string }, - req: Request, - ): boolean { - if (profile.professional.isPublic) return true; - if (req.user?.ename === profile.ename) return true; - return true; - } - - /** - * Resolve the identity to use for the file-manager JWT. - * Prefer the authenticated user (who actually owns the files), - * fall back to the profile ename for unauthenticated/public access. - */ - private fileOwner(req: Request, profileEname: string): string { - return req.user?.ename ?? profileEname; - } - - getProfileAvatar = async (req: Request, res: Response) => { - try { - const { ename } = req.params; - const profile = await this.evaultService.getProfile(ename); - - if (!this.canAccessAsset(profile, req)) { - return res.status(403).json({ error: "This profile is private" }); - } - - const fileId = profile.professional.avatar; - if (!fileId) { - console.log(`[profile] avatar ${ename}: no fileId set, keys=[${Object.keys(profile.professional).join(",")}]`); - return res.status(404).json({ error: "No avatar set" }); - } - - const owner = this.fileOwner(req, ename); - console.log(`[profile] avatar ${ename}: proxying fileId=${fileId} as=${owner}`); - - const { proxyFileFromFileManager } = await import( - "../utils/file-proxy" - ); - await proxyFileFromFileManager(fileId, owner, res); - } catch (error: any) { - console.error("Error proxying avatar:", error.message); - res.status(500).json({ error: "Failed to fetch avatar" }); - } - }; - - getProfileBanner = async (req: Request, res: Response) => { - try { - const { ename } = req.params; - const profile = await this.evaultService.getProfile(ename); - - if (!this.canAccessAsset(profile, req)) { - return res.status(403).json({ error: "This profile is private" }); - } - - const fileId = profile.professional.banner; - if (!fileId) { - console.log(`[profile] banner ${ename}: no fileId set, keys=[${Object.keys(profile.professional).join(",")}]`); - return res.status(404).json({ error: "No banner set" }); - } - - const owner = this.fileOwner(req, ename); - console.log(`[profile] banner ${ename}: proxying fileId=${fileId} as=${owner}`); - - const { proxyFileFromFileManager } = await import( - "../utils/file-proxy" - ); - await proxyFileFromFileManager(fileId, owner, res); - } catch (error: any) { - console.error("Error proxying banner:", error.message); - res.status(500).json({ error: "Failed to fetch banner" }); - } - }; - - getProfileCv = async (req: Request, res: Response) => { - try { - const { ename } = req.params; - const profile = await this.evaultService.getProfile(ename); - - if (!this.canAccessAsset(profile, req)) { - return res.status(403).json({ error: "This profile is private" }); - } - - const fileId = profile.professional.cvFileId; - if (!fileId) { - return res.status(404).json({ error: "No CV uploaded" }); - } - - const { proxyFileFromFileManager } = await import( - "../utils/file-proxy" - ); - await proxyFileFromFileManager(fileId, this.fileOwner(req, ename), res); - } catch (error: any) { - console.error("Error proxying CV:", error.message); - res.status(500).json({ error: "Failed to fetch CV" }); - } - }; - - getProfileVideo = async (req: Request, res: Response) => { - try { - const { ename } = req.params; - const profile = await this.evaultService.getProfile(ename); - - if (!this.canAccessAsset(profile, req)) { - return res.status(403).json({ error: "This profile is private" }); - } - - const fileId = profile.professional.videoIntroFileId; - if (!fileId) { - return res.status(404).json({ error: "No video uploaded" }); - } - - const { proxyFileFromFileManager } = await import( - "../utils/file-proxy" - ); - await proxyFileFromFileManager(fileId, this.fileOwner(req, ename), res); - } catch (error: any) { - console.error("Error proxying video:", error.message); - res.status(500).json({ error: "Failed to fetch video" }); - } - }; -} diff --git a/platforms/profile-editor/api/src/controllers/WebhookController.ts b/platforms/profile-editor/api/src/controllers/WebhookController.ts deleted file mode 100644 index ed0f91c47..000000000 --- a/platforms/profile-editor/api/src/controllers/WebhookController.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { Request, Response } from "express"; -import { Web3Adapter } from "web3-adapter"; -import { UserSearchService } from "../services/UserSearchService"; -import { downloadUrlAndUploadToFileManager } from "../utils/file-proxy"; - -export class WebhookController { - private userSearchService: UserSearchService; - private adapter: Web3Adapter; - - constructor(adapter: Web3Adapter) { - this.userSearchService = new UserSearchService(); - this.adapter = adapter; - } - - handleWebhook = async (req: Request, res: Response) => { - try { - const schemaId = req.body.schemaId; - const globalId = req.body.id; - console.log(`[webhook] incoming: schemaId=${schemaId} globalId=${globalId} w3id=${req.body.w3id ?? "N/A"} keys=[${Object.keys(req.body.data ?? {}).join(",")}]`); - const mapping = Object.values(this.adapter.mapping).find( - (m) => m.schemaId === schemaId, - ); - - this.adapter.addToLockedIds(globalId); - - if (!mapping) { - return res.status(200).send("Unknown schema, skipping"); - } - - const local = await this.adapter.fromGlobal({ - data: req.body.data, - mapping, - }); - - const localId = await this.adapter.mappingDb.getLocalId(globalId); - - if (mapping.tableName === "users") { - await this.handleUserWebhook( - local.data, - localId, - globalId, - req.body, - ); - } else if (mapping.tableName === "professional_profiles") { - await this.handleProfessionalProfileWebhook( - local.data, - localId, - globalId, - req.body, - ); - } - - res.status(200).send(); - } catch (e) { - console.error("Webhook error:", e); - res.status(200).send(); - } - }; - - private async handleUserWebhook( - localData: any, - localId: string | null, - globalId: string, - rawBody: any, - ) { - const ename = localData.ename || rawBody.w3id; - if (!ename) return; - - const userData: any = { - ename, - handle: localData.handle, - name: rawBody.data?.displayName || localData.name, - bio: localData.bio, - isVerified: localData.isVerified ?? false, - isPublic: localData.isPublic !== false, - isArchived: localData.isArchived ?? false, - }; - - if (localData.avatar) userData.avatar = localData.avatar; - if (localData.banner) userData.banner = localData.banner; - - // If the source platform sent a URL (Blabsy/Pictique) instead of a - // file-manager ID, download the image and upload it to file-manager. - if (!userData.avatar && rawBody.data?.avatarUrl) { - const fileId = await downloadUrlAndUploadToFileManager(rawBody.data.avatarUrl, ename); - if (fileId) userData.avatar = fileId; - } - if (!userData.banner && rawBody.data?.bannerUrl) { - const fileId = await downloadUrlAndUploadToFileManager(rawBody.data.bannerUrl, ename); - if (fileId) userData.banner = fileId; - } - - if (localData.location) userData.location = localData.location; - - const user = await this.userSearchService.upsertFromWebhook(userData); - - await this.adapter.mappingDb.storeMapping({ - localId: user.id, - globalId, - }); - this.adapter.addToLockedIds(user.id); - this.adapter.addToLockedIds(globalId); - } - - private async handleProfessionalProfileWebhook( - localData: any, - localId: string | null, - globalId: string, - rawBody: any, - ) { - const ename = rawBody.w3id; - if (!ename) return; - - console.log(`[webhook] professional_profile ${ename}: avatar=${localData.avatar ?? "NONE"} banner=${localData.banner ?? "NONE"} keys=[${Object.keys(localData).join(",")}]`); - - const profileData: any = { ename }; - - if (localData.name || rawBody.data?.displayName) { - profileData.name = rawBody.data?.displayName || localData.name; - } - if (localData.headline) profileData.headline = localData.headline; - if (localData.bio) profileData.bio = localData.bio; - if (localData.avatar) - profileData.avatar = localData.avatar; - if (localData.banner) - profileData.banner = localData.banner; - - // If the source platform sent a URL instead of a file-manager ID, - // download the image and upload it to file-manager. - if (!profileData.avatar && rawBody.data?.avatarUrl) { - const fileId = await downloadUrlAndUploadToFileManager(rawBody.data.avatarUrl, ename); - if (fileId) profileData.avatar = fileId; - } - if (!profileData.banner && rawBody.data?.bannerUrl) { - const fileId = await downloadUrlAndUploadToFileManager(rawBody.data.bannerUrl, ename); - if (fileId) profileData.banner = fileId; - } - - if (localData.cvFileId) profileData.cvFileId = localData.cvFileId; - if (localData.videoIntroFileId) - profileData.videoIntroFileId = localData.videoIntroFileId; - if (localData.location) profileData.location = localData.location; - if (localData.skills) profileData.skills = localData.skills; - if (localData.isPublic !== undefined) - profileData.isPublic = localData.isPublic; - - const user = - await this.userSearchService.upsertFromWebhook(profileData); - - await this.adapter.mappingDb.storeMapping({ - localId: user.id, - globalId, - }); - this.adapter.addToLockedIds(user.id); - this.adapter.addToLockedIds(globalId); - } -} diff --git a/platforms/profile-editor/api/src/controllers/auth.ts b/platforms/profile-editor/api/src/controllers/auth.ts new file mode 100644 index 000000000..d76b54934 --- /dev/null +++ b/platforms/profile-editor/api/src/controllers/auth.ts @@ -0,0 +1,91 @@ +import { EventEmitter } from "node:events"; +import { + buildAuthOffer, + signToken, + verifyLoginSignature, +} from "@metastate-foundation/auth"; +import type { Request, Response } from "express"; +import { env } from "../env"; +import type { UserService } from "../services/UserService"; + +export class AuthController { + private events = new EventEmitter(); + + constructor(private users: UserService) {} + + getOffer = async (_req: Request, res: Response) => { + const offer = buildAuthOffer({ + baseUrl: env.baseUrl, + platform: "profile-editor", + }); + res.json({ uri: offer.uri }); + }; + + 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: unknown) => { + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + this.events.on(id, handler); + + const heartbeat = setInterval(() => { + try { + res.write(": heartbeat\n\n"); + } catch { + clearInterval(heartbeat); + } + }, 30000); + + const cleanup = () => { + clearInterval(heartbeat); + this.events.off(id, handler); + res.end(); + }; + req.on("close", cleanup); + req.on("error", cleanup); + }; + + login = async (req: Request, res: Response) => { + try { + const { ename, session, signature } = req.body; + + const result = await verifyLoginSignature({ + eName: ename, + signature, + session, + registryBaseUrl: env.registryUrl, + }); + + if (!result.valid) { + return res.status(401).json({ + error: "Invalid signature", + message: result.error, + }); + } + + const token = signToken({ userId: ename }, env.jwtSecret); + + let name = ename; + try { + name = (await this.users.findByEname(ename))?.name ?? ename; + } catch { + /* fall back to ename */ + } + + const data = { user: { id: ename, ename, name }, token }; + this.events.emit(session, data); + res.status(200).json(data); + } catch (error) { + console.error("Error during login:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; +} diff --git a/platforms/profile-editor/api/src/controllers/discover.ts b/platforms/profile-editor/api/src/controllers/discover.ts new file mode 100644 index 000000000..d175347f6 --- /dev/null +++ b/platforms/profile-editor/api/src/controllers/discover.ts @@ -0,0 +1,110 @@ +import type { Request, Response } from "express"; +import { AppDataSource } from "../db"; +import { ProfessionalProfile } from "../entities/ProfessionalProfile"; +import { User } from "../entities/User"; + +interface RawRow { + id: string; + ename: string; + name: string | null; + handle: string | null; + bio: string | null; + avatar: string | null; + headline: string | null; + location: string | null; + skills: string[] | null; + isverified: boolean; +} + +/** + * Discovery over the local source-of-truth tables: public professional + * profiles joined to their base identity. avatar is the public URL so the + * client renders it directly. + */ +export class DiscoverController { + discover = async (req: Request, res: Response) => { + try { + const q = ((req.query.q as string) ?? "").trim(); + const skills = ((req.query.skills as string) ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const skillsFilter = skills.length > 0 ? skills : null; + const page = Math.max( + 1, + Number.parseInt(req.query.page as string) || 1, + ); + const limit = Math.min( + 100, + Math.max(1, Number.parseInt(req.query.limit as string) || 12), + ); + + const base = AppDataSource.getRepository(ProfessionalProfile) + .createQueryBuilder("p") + .innerJoin(User, "u", "u.ename = p.ename") + .where("p.isPublic = :pub", { pub: true }) + .andWhere("COALESCE(u.isArchived, false) = false"); + + if (q) { + base.andWhere( + `(COALESCE(u.name,'') ILIKE :q OR u.ename ILIKE :q OR COALESCE(u.handle,'') ILIKE :q + OR COALESCE(p.headline,'') ILIKE :q OR COALESCE(p.bio,'') ILIKE :q OR COALESCE(p.location,'') ILIKE :q)`, + { q: `%${q}%` }, + ); + } + if (skillsFilter) { + base.andWhere("p.skills && :skills", { skills: skillsFilter }); + } + + const total = await base.getCount(); + + const rows = (await base + .clone() + .select([ + "p.id AS id", + "u.ename AS ename", + "u.name AS name", + "u.handle AS handle", + "p.bio AS bio", + "u.avatarUrl AS avatar", + "p.headline AS headline", + "p.location AS location", + "p.skills AS skills", + "u.isVerified AS isverified", + ]) + .orderBy("u.isVerified", "DESC") + .addOrderBy("u.name", "ASC") + .offset((page - 1) * limit) + .limit(limit) + .getRawMany()) as RawRow[]; + + res.setHeader( + "Cache-Control", + "no-store, no-cache, must-revalidate, proxy-revalidate", + ); + res.json({ + query: q, + skills: skillsFilter, + results: rows.map((r) => ({ + id: r.id, + ename: r.ename, + name: r.name, + handle: r.handle, + bio: r.bio, + avatar: r.avatar ?? null, + headline: r.headline, + location: r.location, + skills: r.skills, + isVerified: r.isverified, + })), + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }); + } catch (error) { + console.error("Discovery error:", (error as Error).message); + res.status(500).json({ error: "Search service unavailable" }); + } + }; +} diff --git a/platforms/profile-editor/api/src/controllers/files.ts b/platforms/profile-editor/api/src/controllers/files.ts new file mode 100644 index 000000000..4029025ee --- /dev/null +++ b/platforms/profile-editor/api/src/controllers/files.ts @@ -0,0 +1,49 @@ +import type { Request, RequestHandler, Response } from "express"; +import multer from "multer"; +import { adapter } from "../web3adapter/watchers/subscriber"; + +const upload = multer({ + limits: { fileSize: 100 * 1024 * 1024 }, + storage: multer.memoryStorage(), +}); + +/** + * Uploads the file to the authenticated user's eVault blob storage (the same + * path the adapter's `__file` directive uses) and returns its public CDN URL. + * No file-manager, no local bytea — the URL is what gets stored in the profile. + */ +async function handleUpload(req: Request, res: Response): Promise { + try { + if (!req.file) { + res.status(400).json({ error: "No file provided" }); + return; + } + const ename = req.user?.ename; + if (!ename) { + res.status(401).json({ error: "Authentication required" }); + return; + } + + const result = await adapter.evaultClient.uploadFile(ename, { + filename: req.file.originalname, + contentType: req.file.mimetype, + content: req.file.buffer.toString("base64"), + acl: ["*"], + }); + + res.json({ + publicUrl: result.publicUrl, + name: req.file.originalname, + mimeType: req.file.mimetype, + size: req.file.size, + }); + } catch (error) { + console.error("File upload error:", (error as Error).message); + res.status(500).json({ error: "Failed to upload file" }); + } +} + +export const fileUpload: RequestHandler[] = [ + upload.single("file") as RequestHandler, + handleUpload as RequestHandler, +]; diff --git a/platforms/profile-editor/api/src/controllers/profile.ts b/platforms/profile-editor/api/src/controllers/profile.ts new file mode 100644 index 000000000..2c1584ee6 --- /dev/null +++ b/platforms/profile-editor/api/src/controllers/profile.ts @@ -0,0 +1,185 @@ +import type { Request, Response } from "express"; +import type { ProfessionalProfile } from "../entities/ProfessionalProfile"; +import type { User } from "../entities/User"; +import type { ProfessionalProfileService } from "../services/ProfessionalProfileService"; +import type { UserService } from "../services/UserService"; +import type { FullProfile } from "../types/profile"; + +/** Assemble the client-facing profile from the two local tables. */ +function buildFullProfile( + ename: string, + user: User | null, + prof: ProfessionalProfile | null, +): FullProfile { + return { + ename, + name: user?.name ?? prof?.name ?? ename, + handle: user?.handle, + isVerified: user?.isVerified ?? false, + professional: { + displayName: prof?.name ?? user?.name, + headline: prof?.headline, + bio: prof?.bio ?? user?.bio, + avatar: user?.avatarUrl, // base identity: a public URL + banner: user?.bannerUrl, + cvFileId: prof?.cvFileId, + videoIntroFileId: prof?.videoIntroFileId, + email: prof?.email, + phone: prof?.phone, + website: prof?.website, + location: prof?.location ?? user?.location, + isPublic: prof?.isPublic === true, + workExperience: prof?.workExperience ?? [], + education: prof?.education ?? [], + skills: prof?.skills ?? [], + socialLinks: prof?.socialLinks ?? [], + }, + }; +} + +export class ProfileController { + constructor( + private users: UserService, + private profiles: ProfessionalProfileService, + ) {} + + private async build(ename: string): Promise { + const [user, prof] = await Promise.all([ + this.users.findByEname(ename), + this.profiles.findByEname(ename), + ]); + return buildFullProfile(ename, user, prof); + } + + getProfile = async (req: Request, res: Response) => { + try { + const ename = req.user?.ename; + if (!ename) { + return res + .status(401) + .json({ error: "Authentication required" }); + } + res.json(await this.build(ename)); + } catch (error) { + console.error("Error fetching profile:", (error as Error).message); + res.status(500).json({ error: "Failed to fetch profile" }); + } + }; + + updateProfile = async (req: Request, res: Response) => { + try { + const ename = req.user?.ename; + if (!ename) { + return res + .status(401) + .json({ error: "Authentication required" }); + } + const p = req.body; + const user = await this.users.getOrNew(ename); + const prof = await this.profiles.getOrNew(ename); + + // Base identity (shared with all platforms via the User ontology) — + // mirror name/bio/location onto the professional profile too so + // dreamsync (which reads them there) stays in sync. + if (p.displayName !== undefined) { + user.name = p.displayName; + prof.name = p.displayName; + } + if (p.bio !== undefined) { + user.bio = p.bio; + prof.bio = p.bio; + } + if (p.location !== undefined) { + user.location = p.location; + prof.location = p.location; + } + if (p.avatar !== undefined) user.avatarUrl = p.avatar; + if (p.banner !== undefined) user.bannerUrl = p.banner; + + // Professional-only fields. + if (p.headline !== undefined) prof.headline = p.headline; + if (p.cvFileId !== undefined) prof.cvFileId = p.cvFileId; + if (p.videoIntroFileId !== undefined) { + prof.videoIntroFileId = p.videoIntroFileId; + } + if (p.email !== undefined) prof.email = p.email; + if (p.phone !== undefined) prof.phone = p.phone; + if (p.website !== undefined) prof.website = p.website; + if (p.isPublic !== undefined) prof.isPublic = p.isPublic; + + await this.users.save(user); + await this.profiles.save(prof); + res.json(await this.build(ename)); + } catch (error) { + console.error("[profile] update failed:", (error as Error).message); + res.status(500).json({ error: "Failed to update profile" }); + } + }; + + private updateProfArray( + field: "workExperience" | "education" | "skills" | "socialLinks", + label: string, + ) { + return async (req: Request, res: Response) => { + try { + const ename = req.user?.ename; + if (!ename) { + return res + .status(401) + .json({ error: "Authentication required" }); + } + const prof = await this.profiles.getOrNew(ename); + (prof as unknown as Record)[field] = req.body; + await this.profiles.save(prof); + res.json(await this.build(ename)); + } catch (error) { + console.error( + `[profile] ${label} failed:`, + (error as Error).message, + ); + res.status(500).json({ error: `Failed to update ${label}` }); + } + }; + } + + updateWorkExperience = this.updateProfArray( + "workExperience", + "work experience", + ); + updateEducation = this.updateProfArray("education", "education"); + updateSkills = this.updateProfArray("skills", "skills"); + updateSocialLinks = this.updateProfArray("socialLinks", "social links"); + + getPublicProfile = async (req: Request, res: Response) => { + try { + const profile = await this.build(req.params.ename); + // Base identity (name, avatar, banner) is always returned; the + // professional details are gated behind isPublic. + if (!profile.professional.isPublic) { + return res.json({ + ename: profile.ename, + name: profile.name, + handle: profile.handle, + isVerified: profile.isVerified, + professional: { + displayName: profile.professional.displayName, + avatar: profile.professional.avatar, + banner: profile.professional.banner, + isPublic: false, + workExperience: [], + education: [], + skills: [], + socialLinks: [], + }, + }); + } + res.json(profile); + } catch (error) { + console.error( + "Error fetching public profile:", + (error as Error).message, + ); + res.status(500).json({ error: "Failed to fetch profile" }); + } + }; +} diff --git a/platforms/profile-editor/api/src/controllers/webhook.ts b/platforms/profile-editor/api/src/controllers/webhook.ts new file mode 100644 index 000000000..7227562bf --- /dev/null +++ b/platforms/profile-editor/api/src/controllers/webhook.ts @@ -0,0 +1,63 @@ +import type { Request, Response } from "express"; +import type { Web3Adapter } from "web3-adapter"; +import type { ProfessionalProfileService } from "../services/ProfessionalProfileService"; +import type { UserService } from "../services/UserService"; + +/** + * Inbound sync: AaaS delivers an eVault change here. We map it to local fields + * via the adapter (fromGlobal), upsert the local row, record the id-mapping, + * and lock the ids so the subscriber doesn't echo it straight back out. + * Always 200 so AaaS doesn't dead-letter on a transient blip. + */ +export class WebhookController { + constructor( + private adapter: Web3Adapter, + private users: UserService, + private profiles: ProfessionalProfileService, + ) {} + + handleWebhook = async (req: Request, res: Response) => { + try { + const { schemaId, id: globalId, w3id, data } = req.body ?? {}; + const mapping = Object.values(this.adapter.mapping).find( + (m) => m.schemaId === schemaId, + ); + if (!mapping || !globalId) return res.status(200).send(); + + this.adapter.addToLockedIds(globalId); + const local = await this.adapter.fromGlobal({ data, mapping }); + const ename: string | undefined = + w3id ?? (local.data.ename as string | undefined); + if (!ename) return res.status(200).send(); + + let localId: string | undefined; + if (mapping.tableName === "users") { + const user = await this.users.upsertFromGlobal( + ename, + local.data, + ); + localId = user.id; + } else if (mapping.tableName === "professional_profiles") { + const profile = await this.profiles.upsertFromGlobal( + ename, + local.data, + ); + localId = profile.id; + } else { + return res.status(200).send(); + } + + await this.adapter.mappingDb.storeMapping({ localId, globalId }); + this.adapter.addToLockedIds(localId); + this.adapter.addToLockedIds(globalId); + + res.status(200).send(); + } catch (error) { + // Genuine processing failure (e.g. DB blip): 500 so AaaS retries + // and eventually dead-letters, rather than silently losing the + // change. Non-retryable skips above return 200. + console.error("Webhook error:", (error as Error).message); + res.status(500).send(); + } + }; +} diff --git a/platforms/profile-editor/api/src/database/data-source.ts b/platforms/profile-editor/api/src/database/data-source.ts deleted file mode 100644 index fef36e54a..000000000 --- a/platforms/profile-editor/api/src/database/data-source.ts +++ /dev/null @@ -1,32 +0,0 @@ -import "reflect-metadata"; -import { DataSource } from "typeorm"; -import { config } from "dotenv"; -import path from "path"; -import { User } from "./entities/User"; -import { Session } from "./entities/Session"; -import { PostgresSubscriber } from "../web3adapter/watchers/subscriber"; - -config({ path: path.resolve(__dirname, "../../../../../.env") }); - -export const AppDataSource = new DataSource({ - type: "postgres", - url: process.env.PROFILE_EDITOR_DATABASE_URL, - synchronize: false, - logging: process.env.NODE_ENV === "development", - entities: [User, Session], - migrations: [path.join(__dirname, "migrations", "*.ts")], - subscribers: [PostgresSubscriber], - ssl: process.env.DB_CA_CERT - ? { - rejectUnauthorized: false, - ca: process.env.DB_CA_CERT, - } - : false, - extra: { - max: 10, - min: 2, - idleTimeoutMillis: 30000, - connectionTimeoutMillis: 5000, - statement_timeout: 10000, - }, -}); diff --git a/platforms/profile-editor/api/src/database/entities/Session.ts b/platforms/profile-editor/api/src/database/entities/Session.ts deleted file mode 100644 index 575d0df1a..000000000 --- a/platforms/profile-editor/api/src/database/entities/Session.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - Index, -} from "typeorm"; - -@Entity("sessions") -export class Session { - @PrimaryGeneratedColumn("uuid") - id!: string; - - @Index() - @Column() - userId!: string; - - @Column() - ename!: string; - - @Column({ unique: true }) - token!: string; - - @CreateDateColumn() - createdAt!: Date; - - @Column({ type: "timestamp" }) - expiresAt!: Date; -} diff --git a/platforms/profile-editor/api/src/database/entities/User.ts b/platforms/profile-editor/api/src/database/entities/User.ts deleted file mode 100644 index 8cda1b016..000000000 --- a/platforms/profile-editor/api/src/database/entities/User.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - Entity, - PrimaryGeneratedColumn, - Column, - CreateDateColumn, - UpdateDateColumn, -} from "typeorm"; - -@Entity("users") -export class User { - @PrimaryGeneratedColumn("uuid") - id!: string; - - @Column({ nullable: true, unique: true }) - ename!: string; - - @Column({ nullable: true }) - name!: string; - - @Column({ nullable: true }) - handle!: string; - - @Column({ nullable: true, type: "text" }) - bio!: string; - - @Column({ nullable: true }) - avatar!: string; - - @Column({ nullable: true }) - banner!: string; - - @Column({ nullable: true }) - headline!: string; - - @Column({ nullable: true }) - location!: string; - - @Column("text", { array: true, nullable: true }) - skills!: string[]; - - @Column({ default: false }) - isVerified!: boolean; - - @Column({ default: true }) - isPublic!: boolean; - - @Column({ default: false }) - isArchived!: boolean; - - @CreateDateColumn() - createdAt!: Date; - - @UpdateDateColumn() - updatedAt!: Date; -} diff --git a/platforms/profile-editor/api/src/database/migrations/1773143278029-InitialSchema.ts b/platforms/profile-editor/api/src/database/migrations/1773143278029-InitialSchema.ts deleted file mode 100644 index 5ebef285d..000000000 --- a/platforms/profile-editor/api/src/database/migrations/1773143278029-InitialSchema.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class InitialSchema1773143278029 implements MigrationInterface { - name = 'InitialSchema1773143278029' - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "ename" character varying, "name" character varying, "handle" character varying, "bio" text, "avatarFileId" character varying, "bannerFileId" character varying, "headline" character varying, "location" character varying, "skills" text array, "isVerified" boolean NOT NULL DEFAULT false, "isPublic" boolean NOT NULL DEFAULT true, "isArchived" boolean NOT NULL DEFAULT false, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_0759b745d50a467c0319c4cb284" UNIQUE ("ename"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "sessions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" character varying NOT NULL, "ename" character varying NOT NULL, "token" character varying NOT NULL, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "expiresAt" TIMESTAMP NOT NULL, CONSTRAINT "UQ_e9f62f5dcb8a54b84234c9e7a06" UNIQUE ("token"), CONSTRAINT "PK_3238ef96f18b355b671619111bc" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE INDEX "IDX_57de40bc620f456c7311aa3a1e" ON "sessions" ("userId") `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_57de40bc620f456c7311aa3a1e"`); - await queryRunner.query(`DROP TABLE "sessions"`); - await queryRunner.query(`DROP TABLE "users"`); - } - -} diff --git a/platforms/profile-editor/api/src/database/migrations/1775600000000-RenameAvatarBannerColumns.ts b/platforms/profile-editor/api/src/database/migrations/1775600000000-RenameAvatarBannerColumns.ts deleted file mode 100644 index 0bbde8348..000000000 --- a/platforms/profile-editor/api/src/database/migrations/1775600000000-RenameAvatarBannerColumns.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class RenameAvatarBannerColumns1775600000000 implements MigrationInterface { - name = 'RenameAvatarBannerColumns1775600000000' - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "avatarFileId" TO "avatar"`); - await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "bannerFileId" TO "banner"`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "avatar" TO "avatarFileId"`); - await queryRunner.query(`ALTER TABLE "users" RENAME COLUMN "banner" TO "bannerFileId"`); - } -} diff --git a/platforms/profile-editor/api/src/db.ts b/platforms/profile-editor/api/src/db.ts new file mode 100644 index 000000000..4baf3bd3c --- /dev/null +++ b/platforms/profile-editor/api/src/db.ts @@ -0,0 +1,25 @@ +import "reflect-metadata"; +import path from "node:path"; +import { DataSource } from "typeorm"; +import { ProfessionalProfile } from "./entities/ProfessionalProfile"; +import { User } from "./entities/User"; +import { env } from "./env"; +import { PostgresSubscriber } from "./web3adapter/watchers/subscriber"; + +export const AppDataSource = new DataSource({ + type: "postgres", + url: env.databaseUrl, + synchronize: false, + logging: env.nodeEnv === "development", + entities: [User, ProfessionalProfile], + migrations: [path.join(__dirname, "migrations", "*.{ts,js}")], + subscribers: [PostgresSubscriber], + ssl: env.dbCaCert ? { rejectUnauthorized: false, ca: env.dbCaCert } : false, + extra: { + max: 10, + min: 2, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + statement_timeout: 10000, + }, +}); diff --git a/platforms/profile-editor/api/src/entities/ProfessionalProfile.ts b/platforms/profile-editor/api/src/entities/ProfessionalProfile.ts new file mode 100644 index 000000000..c29098ffc --- /dev/null +++ b/platforms/profile-editor/api/src/entities/ProfessionalProfile.ts @@ -0,0 +1,72 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm"; +import type { Education, SocialLink, WorkExperience } from "../types/profile"; + +/** + * Professional profile (Professional Profile ontology, 550e8400-…-440009). + * Source of truth locally; synced 2-way with dreamsync via the adapter. + * `isPublic` is the gate that travels with it. The shared fields match + * dreamsync's mapping; workExperience/education/socialLinks/email/phone/website + * are profile-editor-only (dreamsync ignores them). + */ +@Entity("professional_profiles") +export class ProfessionalProfile { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ nullable: true, unique: true }) + ename!: string; + + @Column({ nullable: true }) + name!: string; + + @Column({ nullable: true }) + headline!: string; + + @Column({ nullable: true, type: "text" }) + bio!: string; + + @Column({ nullable: true }) + location!: string; + + @Column("text", { array: true, nullable: true }) + skills!: string[]; + + @Column({ nullable: true }) + cvFileId!: string; + + @Column({ nullable: true }) + videoIntroFileId!: string; + + @Column({ default: false }) + isPublic!: boolean; + + @Column({ type: "jsonb", nullable: true }) + workExperience!: WorkExperience[]; + + @Column({ type: "jsonb", nullable: true }) + education!: Education[]; + + @Column({ type: "jsonb", nullable: true }) + socialLinks!: SocialLink[]; + + @Column({ nullable: true }) + email!: string; + + @Column({ nullable: true }) + phone!: string; + + @Column({ nullable: true }) + website!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/platforms/profile-editor/api/src/entities/User.ts b/platforms/profile-editor/api/src/entities/User.ts new file mode 100644 index 000000000..fd6795f6b --- /dev/null +++ b/platforms/profile-editor/api/src/entities/User.ts @@ -0,0 +1,55 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm"; + +/** + * Base cross-platform identity (User ontology, 550e8400-…-440000). Source of + * truth locally; synced 2-way to/from every platform's eVault via the adapter. + * `avatarUrl`/`bannerUrl` are public eVault-blob URLs. Always public + * (`isPrivate` stays false) — base identity is never gated. + */ +@Entity("users") +export class User { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ nullable: true, unique: true }) + ename!: string; + + @Column({ nullable: true }) + handle!: string; + + @Column({ nullable: true }) + name!: string; + + @Column({ nullable: true, type: "text" }) + bio!: string; + + @Column({ nullable: true }) + avatarUrl!: string; + + @Column({ nullable: true }) + bannerUrl!: string; + + @Column({ nullable: true }) + location!: string; + + @Column({ default: false }) + isVerified!: boolean; + + @Column({ default: false }) + isPrivate!: boolean; + + @Column({ default: false }) + isArchived!: boolean; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/platforms/profile-editor/api/src/env.ts b/platforms/profile-editor/api/src/env.ts new file mode 100644 index 000000000..9e166c7cf --- /dev/null +++ b/platforms/profile-editor/api/src/env.ts @@ -0,0 +1,38 @@ +import path from "node:path"; +import { config } from "dotenv"; + +// Load the repo-root .env (five levels up from src/ at runtime). +config({ path: path.resolve(__dirname, "../../../../.env") }); + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is not configured`); + return value; +} + +function optional(name: string, fallback: string): string { + return process.env[name] || fallback; +} + +export const env = { + port: Number(process.env.PROFILE_EDITOR_API_PORT) || 3007, + jwtSecret: required("PROFILE_EDITOR_JWT_SECRET"), + databaseUrl: required("PROFILE_EDITOR_DATABASE_URL"), + dbCaCert: process.env.DB_CA_CERT, + + registryUrl: required("PUBLIC_REGISTRY_URL"), + baseUrl: required("PUBLIC_PROFILE_EDITOR_BASE_URL"), + mappingDbPath: required("PROFILE_EDITOR_MAPPING_DB_PATH"), + + awarenessServiceUrl: optional( + "AWARENESS_SERVICE_URL", + "http://localhost:4100", + ), + awarenessApiKey: process.env.AWARENESS_API_KEY, + // Public URL AaaS should POST packets to. Set this to your tunnel + // (e.g. https://xyz.trycloudflare.com/api/webhook) for local dev against + // prod AaaS. Falls back to {baseUrl}/api/webhook. + awarenessWebhookUrl: process.env.AWARENESS_WEBHOOK_URL, + + nodeEnv: optional("NODE_ENV", "development"), +} as const; diff --git a/platforms/profile-editor/api/src/index.ts b/platforms/profile-editor/api/src/index.ts index 2d7882fd4..4ef57ac34 100644 --- a/platforms/profile-editor/api/src/index.ts +++ b/platforms/profile-editor/api/src/index.ts @@ -1,104 +1,97 @@ import "reflect-metadata"; -import express from "express"; import cors from "cors"; -import { config } from "dotenv"; -import path from "path"; - -config({ path: path.resolve(__dirname, "../../../../.env") }); - -import { AppDataSource } from "./database/data-source"; -import { AuthController } from "./controllers/AuthController"; -import { ProfileController } from "./controllers/ProfileController"; -import { DiscoveryController } from "./controllers/DiscoveryController"; -import { WebhookController } from "./controllers/WebhookController"; -import { EVaultProfileService } from "./services/EVaultProfileService"; -import { RegistryService } from "./services/RegistryService"; -import { authMiddleware, authGuard } from "./middleware/auth"; -import { fileProxyUpload } from "./controllers/FileProxyController"; -import { EVaultSyncService } from "./services/EVaultSyncService"; +import express from "express"; +import { registerSubscriptionOnStartup } from "./aaas"; +import { AuthController } from "./controllers/auth"; +import { DiscoverController } from "./controllers/discover"; +import { fileUpload } from "./controllers/files"; +import { ProfileController } from "./controllers/profile"; +import { WebhookController } from "./controllers/webhook"; +import { AppDataSource } from "./db"; +import { env } from "./env"; +import { authGuard, authMiddleware } from "./middleware/auth"; +import { + educationSchema, + loginSchema, + profilePatchSchema, + skillsSchema, + socialLinksSchema, + validate, + workExperienceSchema, +} from "./schemas"; +import { ProfessionalProfileService } from "./services/ProfessionalProfileService"; +import { UserService } from "./services/UserService"; import { adapter } from "./web3adapter/watchers/subscriber"; const app = express(); -const PORT = process.env.PROFILE_EDITOR_API_PORT || 3007; - app.use(cors()); -app.use(express.json()); - -const registryService = new RegistryService(); -const evaultService = new EVaultProfileService(registryService); - -const authController = new AuthController(evaultService); -const profileController = new ProfileController(evaultService); -const discoveryController = new DiscoveryController(); -const webhookController = new WebhookController(adapter); - -// Webhook route (no auth, receives eVault events) -app.post("/api/webhook", webhookController.handleWebhook); - -// Public auth routes -app.get("/api/auth/offer", authController.getOffer); -app.post("/api/auth", authController.login); -app.get("/api/auth/sessions/:id", authController.sseStream); - -// Public discovery routes -app.get("/api/discover", discoveryController.discover); - -// Public profile view + file proxy routes (authMiddleware is optional here — populates req.user if logged in) -app.get("/api/profiles/:ename", profileController.getPublicProfile); -app.get( - "/api/profiles/:ename/avatar", - authMiddleware, - profileController.getProfileAvatar, -); -app.get( - "/api/profiles/:ename/banner", - authMiddleware, - profileController.getProfileBanner, -); -app.get( - "/api/profiles/:ename/cv", - authMiddleware, - profileController.getProfileCv, -); -app.get( - "/api/profiles/:ename/video", - authMiddleware, - profileController.getProfileVideo, -); - -// Protected routes -app.use(authMiddleware); - -app.post("/api/files", authGuard, ...fileProxyUpload); - -app.get("/api/profile", authGuard, profileController.getProfile); -app.patch("/api/profile", authGuard, profileController.updateProfile); -app.put( - "/api/profile/work-experience", - authGuard, - profileController.updateWorkExperience, -); -app.put("/api/profile/education", authGuard, profileController.updateEducation); -app.put("/api/profile/skills", authGuard, profileController.updateSkills); -app.put( - "/api/profile/social-links", - authGuard, - profileController.updateSocialLinks, -); +app.use(express.json({ limit: "10mb" })); AppDataSource.initialize() - .then(() => { - console.log("Database connection established"); - - const syncService = new EVaultSyncService(evaultService); - profileController.setSyncService(syncService); - syncService.start(5 * 60 * 1000); - - app.listen(PORT, () => { - console.log(`Profile Editor API running on port ${PORT}`); - }); - }) - .catch((error: any) => { - console.error("Database connection failed:", error); - process.exit(1); - }); + .then(async () => { + console.log("Database connection established"); + + const users = new UserService(); + const profiles = new ProfessionalProfileService(); + const auth = new AuthController(users); + const profile = new ProfileController(users, profiles); + const discover = new DiscoverController(); + const webhook = new WebhookController(adapter, users, profiles); + + // Inbound sync from AaaS (no auth) + app.post("/api/webhook", webhook.handleWebhook); + + // Public auth + discovery + app.get("/api/auth/offer", auth.getOffer); + app.post("/api/auth", validate(loginSchema), auth.login); + app.get("/api/auth/sessions/:id", auth.sseStream); + app.get("/api/discover", discover.discover); + + // Auth populates req.user when a token is present + app.use(authMiddleware); + + app.get("/api/profiles/:ename", authGuard, profile.getPublicProfile); + app.post("/api/files", authGuard, ...fileUpload); + + app.get("/api/profile", authGuard, profile.getProfile); + app.patch( + "/api/profile", + authGuard, + validate(profilePatchSchema), + profile.updateProfile, + ); + app.put( + "/api/profile/work-experience", + authGuard, + validate(workExperienceSchema), + profile.updateWorkExperience, + ); + app.put( + "/api/profile/education", + authGuard, + validate(educationSchema), + profile.updateEducation, + ); + app.put( + "/api/profile/skills", + authGuard, + validate(skillsSchema), + profile.updateSkills, + ); + app.put( + "/api/profile/social-links", + authGuard, + validate(socialLinksSchema), + profile.updateSocialLinks, + ); + + await registerSubscriptionOnStartup(); + + app.listen(env.port, () => { + console.log(`Profile Editor API running on port ${env.port}`); + }); + }) + .catch((error: unknown) => { + console.error("Database connection failed:", error); + process.exit(1); + }); diff --git a/platforms/profile-editor/api/src/middleware/auth.ts b/platforms/profile-editor/api/src/middleware/auth.ts index 7220c9aab..cbaba562d 100644 --- a/platforms/profile-editor/api/src/middleware/auth.ts +++ b/platforms/profile-editor/api/src/middleware/auth.ts @@ -1,49 +1,17 @@ import { - createAuthMiddleware, - createAuthGuard, + createAuthGuard, + createAuthMiddleware, } from "@metastate-foundation/auth"; -import { AppDataSource } from "../database/data-source"; -import { Session } from "../database/entities/Session"; -import { MoreThan } from "typeorm"; - -const JWT_SECRET = process.env.PROFILE_EDITOR_JWT_SECRET; -if (!JWT_SECRET) throw new Error("PROFILE_EDITOR_JWT_SECRET not configured"); - -export async function registerSession( - userId: string, - ename: string, - token: string, -): Promise { - const repo = AppDataSource.getRepository(Session); - - await repo.delete({ userId }); - - const session = repo.create({ - userId, - ename, - token, - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - }); - - await repo.save(session); -} +import { env } from "../env"; +/** + * Stateless JWT auth: the token's `userId` IS the eName (login signs + * `{ userId: ename }`), so there's no session table to consult — `findUser` + * just echoes the identity. Token expiry is enforced by the JWT itself. + */ export const authMiddleware = createAuthMiddleware({ - secret: JWT_SECRET, - findUser: async (userId: string) => { - if (!AppDataSource.isInitialized) { - return null; - } - - const repo = AppDataSource.getRepository(Session); - const session = await repo.findOneBy({ - userId, - expiresAt: MoreThan(new Date()), - }); - - if (!session) return null; - return { id: session.userId, ename: session.ename }; - }, + secret: env.jwtSecret, + findUser: async (userId: string) => ({ id: userId, ename: userId }), }); export const authGuard = createAuthGuard(); diff --git a/platforms/profile-editor/api/src/migrations/1700000000000-Init.ts b/platforms/profile-editor/api/src/migrations/1700000000000-Init.ts new file mode 100644 index 000000000..fb1550380 --- /dev/null +++ b/platforms/profile-editor/api/src/migrations/1700000000000-Init.ts @@ -0,0 +1,59 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +export class Init1700000000000 implements MigrationInterface { + name = "Init1700000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); + + await queryRunner.query(` + CREATE TABLE "users" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ename" character varying, + "handle" character varying, + "name" character varying, + "bio" text, + "avatarUrl" character varying, + "bannerUrl" character varying, + "location" character varying, + "isVerified" boolean NOT NULL DEFAULT false, + "isPrivate" boolean NOT NULL DEFAULT false, + "isArchived" boolean NOT NULL DEFAULT false, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "UQ_users_ename" UNIQUE ("ename"), + CONSTRAINT "PK_users_id" PRIMARY KEY ("id") + ) + `); + + await queryRunner.query(` + CREATE TABLE "professional_profiles" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ename" character varying, + "name" character varying, + "headline" character varying, + "bio" text, + "location" character varying, + "skills" text array, + "cvFileId" character varying, + "videoIntroFileId" character varying, + "isPublic" boolean NOT NULL DEFAULT false, + "workExperience" jsonb, + "education" jsonb, + "socialLinks" jsonb, + "email" character varying, + "phone" character varying, + "website" character varying, + "createdAt" TIMESTAMP NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "UQ_professional_profiles_ename" UNIQUE ("ename"), + CONSTRAINT "PK_professional_profiles_id" PRIMARY KEY ("id") + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "professional_profiles"`); + await queryRunner.query(`DROP TABLE "users"`); + } +} diff --git a/platforms/profile-editor/api/src/schemas.ts b/platforms/profile-editor/api/src/schemas.ts new file mode 100644 index 000000000..9f76ea272 --- /dev/null +++ b/platforms/profile-editor/api/src/schemas.ts @@ -0,0 +1,83 @@ +import type { NextFunction, Request, Response } from "express"; +import { z } from "zod"; + +/** + * Validates `req.body` against a schema, replacing it with the parsed value. + * Strings are kept permissive (empty string is allowed and meaningful — it + * clears a field, e.g. `cvFileId: ""`). + */ +export function validate(schema: z.ZodTypeAny) { + return (req: Request, res: Response, next: NextFunction): void => { + const result = schema.safeParse(req.body); + if (!result.success) { + res.status(400).json({ + error: "Invalid request body", + details: result.error.issues.map((i) => ({ + path: i.path.join("."), + message: i.message, + })), + }); + return; + } + req.body = result.data; + next(); + }; +} + +export const loginSchema = z.object({ + ename: z.string().min(1), + session: z.string().min(1), + signature: z.string().min(1), +}); + +export const profilePatchSchema = z.object({ + displayName: z.string().optional(), + headline: z.string().optional(), + bio: z.string().optional(), + avatar: z.string().optional(), + banner: z.string().optional(), + cvFileId: z.string().optional(), + videoIntroFileId: z.string().optional(), + email: z.string().optional(), + phone: z.string().optional(), + website: z.string().optional(), + location: z.string().optional(), + isPublic: z.boolean().optional(), +}); + +export const workExperienceSchema = z.array( + z.object({ + id: z.string().optional(), + company: z.string(), + role: z.string(), + description: z.string().optional(), + startDate: z.string(), + endDate: z.string().optional(), + location: z.string().optional(), + sortOrder: z.number(), + }), +); + +export const educationSchema = z.array( + z.object({ + id: z.string().optional(), + institution: z.string(), + degree: z.string(), + fieldOfStudy: z.string().optional(), + startDate: z.string(), + endDate: z.string().optional(), + description: z.string().optional(), + sortOrder: z.number(), + }), +); + +export const skillsSchema = z.array(z.string()); + +export const socialLinksSchema = z.array( + z.object({ + id: z.string().optional(), + platform: z.string(), + url: z.string(), + label: z.string().optional(), + }), +); diff --git a/platforms/profile-editor/api/src/services/EVaultProfileService.ts b/platforms/profile-editor/api/src/services/EVaultProfileService.ts deleted file mode 100644 index ab3b4888a..000000000 --- a/platforms/profile-editor/api/src/services/EVaultProfileService.ts +++ /dev/null @@ -1,525 +0,0 @@ -import { GraphQLClient } from "graphql-request"; -import { RegistryService } from "./RegistryService"; -import type { - ProfessionalProfile, - FullProfile, - UserOntologyData, -} from "../types/profile"; - -const PROFESSIONAL_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440009"; -const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; - -function getFileManagerPublicUrl(fileId: string): string { - const base = process.env.PUBLIC_FILE_MANAGER_BASE_URL || "http://localhost:3005"; - return `${base}/api/public/files/${fileId}`; -} - -function normalizeEName(eName: string): string { - return eName.startsWith("@") ? eName : `@${eName}`; -} - -const META_ENVELOPES_QUERY = ` - query MetaEnvelopes($filter: MetaEnvelopeFilterInput, $first: Int, $after: String) { - metaEnvelopes(filter: $filter, first: $first, after: $after) { - edges { - cursor - node { - id - ontology - parsed - } - } - pageInfo { - hasNextPage - endCursor - } - totalCount - } - } -`; - -const META_ENVELOPE_QUERY = ` - query MetaEnvelope($id: ID!) { - metaEnvelope(id: $id) { - id - ontology - parsed - } - } -`; - -const CREATE_MUTATION = ` - mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { - createMetaEnvelope(input: $input) { - metaEnvelope { - id - ontology - parsed - } - errors { field message code } - } - } -`; - -const UPDATE_MUTATION = ` - mutation UpdateMetaEnvelope($id: ID!, $input: MetaEnvelopeInput!) { - updateMetaEnvelope(id: $id, input: $input) { - metaEnvelope { - id - ontology - parsed - } - errors { message code } - } - } -`; - -type MetaEnvelopeNode = { - id: string; - ontology: string; - parsed: Record; -}; - -type MetaEnvelopesResult = { - metaEnvelopes: { - edges: Array<{ cursor: string; node: MetaEnvelopeNode }>; - pageInfo: { hasNextPage: boolean; endCursor: string }; - totalCount: number; - }; -}; - -type MetaEnvelopeResult = { - metaEnvelope: MetaEnvelopeNode | null; -}; - -type CreateResult = { - createMetaEnvelope: { - metaEnvelope: MetaEnvelopeNode | null; - errors: Array<{ field?: string; message: string; code?: string }>; - }; -}; - -type UpdateResult = { - updateMetaEnvelope: { - metaEnvelope: MetaEnvelopeNode | null; - errors: Array<{ message: string; code?: string }>; - }; -}; - -export interface PreparedWrite { - profile: FullProfile; - persisted: Promise; -} - -/** - * Strip empty arrays from eVault payload — serializeValue([]) is broken - * in the eVault (doesn't JSON-stringify), so we omit them and let the - * eVault delete those Envelope nodes. Reads default to []. - */ -function buildPayload(merged: ProfessionalProfile): Record { - const payload: Record = {}; - for (const [key, value] of Object.entries(merged)) { - if (value === null || value === undefined) continue; - if (Array.isArray(value) && value.length === 0) continue; - payload[key] = value; - } - return payload; -} - -async function getLocalUser(eName: string) { - const { AppDataSource } = await import("../database/data-source"); - const { User } = await import("../database/entities/User"); - if (!AppDataSource.isInitialized) { - throw new Error("Database not initialized — cannot access local user"); - } - const repo = AppDataSource.getRepository(User); - return { repo, user: await repo.findOneBy({ ename: eName }), User }; -} - -function buildFullProfile( - eName: string, - merged: ProfessionalProfile, - userData: UserOntologyData, - localAvatar?: string, - localBanner?: string, -): FullProfile { - const name = merged.displayName ?? userData.displayName ?? eName; - return { - ename: eName, - name, - handle: userData.username, - isVerified: userData.isVerified, - professional: { - displayName: merged.displayName, - headline: merged.headline, - bio: merged.bio, - avatar: localAvatar ?? undefined, - banner: localBanner ?? undefined, - cvFileId: merged.cvFileId, - videoIntroFileId: merged.videoIntroFileId, - email: merged.email, - phone: merged.phone, - website: merged.website, - location: merged.location, - isPublic: merged.isPublic === true, - workExperience: merged.workExperience ?? [], - education: merged.education ?? [], - skills: merged.skills ?? [], - socialLinks: merged.socialLinks ?? [], - }, - }; -} - -interface CacheEntry { - profile: FullProfile; - expiresAt: number; - /** Cached envelope ID so writes don't need a read round-trip. */ - envelopeId?: string; -} - -export class EVaultProfileService { - private registryService: RegistryService; - private cache = new Map(); - private static CACHE_TTL = 30 * 1000; - private static WRITE_CACHE_TTL = 5 * 60 * 1000; - private writeQueue = new Map>(); - - constructor(registryService: RegistryService) { - this.registryService = registryService; - } - - private async getClient(eName: string): Promise { - const t0 = Date.now(); - const endpoint = await this.registryService.getEvaultGraphqlUrl(eName); - const t1 = Date.now(); - const token = await this.registryService.ensurePlatformToken(); - const t2 = Date.now(); - if (t2 - t0 > 50) { - console.log(`[eVault] getClient ${eName}: resolve=${t1 - t0}ms token=${t2 - t1}ms`); - } - return new GraphQLClient(endpoint, { - headers: { - Authorization: `Bearer ${token}`, - "X-ENAME": normalizeEName(eName), - }, - }); - } - - private async findMetaEnvelopeByOntology( - client: GraphQLClient, - ontologyId: string, - ): Promise { - const result = await client.request( - META_ENVELOPES_QUERY, - { - filter: { ontologyId }, - first: 10, - }, - ); - if (result.metaEnvelopes.totalCount > 1) { - console.warn( - `[eVault] DUPLICATE ENVELOPES: ${result.metaEnvelopes.totalCount} for ontology ${ontologyId}`, - ); - } - const edge = result.metaEnvelopes.edges[0]; - return edge?.node ?? null; - } - - /** Fetch profile from eVault (bypasses cache). Returns profile + envelope ID. */ - private async fetchFromEvault(eName: string): Promise<{ profile: FullProfile; envelopeId?: string }> { - const client = await this.getClient(eName); - - const [professionalNode, userNode] = await Promise.all([ - this.findMetaEnvelopeByOntology(client, PROFESSIONAL_PROFILE_ONTOLOGY), - this.findMetaEnvelopeByOntology(client, USER_ONTOLOGY), - ]); - - const userData = (userNode?.parsed ?? {}) as UserOntologyData; - const profData = (professionalNode?.parsed ?? {}) as ProfessionalProfile; - - // Avatar/banner live on the local User entity (file-manager IDs) - const { user: localUser } = await getLocalUser(eName); - - return { - profile: buildFullProfile(eName, profData, userData, localUser?.avatar, localUser?.banner), - envelopeId: professionalNode?.id, - }; - } - - /** Get profile — serves from 30s cache, falls back to eVault. */ - async getProfile(eName: string): Promise { - const now = Date.now(); - const cached = this.cache.get(eName); - if (cached && cached.expiresAt > now) { - return cached.profile; - } - - const { profile, envelopeId } = await this.fetchFromEvault(eName); - this.cache.set(eName, { - profile, - expiresAt: now + EVaultProfileService.CACHE_TTL, - envelopeId, - }); - return profile; - } - - /** Get profile fresh from eVault — bypasses cache, but updates it. */ - async getFreshProfile(eName: string): Promise { - const { profile, envelopeId } = await this.fetchFromEvault(eName); - this.cache.set(eName, { - profile, - expiresAt: Date.now() + EVaultProfileService.CACHE_TTL, - envelopeId, - }); - return profile; - } - - async getPublicProfile(eName: string): Promise { - const profile = await this.getProfile(eName); - if (!profile.professional.isPublic) { - return null; - } - return profile; - } - - /** - * Prepare a profile update. Uses the cache as merge base when available - * so rapid back-to-back edits don't clobber each other. - */ - async prepareUpdate( - eName: string, - data: Partial, - ): Promise { - // Persist avatar/banner to the local User row immediately so - // getProfile returns the correct value right away. - if (data.avatar !== undefined || data.banner !== undefined) { - const { repo, user: localUser, User } = await getLocalUser(eName); - const u = localUser ?? repo.create({ ename: eName }); - if (data.avatar !== undefined) u.avatar = data.avatar; - if (data.banner !== undefined) u.banner = data.banner; - await repo.save(u); - - // Mark new avatar/banner files as publicly accessible - const { markFilePublic } = await import("../utils/file-proxy"); - if (data.avatar) markFilePublic(data.avatar, eName).catch(() => {}); - if (data.banner) markFilePublic(data.banner, eName).catch(() => {}); - } - - const cached = this.cache.get(eName); - let baseProfessional: ProfessionalProfile; - let userData: UserOntologyData; - let cachedEnvelopeId: string | undefined; - - if (cached) { - baseProfessional = cached.profile.professional; - userData = { - displayName: cached.profile.name, - username: cached.profile.handle, - isVerified: cached.profile.isVerified, - } as UserOntologyData; - cachedEnvelopeId = cached.envelopeId; - } else { - const { profile, envelopeId } = await this.fetchFromEvault(eName); - baseProfessional = profile.professional; - userData = { - displayName: profile.name, - username: profile.handle, - isVerified: profile.isVerified, - } as UserOntologyData; - cachedEnvelopeId = envelopeId; - } - - const client = await this.getClient(eName); - const existingEnvelope: MetaEnvelopeNode | null = cachedEnvelopeId - ? { id: cachedEnvelopeId, ontology: PROFESSIONAL_PROFILE_ONTOLOGY, parsed: {} } - : null; - - const merged: ProfessionalProfile = { - ...baseProfessional, - ...data, - }; - - const payload = buildPayload(merged); - const acl = merged.isPublic === true ? ["*"] : [normalizeEName(eName)]; - - // Read local user for the optimistic profile (may have just been updated above) - const { user: freshLocalUser } = await getLocalUser(eName); - const profile = buildFullProfile(eName, merged, userData, freshLocalUser?.avatar, freshLocalUser?.banner); - - // Immediately update the cache with the optimistic result - this.cache.set(eName, { - profile, - expiresAt: Date.now() + EVaultProfileService.CACHE_TTL, - envelopeId: cachedEnvelopeId, - }); - - const persisted = this.enqueueWrite(eName, async () => { - await this.writeToEvault(client, eName, existingEnvelope, payload, acl); - // After eVault write, sync avatar/banner URLs to User ontology - if (data.avatar !== undefined || data.banner !== undefined) { - await this.syncAvatarBannerToUserOntology(client, eName, merged); - } - }); - - return { profile, persisted }; - } - - private enqueueWrite( - eName: string, - fn: () => Promise, - ): Promise { - const prev = this.writeQueue.get(eName) ?? Promise.resolve(); - const next = prev.then(fn, fn); - this.writeQueue.set(eName, next); - next.finally(() => { - if (this.writeQueue.get(eName) === next) { - this.writeQueue.delete(eName); - } - }); - return next; - } - - private async writeToEvault( - client: GraphQLClient, - eName: string, - existing: MetaEnvelopeNode | null, - payload: Record, - acl: string[], - ): Promise { - try { - if (existing) { - const result = await client.request(UPDATE_MUTATION, { - id: existing.id, - input: { - ontology: PROFESSIONAL_PROFILE_ONTOLOGY, - payload, - acl, - }, - }); - - if (result.updateMetaEnvelope.errors?.length) { - throw new Error( - result.updateMetaEnvelope.errors - .map((e) => e.message) - .join("; "), - ); - } - } else { - const result = await client.request(CREATE_MUTATION, { - input: { - ontology: PROFESSIONAL_PROFILE_ONTOLOGY, - payload, - acl, - }, - }); - - if (result.createMetaEnvelope.errors?.length) { - const errors = result.createMetaEnvelope.errors; - const couldBeConflict = errors.some( - (e) => - e.code === "CREATE_FAILED" || - e.code === "ONTOLOGY_ALREADY_EXISTS", - ); - - if (!couldBeConflict) { - throw new Error(errors.map((e) => e.message).join("; ")); - } - - const raced = await this.findMetaEnvelopeByOntology( - client, - PROFESSIONAL_PROFILE_ONTOLOGY, - ); - if (raced) { - const updateResult = await client.request( - UPDATE_MUTATION, - { - id: raced.id, - input: { - ontology: PROFESSIONAL_PROFILE_ONTOLOGY, - payload, - acl, - }, - }, - ); - if (updateResult.updateMetaEnvelope.errors?.length) { - throw new Error( - updateResult.updateMetaEnvelope.errors - .map((e) => e.message) - .join("; "), - ); - } - } else { - throw new Error(errors.map((e) => e.message).join("; ")); - } - } - } - - // Write succeeded — extend the cache TTL - const cached = this.cache.get(eName); - if (cached) { - cached.expiresAt = Date.now() + EVaultProfileService.WRITE_CACHE_TTL; - } - } catch (err) { - console.error(`[eVault WRITE FAIL] ${eName}: invalidating cache`, (err as Error).message); - this.cache.delete(eName); - throw err; - } - } - - /** - * Writes avatarUrl / bannerUrl as public file-manager URLs into the - * User ontology so other platforms can render them directly. - */ - private async syncAvatarBannerToUserOntology( - client: GraphQLClient, - eName: string, - profile: ProfessionalProfile, - ): Promise { - try { - const userNode = await this.findMetaEnvelopeByOntology(client, USER_ONTOLOGY); - const existing = (userNode?.parsed ?? {}) as Record; - // Preserve the existing ACL; only default to public for new envelopes - const existingAcl = (userNode as any)?.acl; - - // Only patch avatarUrl/bannerUrl — don't overwrite other User fields - const patch: Record = { ...existing }; - if (profile.avatar) { - patch.avatarUrl = getFileManagerPublicUrl(profile.avatar); - } - if (profile.banner) { - patch.bannerUrl = getFileManagerPublicUrl(profile.banner); - } - - if (userNode) { - await client.request(UPDATE_MUTATION, { - id: userNode.id, - input: { - ontology: USER_ONTOLOGY, - payload: patch, - acl: existingAcl ?? ["*"], - }, - }); - } else { - patch.ename = eName; - patch.displayName = profile.displayName ?? eName; - await client.request(CREATE_MUTATION, { - input: { ontology: USER_ONTOLOGY, payload: patch, acl: ["*"] }, - }); - } - } catch (e) { - console.error("Failed to sync avatar/banner to User ontology:", e); - } - } - - async getProfileByEnvelope( - eName: string, - id: string, - ): Promise { - const client = await this.getClient(eName); - const result = await client.request( - META_ENVELOPE_QUERY, - { id }, - ); - return result.metaEnvelope; - } -} diff --git a/platforms/profile-editor/api/src/services/EVaultSyncService.ts b/platforms/profile-editor/api/src/services/EVaultSyncService.ts deleted file mode 100644 index 7b5e8988a..000000000 --- a/platforms/profile-editor/api/src/services/EVaultSyncService.ts +++ /dev/null @@ -1,133 +0,0 @@ -import axios from "axios"; -import { EVaultProfileService } from "./EVaultProfileService"; -import { UserSearchService } from "./UserSearchService"; - -interface VaultEntry { - ename: string; - uri: string; - evault: string; -} - -export class EVaultSyncService { - private evaultService: EVaultProfileService; - private userSearchService: UserSearchService; - private intervalId: ReturnType | null = null; - private syncing = false; - - private get registryUrl(): string { - return process.env.PUBLIC_REGISTRY_URL || "http://localhost:4321"; - } - - constructor(evaultService: EVaultProfileService) { - this.evaultService = evaultService; - this.userSearchService = new UserSearchService(); - } - - start(intervalMs: number = 5 * 60 * 1000): void { - this.syncAll(); - - this.intervalId = setInterval(() => { - this.syncAll(); - }, intervalMs); - - console.log( - `eVault sync started (every ${Math.round(intervalMs / 1000)}s)`, - ); - } - - stop(): void { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - console.log("eVault sync stopped"); - } - } - - private async syncAll(): Promise { - if (this.syncing) { - console.log("Sync already in progress, skipping"); - return; - } - - this.syncing = true; - const startTime = Date.now(); - - try { - const response = await axios.get( - `${this.registryUrl}/list`, - { timeout: 10000 }, - ); - const vaults = response.data; - - let synced = 0; - let failed = 0; - - for (const vault of vaults) { - try { - await this.syncUser(vault.ename); - synced++; - } catch { - failed++; - } - } - - const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.log( - `eVault sync complete: ${synced} synced, ${failed} failed, ${elapsed}s`, - ); - } catch (error: any) { - console.error( - "eVault sync failed to fetch registry list:", - error.message, - ); - } finally { - this.syncing = false; - } - } - - private async syncUser(ename: string): Promise { - const profile = await this.evaultService.getProfile(ename); - console.log(`[sync] ${ename}: avatar=${profile.professional.avatar ?? "NONE"} banner=${profile.professional.banner ?? "NONE"}`); - - const data: any = { - ename, - name: profile.name, - handle: profile.handle, - isVerified: profile.isVerified ?? false, - bio: profile.professional.bio, - headline: profile.professional.headline, - location: profile.professional.location, - skills: profile.professional.skills, - isPublic: profile.professional.isPublic === true, - isArchived: false, - }; - - if (profile.professional.avatar) data.avatar = profile.professional.avatar; - if (profile.professional.banner) data.banner = profile.professional.banner; - - await this.userSearchService.upsertFromWebhook(data); - } - - /** Sync a single user's profile to the local search DB from the profile cache. */ - syncUserToSearchDb(profile: import("../types/profile").FullProfile): void { - const data: any = { - ename: profile.ename, - name: profile.name, - handle: profile.handle, - isVerified: profile.isVerified ?? false, - bio: profile.professional.bio, - headline: profile.professional.headline, - location: profile.professional.location, - skills: profile.professional.skills, - isPublic: profile.professional.isPublic === true, - isArchived: false, - }; - - if (profile.professional.avatar) data.avatar = profile.professional.avatar; - if (profile.professional.banner) data.banner = profile.professional.banner; - - this.userSearchService.upsertFromWebhook(data).catch((err) => { - console.error(`[search-db sync] ${profile.ename}:`, err.message); - }); - } -} diff --git a/platforms/profile-editor/api/src/services/ProfessionalProfileService.ts b/platforms/profile-editor/api/src/services/ProfessionalProfileService.ts new file mode 100644 index 000000000..069328983 --- /dev/null +++ b/platforms/profile-editor/api/src/services/ProfessionalProfileService.ts @@ -0,0 +1,60 @@ +import type { Repository } from "typeorm"; +import { AppDataSource } from "../db"; +import { ProfessionalProfile } from "../entities/ProfessionalProfile"; + +/** Local fields the adapter maps from the Professional Profile ontology. */ +const PROFILE_FIELDS = [ + "name", + "headline", + "bio", + "location", + "skills", + "cvFileId", + "videoIntroFileId", + "isPublic", + "workExperience", + "education", + "socialLinks", + "email", + "phone", + "website", +] as const; + +export class ProfessionalProfileService { + private repo: Repository; + + constructor() { + this.repo = AppDataSource.getRepository(ProfessionalProfile); + } + + findByEname(ename: string): Promise { + return this.repo.findOneBy({ ename }); + } + + /** Existing row, or a new unsaved one — caller mutates then saves once. */ + async getOrNew(ename: string): Promise { + return ( + (await this.repo.findOneBy({ ename })) ?? + this.repo.create({ ename }) + ); + } + + save(profile: ProfessionalProfile): Promise { + return this.repo.save(profile); + } + + /** Upsert from an inbound webhook (adapter fromGlobal output). */ + async upsertFromGlobal( + ename: string, + data: Record, + ): Promise { + const profile = + (await this.repo.findOneBy({ ename })) ?? + this.repo.create({ ename }); + const target = profile as unknown as Record; + for (const key of PROFILE_FIELDS) { + if (data[key] !== undefined) target[key] = data[key]; + } + return this.repo.save(profile); + } +} diff --git a/platforms/profile-editor/api/src/services/RegistryService.ts b/platforms/profile-editor/api/src/services/RegistryService.ts deleted file mode 100644 index fb6736c19..000000000 --- a/platforms/profile-editor/api/src/services/RegistryService.ts +++ /dev/null @@ -1,78 +0,0 @@ -import axios from "axios"; - -interface PlatformTokenResponse { - token: string; - expiresAt?: number; -} - -interface ResolveResponse { - uri: string; - evault: string; -} - -export class RegistryService { - private platformToken: string | null = null; - private tokenExpiresAt: number = 0; - - /** Cache registry resolution: eName → { uri, evault, expiresAt } */ - private resolveCache = new Map< - string, - { uri: string; evault: string; expiresAt: number } - >(); - private static RESOLVE_TTL = 10 * 60 * 1000; // 10 minutes - - private get registryUrl(): string { - const url = process.env.PUBLIC_REGISTRY_URL; - if (!url) throw new Error("PUBLIC_REGISTRY_URL not configured"); - return url; - } - - private get platformBaseUrl(): string { - const url = process.env.PUBLIC_PROFILE_EDITOR_BASE_URL; - if (!url) - throw new Error("PUBLIC_PROFILE_EDITOR_BASE_URL not configured"); - return url; - } - - async ensurePlatformToken(): Promise { - const now = Date.now(); - if (this.platformToken && this.tokenExpiresAt > now + 5 * 60 * 1000) { - return this.platformToken; - } - - const response = await axios.post( - new URL("/platforms/certification", this.registryUrl).toString(), - { platform: this.platformBaseUrl }, - { headers: { "Content-Type": "application/json" } }, - ); - - this.platformToken = response.data.token; - this.tokenExpiresAt = response.data.expiresAt || now + 3600000; - return this.platformToken; - } - - async resolveEName(eName: string): Promise<{ uri: string; evault: string }> { - const now = Date.now(); - const cached = this.resolveCache.get(eName); - if (cached && cached.expiresAt > now) { - return { uri: cached.uri, evault: cached.evault }; - } - - const response = await axios.get( - `${this.registryUrl}/resolve`, - { params: { w3id: eName } }, - ); - const result = { uri: response.data.uri, evault: response.data.evault }; - this.resolveCache.set(eName, { - ...result, - expiresAt: now + RegistryService.RESOLVE_TTL, - }); - return result; - } - - async getEvaultGraphqlUrl(eName: string): Promise { - const { uri } = await this.resolveEName(eName); - const base = uri.replace(/\/$/, ""); - return `${base}/graphql`; - } -} diff --git a/platforms/profile-editor/api/src/services/UserSearchService.ts b/platforms/profile-editor/api/src/services/UserSearchService.ts deleted file mode 100644 index bd0b85455..000000000 --- a/platforms/profile-editor/api/src/services/UserSearchService.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { Repository } from "typeorm"; -import { AppDataSource } from "../database/data-source"; -import { User } from "../database/entities/User"; - -export class UserSearchService { - private userRepository: Repository; - - constructor() { - this.userRepository = AppDataSource.getRepository(User); - } - - async searchUsers( - query: string, - page: number = 1, - limit: number = 10, - sortBy: string = "relevance", - ) { - const searchQuery = query.trim(); - - if (searchQuery.length < 1) { - return { results: [], total: 0, page, limit, totalPages: 0 }; - } - - if (page < 1 || limit < 1 || limit > 100) { - return { results: [], total: 0, page, limit, totalPages: 0 }; - } - - const queryBuilder = this.userRepository - .createQueryBuilder("user") - .select([ - "user.id", - "user.ename", - "user.name", - "user.handle", - "user.bio", - "user.avatar", - "user.headline", - "user.location", - "user.skills", - "user.isVerified", - ]) - .addSelect( - ` - CASE - WHEN user.ename ILIKE :exactQuery THEN 100 - WHEN COALESCE(user.name, '') ILIKE :exactQuery THEN 90 - WHEN user.handle ILIKE :exactQuery THEN 80 - WHEN user.ename ILIKE :query THEN 70 - WHEN COALESCE(user.name, '') ILIKE :query THEN 60 - WHEN user.handle ILIKE :query THEN 50 - WHEN COALESCE(user.headline, '') ILIKE :query THEN 45 - WHEN COALESCE(user.bio, '') ILIKE :query THEN 30 - WHEN COALESCE(user.location, '') ILIKE :query THEN 25 - WHEN user.ename ILIKE :fuzzyQuery THEN 40 - WHEN COALESCE(user.name, '') ILIKE :fuzzyQuery THEN 35 - WHEN user.handle ILIKE :fuzzyQuery THEN 30 - WHEN EXISTS (SELECT 1 FROM unnest(COALESCE(user.skills, ARRAY[]::text[])) AS s WHERE s ILIKE :query) THEN 20 - ELSE 0 - END`, - "relevance_score", - ) - .where( - `(COALESCE(user.name, '') ILIKE :query OR user.ename ILIKE :query OR user.handle ILIKE :query - OR COALESCE(user.headline, '') ILIKE :query OR COALESCE(user.bio, '') ILIKE :query OR COALESCE(user.location, '') ILIKE :query - OR user.ename ILIKE :fuzzyQuery OR COALESCE(user.name, '') ILIKE :fuzzyQuery OR user.handle ILIKE :fuzzyQuery - OR EXISTS (SELECT 1 FROM unnest(COALESCE(user.skills, ARRAY[]::text[])) AS s WHERE s ILIKE :query))`, - { - query: `%${searchQuery}%`, - exactQuery: searchQuery, - fuzzyQuery: `%${searchQuery.split("").join("%")}%`, - }, - ) - .andWhere("user.isPublic = :isPublic", { isPublic: true }) - .andWhere("user.isArchived = :archived", { archived: false }); - - switch (sortBy) { - case "name": - queryBuilder.orderBy("user.name", "ASC"); - break; - case "newest": - queryBuilder.orderBy("user.createdAt", "DESC"); - break; - case "relevance": - default: - queryBuilder - .orderBy("relevance_score", "DESC") - .addOrderBy("user.isVerified", "DESC") - .addOrderBy("user.name", "ASC"); - break; - } - - const offset = (page - 1) * limit; - queryBuilder.skip(offset).take(limit); - - const [results, total] = await queryBuilder.getManyAndCount(); - - return { - results: results.map((user) => ({ - id: user.id, - ename: user.ename, - name: user.name, - handle: user.handle, - bio: user.bio, - avatar: user.avatar, - headline: user.headline, - location: user.location, - skills: user.skills, - isVerified: user.isVerified, - })), - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }; - } - - async listPublicUsers(page: number = 1, limit: number = 12) { - const queryBuilder = this.userRepository - .createQueryBuilder("user") - .select([ - "user.id", - "user.ename", - "user.name", - "user.handle", - "user.bio", - "user.avatar", - "user.headline", - "user.location", - "user.skills", - "user.isVerified", - ]) - .where("user.isPublic = :isPublic", { isPublic: true }) - .andWhere("user.isArchived = :archived", { archived: false }) - .orderBy("user.isVerified", "DESC") - .addOrderBy("user.name", "ASC"); - - const offset = (page - 1) * limit; - queryBuilder.skip(offset).take(limit); - - const [results, total] = await queryBuilder.getManyAndCount(); - - return { - results: results.map((user) => ({ - id: user.id, - ename: user.ename, - name: user.name, - handle: user.handle, - bio: user.bio, - avatar: user.avatar, - headline: user.headline, - location: user.location, - skills: user.skills, - isVerified: user.isVerified, - })), - total, - page, - limit, - totalPages: Math.ceil(total / limit), - }; - } - - async findByEname(ename: string): Promise { - return this.userRepository.findOneBy({ ename }); - } - - async upsertFromWebhook(data: Partial & { ename: string }): Promise { - let user = await this.userRepository.findOneBy({ ename: data.ename }); - - if (user) { - for (const key of Object.keys(data)) { - if (data[key as keyof User] !== undefined) { - (user as any)[key] = data[key as keyof User]; - } - } - return this.userRepository.save(user); - } - - user = this.userRepository.create(data); - return this.userRepository.save(user); - } -} diff --git a/platforms/profile-editor/api/src/services/UserService.ts b/platforms/profile-editor/api/src/services/UserService.ts new file mode 100644 index 000000000..0cf18a70c --- /dev/null +++ b/platforms/profile-editor/api/src/services/UserService.ts @@ -0,0 +1,55 @@ +import type { Repository } from "typeorm"; +import { AppDataSource } from "../db"; +import { User } from "../entities/User"; + +/** Local fields the adapter maps from the User ontology (universal → local). */ +const USER_FIELDS = [ + "handle", + "name", + "bio", + "avatarUrl", + "bannerUrl", + "isVerified", + "isPrivate", + "location", + "isArchived", +] as const; + +export class UserService { + private repo: Repository; + + constructor() { + this.repo = AppDataSource.getRepository(User); + } + + findByEname(ename: string): Promise { + return this.repo.findOneBy({ ename }); + } + + /** Existing row, or a new unsaved one — caller mutates then saves once. */ + async getOrNew(ename: string): Promise { + return ( + (await this.repo.findOneBy({ ename })) ?? + this.repo.create({ ename }) + ); + } + + save(user: User): Promise { + return this.repo.save(user); + } + + /** Upsert from an inbound webhook (adapter fromGlobal output). */ + async upsertFromGlobal( + ename: string, + data: Record, + ): Promise { + const user = + (await this.repo.findOneBy({ ename })) ?? + this.repo.create({ ename }); + const target = user as unknown as Record; + for (const key of USER_FIELDS) { + if (data[key] !== undefined) target[key] = data[key]; + } + return this.repo.save(user); + } +} diff --git a/platforms/profile-editor/api/src/types/express.d.ts b/platforms/profile-editor/api/src/types/express.d.ts index d593d2448..40c9b756d 100644 --- a/platforms/profile-editor/api/src/types/express.d.ts +++ b/platforms/profile-editor/api/src/types/express.d.ts @@ -1,13 +1,9 @@ +import type { AuthUser } from "@metastate-foundation/auth"; + declare global { - namespace Express { - interface Request { - user?: { - id: string; - ename: string; - [key: string]: unknown; - }; - } - } + namespace Express { + interface Request { + user?: AuthUser; + } + } } - -export {}; diff --git a/platforms/profile-editor/api/src/types/profile.ts b/platforms/profile-editor/api/src/types/profile.ts index 38b90219b..591809166 100644 --- a/platforms/profile-editor/api/src/types/profile.ts +++ b/platforms/profile-editor/api/src/types/profile.ts @@ -1,83 +1,66 @@ export interface WorkExperience { - id?: string; - company: string; - role: string; - description?: string; - startDate: string; - endDate?: string; - location?: string; - sortOrder: number; + id?: string; + company: string; + role: string; + description?: string; + startDate: string; + endDate?: string; + location?: string; + sortOrder: number; } export interface Education { - id?: string; - institution: string; - degree: string; - fieldOfStudy?: string; - startDate: string; - endDate?: string; - description?: string; - sortOrder: number; + id?: string; + institution: string; + degree: string; + fieldOfStudy?: string; + startDate: string; + endDate?: string; + description?: string; + sortOrder: number; } export interface SocialLink { - id?: string; - platform: string; - url: string; - label?: string; + id?: string; + platform: string; + url: string; + label?: string; } +/** The full professional-profile payload stored in the eVault PROFESSIONAL envelope. */ export interface ProfessionalProfile { - displayName?: string; - headline?: string; - bio?: string; - avatar?: string; - banner?: string; - cvFileId?: string; - videoIntroFileId?: string; - email?: string; - phone?: string; - website?: string; - location?: string; - isPublic?: boolean; - workExperience?: WorkExperience[]; - education?: Education[]; - skills?: string[]; - socialLinks?: SocialLink[]; -} - -export interface UserOntologyData { - username?: string; - displayName?: string; - bio?: string; - avatar?: string; - banner?: string; - ename?: string; - isVerified?: boolean; - isPrivate?: boolean; - location?: string; - website?: string; + displayName?: string; + headline?: string; + bio?: string; + avatar?: string; + banner?: string; + cvFileId?: string; + videoIntroFileId?: string; + email?: string; + phone?: string; + website?: string; + location?: string; + isPublic?: boolean; + workExperience?: WorkExperience[]; + education?: Education[]; + skills?: string[]; + socialLinks?: SocialLink[]; } +/** The shape returned to the client. */ export interface FullProfile { - ename: string; - name?: string; - handle?: string; - isVerified?: boolean; - professional: ProfessionalProfile; -} - -export interface ProfileUpdatePayload { - displayName?: string; - headline?: string; - bio?: string; - avatar?: string; - banner?: string; - cvFileId?: string; - videoIntroFileId?: string; - email?: string; - phone?: string; - website?: string; - location?: string; - isPublic?: boolean; + ename: string; + name?: string; + handle?: string; + isVerified?: boolean; + professional: Required< + Pick< + ProfessionalProfile, + "workExperience" | "education" | "skills" | "socialLinks" + > + > & + Omit< + ProfessionalProfile, + "workExperience" | "education" | "skills" | "socialLinks" + >; } diff --git a/platforms/profile-editor/api/src/utils/file-proxy.ts b/platforms/profile-editor/api/src/utils/file-proxy.ts deleted file mode 100644 index f160dc369..000000000 --- a/platforms/profile-editor/api/src/utils/file-proxy.ts +++ /dev/null @@ -1,235 +0,0 @@ -import axios from "axios"; -import jwt from "jsonwebtoken"; -import FormData from "form-data"; -import type { Response } from "express"; - -const FILE_MANAGER_BASE_URL = () => - process.env.PUBLIC_FILE_MANAGER_BASE_URL || "http://localhost:3005"; - -function mintFmToken(userId: string): string { - const secret = process.env.FILE_MANAGER_JWT_SECRET; - if (!secret) throw new Error("FILE_MANAGER_JWT_SECRET not configured"); - return jwt.sign({ userId }, secret, { expiresIn: "1h" }); -} - -import dns from "dns/promises"; -import net from "net"; - -/** - * Validates a URL is safe to fetch (not internal/private). - * Blocks non-https schemes (except data:), loopback, private, and link-local IPs. - */ -async function isUrlAllowed(url: string): Promise { - if (url.startsWith("data:")) return true; - let parsed: URL; - try { - parsed = new URL(url); - } catch { - return false; - } - if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false; - - const hostname = parsed.hostname; - if (hostname === "localhost" || hostname === "[::1]") return false; - - // Resolve hostname to IP and check for private ranges - let addresses: string[]; - try { - if (net.isIP(hostname)) { - addresses = [hostname]; - } else { - const results = await dns.resolve4(hostname).catch(() => [] as string[]); - const results6 = await dns.resolve6(hostname).catch(() => [] as string[]); - addresses = [...results, ...results6]; - } - } catch { - return false; - } - - for (const addr of addresses) { - if (net.isIPv4(addr)) { - const parts = addr.split(".").map(Number); - if (parts[0] === 127) return false; // 127.0.0.0/8 - if (parts[0] === 10) return false; // 10.0.0.0/8 - if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false; // 172.16.0.0/12 - if (parts[0] === 192 && parts[1] === 168) return false; // 192.168.0.0/16 - if (parts[0] === 169 && parts[1] === 254) return false; // 169.254.0.0/16 - if (parts[0] === 0) return false; // 0.0.0.0/8 - } else if (net.isIPv6(addr)) { - const normalized = addr.toLowerCase(); - if (normalized === "::1") return false; - if (normalized.startsWith("fc") || normalized.startsWith("fd")) return false; // fc00::/7 - if (normalized.startsWith("fe80")) return false; // fe80::/10 - } - } - return true; -} - -/** - * Downloads an image from a URL (HTTP or data: URI) and uploads it to the - * file-manager service, returning the resulting file ID. - * Returns null on any failure so webhook processing can continue. - */ -export async function downloadUrlAndUploadToFileManager( - url: string, - ename: string, -): Promise { - try { - if (!(await isUrlAllowed(url))) { - console.warn("SSRF blocked: disallowed URL", url); - return null; - } - - let buffer: Buffer; - let mimeType = "image/png"; - let filename = "avatar.png"; - - if (url.startsWith("data:")) { - const match = url.match(/^data:([^;]+);base64,(.+)$/); - if (!match) return null; - mimeType = match[1]; - buffer = Buffer.from(match[2], "base64"); - const ext = mimeType.split("/")[1] || "bin"; - filename = `upload.${ext}`; - } else { - const response = await axios.get(url, { - responseType: "arraybuffer", - timeout: 15_000, - maxRedirects: 3, - }); - buffer = Buffer.from(response.data); - const ct = response.headers["content-type"]; - if (ct) mimeType = ct.split(";")[0].trim(); - const ext = mimeType.split("/")[1] || "bin"; - filename = `upload.${ext}`; - } - - const token = mintFmToken(ename); - const form = new FormData(); - form.append("file", buffer, { filename, contentType: mimeType }); - - const res = await axios.post( - `${FILE_MANAGER_BASE_URL()}/api/files`, - form, - { - headers: { - ...form.getHeaders(), - Authorization: `Bearer ${token}`, - }, - timeout: 30_000, - maxContentLength: Infinity, - maxBodyLength: Infinity, - }, - ); - - const fileId = res.data?.id; - if (fileId) { - await markFilePublic(fileId, ename); - } - return fileId ?? null; - } catch (error: any) { - console.error( - "Failed to download/upload avatar or banner:", - error.message, - ); - return null; - } -} - -/** - * Marks a file as publicly accessible in file-manager via PATCH. - */ -export async function markFilePublic(fileId: string, ename: string): Promise { - try { - const token = mintFmToken(ename); - await axios.patch( - `${FILE_MANAGER_BASE_URL()}/api/files/${fileId}`, - { isPublic: true }, - { - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - timeout: 10_000, - }, - ); - } catch (error: any) { - console.error("Failed to mark file as public:", error.message); - } -} - -export async function proxyFileFromFileManager( - fileId: string, - ename: string, - res: Response, - disposition: "inline" | "attachment" = "inline", -): Promise { - try { - const token = mintFmToken(ename); - // Try preview first (works for images/PDFs); fall back to download for - // videos and other types that file-manager doesn't support previewing. - let response: import("axios").AxiosResponse; - try { - response = await axios.get( - `${FILE_MANAGER_BASE_URL()}/api/files/${fileId}/preview`, - { - responseType: "stream", - timeout: 60000, - headers: { Authorization: `Bearer ${token}` }, - }, - ); - } catch (previewErr: any) { - if (previewErr?.response?.status === 400) { - // Preview not supported for this type — use download endpoint - response = await axios.get( - `${FILE_MANAGER_BASE_URL()}/api/files/${fileId}/download`, - { - responseType: "stream", - timeout: 60000, - headers: { Authorization: `Bearer ${token}` }, - }, - ); - } else { - throw previewErr; - } - } - - const contentType = - response.headers["content-type"] || "application/octet-stream"; - const contentLength = response.headers["content-length"]; - - res.set("Content-Type", contentType); - res.set("Cache-Control", "no-cache"); - // Always set our own disposition so videos/PDFs render inline - const filename = - response.headers["content-disposition"]?.match( - /filename="?([^"]+)"?/, - )?.[1] ?? fileId; - res.set( - "Content-Disposition", - disposition === "attachment" - ? `attachment; filename="${filename}"` - : `inline; filename="${filename}"`, - ); - if (contentLength) { - res.set("Content-Length", contentLength); - } - response.data.pipe(res); - } catch (error: any) { - if (error?.response?.status === 404) { - console.warn(`[file-proxy] 404 from file-manager for fileId=${fileId} (user=${ename})`); - res.status(404).json({ error: "File not found" }); - } else if (error?.response) { - const chunks: Buffer[] = []; - error.response.data.on("data", (chunk: Buffer) => chunks.push(chunk)); - error.response.data.on("end", () => { - const body = Buffer.concat(chunks).toString(); - console.error("File proxy error:", body); - }); - res.status(502).json({ error: "Failed to fetch file" }); - } else { - console.error("File proxy error:", error.message); - res.status(502).json({ error: "Failed to fetch file" }); - } - } -} diff --git a/platforms/profile-editor/api/src/web3adapter/mappings/professional-profile.mapping.json b/platforms/profile-editor/api/src/web3adapter/mappings/professional-profile.mapping.json index 906b2c9e6..20062e8f0 100644 --- a/platforms/profile-editor/api/src/web3adapter/mappings/professional-profile.mapping.json +++ b/platforms/profile-editor/api/src/web3adapter/mappings/professional-profile.mapping.json @@ -1,16 +1,22 @@ { - "tableName": "professional_profiles", - "schemaId": "550e8400-e29b-41d4-a716-446655440009", - "ownerEnamePath": "ename", - "ownedJunctionTables": [], - "localToUniversalMap": { - "name": "displayName", - "headline": "headline", - "bio": "bio", - "cvFileId": "cvFileId", - "videoIntroFileId": "videoIntroFileId", - "location": "location", - "skills": "skills", - "isPublic": "isPublic" - } + "tableName": "professional_profiles", + "schemaId": "550e8400-e29b-41d4-a716-446655440009", + "ownerEnamePath": "ename", + "ownedJunctionTables": [], + "localToUniversalMap": { + "name": "displayName", + "headline": "headline", + "bio": "bio", + "location": "location", + "skills": "skills", + "cvFileId": "__file(cvFileId)", + "videoIntroFileId": "__file(videoIntroFileId)", + "isPublic": "isPublic", + "workExperience": "workExperience", + "education": "education", + "socialLinks": "socialLinks", + "email": "email", + "phone": "phone", + "website": "website" + } } diff --git a/platforms/profile-editor/api/src/web3adapter/mappings/user.mapping.json b/platforms/profile-editor/api/src/web3adapter/mappings/user.mapping.json index 0801aacc7..d39eba0e6 100644 --- a/platforms/profile-editor/api/src/web3adapter/mappings/user.mapping.json +++ b/platforms/profile-editor/api/src/web3adapter/mappings/user.mapping.json @@ -1,18 +1,20 @@ { - "tableName": "users", - "schemaId": "550e8400-e29b-41d4-a716-446655440000", - "ownerEnamePath": "ename", - "ownedJunctionTables": [], - "localToUniversalMap": { - "handle": "username", - "name": "displayName", - "bio": "bio", - "ename": "ename", - "isVerified": "isVerified", - "isPublic": "isPublic", - "location": "location", - "createdAt": "createdAt", - "updatedAt": "updatedAt", - "isArchived": "isArchived" - } + "tableName": "users", + "schemaId": "550e8400-e29b-41d4-a716-446655440000", + "ownerEnamePath": "ename", + "ownedJunctionTables": [], + "localToUniversalMap": { + "handle": "username", + "name": "displayName", + "bio": "bio", + "avatarUrl": "__file(avatarUrl)", + "bannerUrl": "__file(bannerUrl)", + "ename": "ename", + "isVerified": "isVerified", + "isPrivate": "isPrivate", + "location": "location", + "createdAt": "createdAt", + "updatedAt": "updatedAt", + "isArchived": "isArchived" + } } diff --git a/platforms/profile-editor/api/src/web3adapter/watchers/subscriber.ts b/platforms/profile-editor/api/src/web3adapter/watchers/subscriber.ts index 723b0f071..f9415e299 100644 --- a/platforms/profile-editor/api/src/web3adapter/watchers/subscriber.ts +++ b/platforms/profile-editor/api/src/web3adapter/watchers/subscriber.ts @@ -1,132 +1,102 @@ +import path from "node:path"; import { - EventSubscriber, - EntitySubscriberInterface, - InsertEvent, - UpdateEvent, - RemoveEvent, + type EntitySubscriberInterface, + EventSubscriber, + type InsertEvent, + type UpdateEvent, } from "typeorm"; import { Web3Adapter } from "web3-adapter"; -import path from "path"; -import dotenv from "dotenv"; - -dotenv.config({ path: path.resolve(__dirname, "../../../../../../.env") }); +import { env } from "../../env"; +/** Single adapter instance — owns the mappings, the eVault client, the id-map. */ export const adapter = new Web3Adapter({ - schemasPath: path.resolve(__dirname, "../mappings/"), - dbPath: path.resolve( - process.env.PROFILE_EDITOR_MAPPING_DB_PATH as string, - ), - registryUrl: process.env.PUBLIC_REGISTRY_URL as string, - platform: process.env.PUBLIC_PROFILE_EDITOR_BASE_URL as string, + schemasPath: path.resolve(__dirname, "../mappings/"), + dbPath: path.resolve(env.mappingDbPath), + registryUrl: env.registryUrl, + platform: env.baseUrl, }); +const SYNCED_TABLES = new Set(["users", "professional_profiles"]); + +/** + * Outbound sync: on any local write to users/professional_profiles, push the + * row to the owner's eVault via the adapter (toGlobal). `lockedIds` skips rows + * we just received inbound (echo prevention). A 3s debounce coalesces rapid + * edits and lets the webhook's storeMapping land first. + */ @EventSubscriber() export class PostgresSubscriber implements EntitySubscriberInterface { - private adapter: Web3Adapter; - private pendingChanges: Map = new Map(); - - constructor() { - this.adapter = adapter; - - setInterval(() => { - const now = Date.now(); - const maxAge = 10 * 60 * 1000; - for (const [key, timestamp] of this.pendingChanges.entries()) { - if (now - timestamp > maxAge) { - this.pendingChanges.delete(key); - } - } - }, 5 * 60 * 1000); - } - - async afterInsert(event: InsertEvent) { - const entity = event.entity; - if (!entity) return; - const tableName = event.metadata.tableName.endsWith("s") - ? event.metadata.tableName - : event.metadata.tableName + "s"; - await this.handleChange(this.entityToPlain(entity), tableName); - } - - async afterUpdate(event: UpdateEvent) { - const entity = event.entity; - if (!entity) return; - await this.handleChange( - this.entityToPlain(entity), - event.metadata.tableName, - ); - } - - async afterRemove(event: RemoveEvent) { - const entity = event.entity; - if (!entity) return; - await this.handleChange( - this.entityToPlain(entity), - event.metadata.tableName, - ); - } - - private async handleChange(data: any, tableName: string): Promise { - if (tableName === "sessions" || tableName === "users") return; - if (!data.id) return; - console.log(`[subscriber] change detected: table=${tableName} id=${data.id} keys=[${Object.keys(data).join(",")}]`); - - const changeKey = `${tableName}:${data.id}`; - if (this.pendingChanges.has(changeKey)) return; - this.pendingChanges.set(changeKey, Date.now()); - - try { - setTimeout(async () => { - try { - let globalId = - await this.adapter.mappingDb.getGlobalId(data.id); - globalId = globalId ?? ""; - - if (this.adapter.lockedIds.includes(globalId)) { - return; - } - - await this.adapter.handleChange({ - data, - tableName: tableName.toLowerCase(), - }); - } finally { - this.pendingChanges.delete(changeKey); - } - }, 3_000); - } catch (error) { - console.error( - `Error processing change for ${tableName}:`, - error, - ); - this.pendingChanges.delete(changeKey); - } - } - - private entityToPlain(entity: any): any { - if (!entity || typeof entity !== "object") return entity; - if (entity instanceof Date) return entity.toISOString(); - if (Array.isArray(entity)) - return entity.map((item) => this.entityToPlain(item)); - - const plain: Record = {}; - for (const [key, value] of Object.entries(entity)) { - if (key.startsWith("_")) continue; + private pending = new Map(); + + constructor() { + setInterval( + () => { + const now = Date.now(); + for (const [key, ts] of this.pending.entries()) { + if (now - ts > 10 * 60 * 1000) this.pending.delete(key); + } + }, + 5 * 60 * 1000, + ); + } + + afterInsert(event: InsertEvent) { + this.handle(event.entity, event.metadata.tableName); + } + + afterUpdate(event: UpdateEvent) { + if (event.entity) this.handle(event.entity, event.metadata.tableName); + } + + private handle(entity: unknown, tableName: string): void { + if (!SYNCED_TABLES.has(tableName)) return; + const id = (entity as { id?: string })?.id; + if (!id) return; + + const key = `${tableName}:${id}`; + if (this.pending.has(key)) return; + this.pending.set(key, Date.now()); + + setTimeout(async () => { + try { + const globalId = + (await adapter.mappingDb.getGlobalId(id)) ?? ""; + if (adapter.lockedIds.includes(globalId)) return; + + // Re-fetch the full row so toGlobal sees every mapped field + // (a TypeORM update event may carry only changed columns). + const { AppDataSource } = await import("../../db"); + const { User } = await import("../../entities/User"); + const { ProfessionalProfile } = await import( + "../../entities/ProfessionalProfile" + ); + const repo = AppDataSource.getRepository( + tableName === "users" ? User : ProfessionalProfile, + ); + const row = await repo.findOneBy({ id }); + if (!row) return; + + await adapter.handleChange({ + data: entityToPlain(row), + tableName, + }); + } catch (err) { + console.error( + `[subscriber] ${tableName} sync failed:`, + (err as Error).message, + ); + } finally { + this.pending.delete(key); + } + }, 3_000); + } +} - 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; - } +function entityToPlain(entity: unknown): Record { + const plain: Record = {}; + for (const [key, value] of Object.entries(entity as object)) { + if (key.startsWith("_")) continue; + plain[key] = value instanceof Date ? value.toISOString() : value; + } + return plain; } diff --git a/platforms/profile-editor/api/tsconfig.json b/platforms/profile-editor/api/tsconfig.json index f746b3c9a..70c70785c 100644 --- a/platforms/profile-editor/api/tsconfig.json +++ b/platforms/profile-editor/api/tsconfig.json @@ -16,10 +16,7 @@ "declaration": true, "declarationMap": true, "sourceMap": true, - "typeRoots": [ - "./src/types", - "./node_modules/@types" - ] + "typeRoots": ["./src/types", "./node_modules/@types"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/platforms/profile-editor/client/src/lib/components/profile/DocumentsSection.svelte b/platforms/profile-editor/client/src/lib/components/profile/DocumentsSection.svelte index aab84a661..d435c4437 100644 --- a/platforms/profile-editor/client/src/lib/components/profile/DocumentsSection.svelte +++ b/platforms/profile-editor/client/src/lib/components/profile/DocumentsSection.svelte @@ -1,6 +1,6 @@ - {#if avatarProxyUrl()} - + {#if result.avatar} + {/if} {(result.name || result.ename || '?')[0]?.toUpperCase()} diff --git a/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte b/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte index 18cf0c15f..019f87af2 100644 --- a/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte +++ b/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte @@ -1,7 +1,7 @@