diff --git a/.env.example b/.env.example index 68051e031..c62b15c2f 100644 --- a/.env.example +++ b/.env.example @@ -92,6 +92,7 @@ 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:3006" DREAMSYNC_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/dreamsync VITE_DREAMSYNC_BASE_URL="http://localhost:8888" diff --git a/.npmrc b/.npmrc index 0d1ebfb2e..ed160c5f4 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,2 @@ optional=true strict-peer-dependencies=false - diff --git a/README.md b/README.md index e47ba09f5..524efd9ec 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ ### Port Assignments #### Core Services (Always Running) + - **4321** - Registry Service - **3001** - evault-core Provisioning API (Express) - **4000** - evault-core GraphQL/HTTP API (Fastify) @@ -17,6 +18,7 @@ - **7687** - Neo4j Bolt Protocol #### Platform APIs + - **3000** - Blabsy W3DS Auth API - **3002** - Cerberus API - **3003** - Group Charter Manager API @@ -28,6 +30,7 @@ - **1111** - Pictique API #### Frontend Services + - **8080** - Dev sandbox (W3DS) / Blabsy Frontend - **5173** - Pictique Frontend - **3004** - Group Charter Manager Frontend @@ -37,19 +40,24 @@ ### Docker Compose Profiles #### `socials` Profile + Runs core services plus social media platforms: + - Core: registry, evault-core, postgres, neo4j - Pictique API + Frontend - Blabsy API + Frontend #### `charter-blabsy` Profile + Runs core services plus charter and blabsy: + - Core: registry, evault-core, postgres, neo4j - Group Charter Manager API + Frontend - Blabsy API + Frontend - Cerberus #### `all` Profile + Runs all services (core + all APIs + all frontends) ### Usage diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index e112cac0c..616fc4c96 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -46,6 +46,7 @@ const config: Config = { sidebarPath: './sidebars.ts', editUrl: 'https://github.com/MetaState-Prototype-Project/prototype/tree/main/docs/', }, + blog: false, } satisfies Preset.Options, ], ], diff --git a/infrastructure/control-panel/config/admin-enames.json b/infrastructure/control-panel/config/admin-enames.json index 1827d693c..5bc4d31c8 100644 --- a/infrastructure/control-panel/config/admin-enames.json +++ b/infrastructure/control-panel/config/admin-enames.json @@ -3,7 +3,6 @@ "@7218b67d-da21-54d6-9a85-7c4db1d09768", "@82f7a77a-f03a-52aa-88fc-1b1e488ad498", "@35a31f0d-dd76-5780-b383-29f219fcae99", - "@82f7a77a-f03a-52aa-88fc-1b1e488ad498", - "@af7e4f55-ad9d-537c-81ef-4f3a234bdd2c" + "@82f7a77a-f03a-52aa-88fc-1b1e488ad498" ] } diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 000000000..612124ac0 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,33 @@ +{ + "name": "@metastate-foundation/auth", + "version": "0.1.0", + "description": "Shared authentication utilities for w3ds platform APIs", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "check-types": "tsc --noEmit", + "postinstall": "npm run build" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": ["dist"], + "dependencies": { + "jsonwebtoken": "^9.0.2", + "signature-validator": "workspace:*", + "uuid": "^11.1.0" + }, + "devDependencies": { + "@types/jsonwebtoken": "^9.0.9", + "@types/node": "^20.11.24", + "@types/uuid": "^10.0.0", + "typescript": "~5.6.2" + } +} diff --git a/packages/auth/src/auth-offer.ts b/packages/auth/src/auth-offer.ts new file mode 100644 index 000000000..afd103af8 --- /dev/null +++ b/packages/auth/src/auth-offer.ts @@ -0,0 +1,15 @@ +import { v4 as uuidv4 } from "uuid"; +import type { AuthOfferConfig } from "./types.js"; + +export interface AuthOffer { + uri: string; + session: string; +} + +export function buildAuthOffer(config: AuthOfferConfig): AuthOffer { + const callbackPath = config.callbackPath ?? "/api/auth"; + const url = new URL(callbackPath, config.baseUrl).toString(); + const session = uuidv4(); + const uri = `w3ds://auth?redirect=${url}&session=${session}&platform=${config.platform}`; + return { uri, session }; +} diff --git a/packages/auth/src/guard.ts b/packages/auth/src/guard.ts new file mode 100644 index 000000000..a53d4bfad --- /dev/null +++ b/packages/auth/src/guard.ts @@ -0,0 +1,8 @@ +export function createAuthGuard() { + return (req: any, res: any, next: any): void => { + if (!req.user) { + return res.status(401).json({ error: "Authentication required" }); + } + next(); + }; +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 000000000..36cc6998c --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,14 @@ +export { signToken, verifyToken } from "./jwt.js"; +export { createAuthMiddleware } from "./middleware.js"; +export { createAuthGuard } from "./guard.js"; +export { buildAuthOffer } from "./auth-offer.js"; +export type { AuthOffer } from "./auth-offer.js"; +export { verifyLoginSignature } from "./verify-login.js"; +export type { + AuthUser, + JwtPayload, + AuthMiddlewareConfig, + AuthOfferConfig, + LoginVerificationConfig, + LoginVerificationResult, +} from "./types.js"; diff --git a/packages/auth/src/jwt.ts b/packages/auth/src/jwt.ts new file mode 100644 index 000000000..3704135b2 --- /dev/null +++ b/packages/auth/src/jwt.ts @@ -0,0 +1,20 @@ +import jwt from "jsonwebtoken"; +import type { JwtPayload } from "./types.js"; + +export function signToken( + payload: JwtPayload, + secret: string, + options?: { expiresIn?: string | number }, +): string { + return jwt.sign(payload as object, secret, { + expiresIn: (options?.expiresIn ?? "7d") as any, + }); +} + +export function verifyToken(token: string, secret: string): JwtPayload { + try { + return jwt.verify(token, secret) as JwtPayload; + } catch { + throw new Error("Invalid token"); + } +} diff --git a/packages/auth/src/middleware.ts b/packages/auth/src/middleware.ts new file mode 100644 index 000000000..b3812f7fb --- /dev/null +++ b/packages/auth/src/middleware.ts @@ -0,0 +1,32 @@ +import { verifyToken } from "./jwt.js"; +import type { AuthMiddlewareConfig } from "./types.js"; + +export function createAuthMiddleware(config: AuthMiddlewareConfig) { + return async (req: any, res: any, next: any): Promise => { + try { + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + return next(); + } + + const token = authHeader.split(" ")[1]; + const decoded = verifyToken(token, config.secret); + + if (!decoded?.userId) { + return res.status(401).json({ error: "Invalid token" }); + } + + const user = await config.findUser(decoded.userId); + + if (!user) { + return res.status(401).json({ error: "User not found" }); + } + + req.user = user; + next(); + } catch (error) { + console.error("Auth middleware error:", error); + res.status(401).json({ error: "Invalid token" }); + } + }; +} diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts new file mode 100644 index 000000000..21495e93b --- /dev/null +++ b/packages/auth/src/types.ts @@ -0,0 +1,34 @@ +export interface AuthUser { + id: string; + ename: string; + [key: string]: unknown; +} + +export interface JwtPayload { + userId: string; + [key: string]: unknown; +} + +export interface AuthMiddlewareConfig { + secret: string; + findUser: (userId: string) => Promise; +} + +export interface AuthOfferConfig { + baseUrl: string; + platform: string; + callbackPath?: string; +} + +export interface LoginVerificationConfig { + eName: string; + signature: string; + session: string; + registryBaseUrl: string; +} + +export interface LoginVerificationResult { + valid: boolean; + error?: string; + publicKey?: string; +} diff --git a/packages/auth/src/verify-login.ts b/packages/auth/src/verify-login.ts new file mode 100644 index 000000000..2904aa0f0 --- /dev/null +++ b/packages/auth/src/verify-login.ts @@ -0,0 +1,22 @@ +import { verifySignature } from "signature-validator"; +import type { + LoginVerificationConfig, + LoginVerificationResult, +} from "./types.js"; + +export async function verifyLoginSignature( + config: LoginVerificationConfig, +): Promise { + const result = await verifySignature({ + eName: config.eName, + signature: config.signature, + payload: config.session, + registryBaseUrl: config.registryBaseUrl, + }); + + return { + valid: result.valid, + error: result.error, + publicKey: result.publicKey, + }; +} diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 000000000..b6d15c93b --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ui/.gitignore b/packages/ui/.gitignore new file mode 100644 index 000000000..407df197b --- /dev/null +++ b/packages/ui/.gitignore @@ -0,0 +1,3 @@ +node_modules +.svelte-kit +dist diff --git a/packages/ui/.prettierignore b/packages/ui/.prettierignore new file mode 100644 index 000000000..1521c8b76 --- /dev/null +++ b/packages/ui/.prettierignore @@ -0,0 +1 @@ +dist diff --git a/packages/ui/.prettierrc b/packages/ui/.prettierrc new file mode 100644 index 000000000..95730232b --- /dev/null +++ b/packages/ui/.prettierrc @@ -0,0 +1,8 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/packages/ui/components.json b/packages/ui/components.json new file mode 100644 index 000000000..c5d91b458 --- /dev/null +++ b/packages/ui/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/app.css", + "baseColor": "slate" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" +} diff --git a/packages/ui/eslint.config.js b/packages/ui/eslint.config.js new file mode 100644 index 000000000..56232c02e --- /dev/null +++ b/packages/ui/eslint.config.js @@ -0,0 +1,48 @@ +import prettier from 'eslint-config-prettier'; +import { fileURLToPath } from 'node:url'; +import { includeIgnoreFile } from '@eslint/compat'; +import js from '@eslint/js'; +import svelte from 'eslint-plugin-svelte'; +import { defineConfig } from 'eslint/config'; +import globals from 'globals'; +import ts from 'typescript-eslint'; +import svelteConfig from './svelte.config.js'; + +const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url)); + +export default defineConfig( + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node } + }, + rules: { + 'no-undef': 'off', + 'svelte/no-navigation-without-resolve': ['error', { ignoreLinks: true }], + '@typescript-eslint/no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_' + } + ] + } + }, + { + files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: ['.svelte'], + parser: ts.parser, + svelteConfig + } + } + } +); diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 000000000..67515c2fb --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,138 @@ +{ + "name": "@metastate-foundation/ui", + "version": "0.0.1", + "type": "module", + "svelte": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "svelte": "./dist/index.js", + "default": "./dist/index.js" + }, + "./styles": { + "default": "./src/app.css" + }, + "./avatar": { + "types": "./dist/components/ui/avatar/index.d.ts", + "svelte": "./dist/components/ui/avatar/index.js", + "default": "./dist/components/ui/avatar/index.js" + }, + "./badge": { + "types": "./dist/components/ui/badge/index.d.ts", + "svelte": "./dist/components/ui/badge/index.js", + "default": "./dist/components/ui/badge/index.js" + }, + "./button": { + "types": "./dist/components/ui/button/index.d.ts", + "svelte": "./dist/components/ui/button/index.js", + "default": "./dist/components/ui/button/index.js" + }, + "./card": { + "types": "./dist/components/ui/card/index.d.ts", + "svelte": "./dist/components/ui/card/index.js", + "default": "./dist/components/ui/card/index.js" + }, + "./input": { + "types": "./dist/components/ui/input/index.d.ts", + "svelte": "./dist/components/ui/input/index.js", + "default": "./dist/components/ui/input/index.js" + }, + "./label": { + "types": "./dist/components/ui/label/index.d.ts", + "svelte": "./dist/components/ui/label/index.js", + "default": "./dist/components/ui/label/index.js" + }, + "./progress": { + "types": "./dist/components/ui/progress/index.d.ts", + "svelte": "./dist/components/ui/progress/index.js", + "default": "./dist/components/ui/progress/index.js" + }, + "./select": { + "types": "./dist/components/ui/select/index.d.ts", + "svelte": "./dist/components/ui/select/index.js", + "default": "./dist/components/ui/select/index.js" + }, + "./separator": { + "types": "./dist/components/ui/separator/index.d.ts", + "svelte": "./dist/components/ui/separator/index.js", + "default": "./dist/components/ui/separator/index.js" + }, + "./skeleton": { + "types": "./dist/components/ui/skeleton/index.d.ts", + "svelte": "./dist/components/ui/skeleton/index.js", + "default": "./dist/components/ui/skeleton/index.js" + }, + "./sonner": { + "types": "./dist/components/ui/sonner/index.d.ts", + "svelte": "./dist/components/ui/sonner/index.js", + "default": "./dist/components/ui/sonner/index.js" + }, + "./textarea": { + "types": "./dist/components/ui/textarea/index.d.ts", + "svelte": "./dist/components/ui/textarea/index.js", + "default": "./dist/components/ui/textarea/index.js" + }, + "./utils": { + "types": "./dist/utils.d.ts", + "import": "./dist/utils.js", + "default": "./dist/utils.js" + }, + "./hooks/*": { + "types": "./dist/hooks/*.d.ts", + "svelte": "./dist/hooks/*.js", + "default": "./dist/hooks/*.js" + } + }, + "files": [ + "dist", + "src", + "!dist/**/*.test.*" + ], + "scripts": { + "dev": "chokidar \"src/**/*\" -c \"pnpm build\" --initial", + "build": "svelte-kit sync && svelte-package", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --threshold error", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "prettier --check . && eslint .", + "prepare": "svelte-kit sync && svelte-package", + "add": "shadcn-svelte add" + }, + "peerDependencies": { + "svelte": "^5.0.0" + }, + "dependencies": { + "bits-ui": "^2.15.4", + "clsx": "^2.1.1", + "mode-watcher": "^1.1.0", + "svelte-sonner": "^1.0.5", + "@lucide/svelte": "^0.564.0", + "@sveltejs/kit": "^2.43.2", + "tailwind-merge": "^3.4.1", + "tailwind-variants": "^3.2.2" + }, + "devDependencies": { + "@eslint/compat": "^1.4.0", + "@eslint/js": "^9.36.0", + "@internationalized/date": "^3.10.0", + "@sveltejs/package": "^2.3.10", + "@sveltejs/vite-plugin-svelte": "^6.2.0", + "@tailwindcss/vite": "^4.1.14", + "chokidar-cli": "^3.0.0", + "eslint": "^9.36.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-svelte": "^3.12.4", + "globals": "^16.4.0", + "prettier": "^3.6.2", + "prettier-plugin-svelte": "^3.4.0", + "shadcn-svelte": "^1.1.1", + "svelte": "^5.39.5", + "svelte-check": "^4.3.2", + "tailwindcss": "^4.1.14", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.2", + "typescript-eslint": "^8.44.1", + "vite": "^7.1.7" + } +} diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css new file mode 100644 index 000000000..fdbba7ef8 --- /dev/null +++ b/packages/ui/src/app.css @@ -0,0 +1,123 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +/* Scan UI component output for Tailwind classes */ +@source "../dist"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.129 0.042 264.695); + --card: oklch(1 0 0); + --card-foreground: oklch(0.129 0.042 264.695); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.129 0.042 264.695); + --primary: oklch(0.208 0.042 265.755); + --primary-foreground: oklch(0.984 0.003 247.858); + --secondary: oklch(0.968 0.007 247.896); + --secondary-foreground: oklch(0.208 0.042 265.755); + --muted: oklch(0.968 0.007 247.896); + --muted-foreground: oklch(0.554 0.046 257.417); + --accent: oklch(0.968 0.007 247.896); + --accent-foreground: oklch(0.208 0.042 265.755); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.929 0.013 255.508); + --input: oklch(0.929 0.013 255.508); + --ring: oklch(0.704 0.04 256.788); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.984 0.003 247.858); + --sidebar-foreground: oklch(0.129 0.042 264.695); + --sidebar-primary: oklch(0.208 0.042 265.755); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.968 0.007 247.896); + --sidebar-accent-foreground: oklch(0.208 0.042 265.755); + --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-ring: oklch(0.704 0.04 256.788); +} + +.dark { + --background: oklch(0.129 0.042 264.695); + --foreground: oklch(0.984 0.003 247.858); + --card: oklch(0.208 0.042 265.755); + --card-foreground: oklch(0.984 0.003 247.858); + --popover: oklch(0.208 0.042 265.755); + --popover-foreground: oklch(0.984 0.003 247.858); + --primary: oklch(0.929 0.013 255.508); + --primary-foreground: oklch(0.208 0.042 265.755); + --secondary: oklch(0.279 0.041 260.031); + --secondary-foreground: oklch(0.984 0.003 247.858); + --muted: oklch(0.279 0.041 260.031); + --muted-foreground: oklch(0.704 0.04 256.788); + --accent: oklch(0.279 0.041 260.031); + --accent-foreground: oklch(0.984 0.003 247.858); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.551 0.027 264.364); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.208 0.042 265.755); + --sidebar-foreground: oklch(0.984 0.003 247.858); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.279 0.041 260.031); + --sidebar-accent-foreground: oklch(0.984 0.003 247.858); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.551 0.027 264.364); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/packages/ui/src/lib/components/ui/avatar/avatar-fallback.svelte b/packages/ui/src/lib/components/ui/avatar/avatar-fallback.svelte new file mode 100644 index 000000000..4a452e9db --- /dev/null +++ b/packages/ui/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -0,0 +1,17 @@ + + + diff --git a/packages/ui/src/lib/components/ui/avatar/avatar-image.svelte b/packages/ui/src/lib/components/ui/avatar/avatar-image.svelte new file mode 100644 index 000000000..7ccc3ce38 --- /dev/null +++ b/packages/ui/src/lib/components/ui/avatar/avatar-image.svelte @@ -0,0 +1,17 @@ + + + diff --git a/packages/ui/src/lib/components/ui/avatar/avatar.svelte b/packages/ui/src/lib/components/ui/avatar/avatar.svelte new file mode 100644 index 000000000..3fd4dc2aa --- /dev/null +++ b/packages/ui/src/lib/components/ui/avatar/avatar.svelte @@ -0,0 +1,19 @@ + + + diff --git a/packages/ui/src/lib/components/ui/avatar/index.ts b/packages/ui/src/lib/components/ui/avatar/index.ts new file mode 100644 index 000000000..9585f8ad6 --- /dev/null +++ b/packages/ui/src/lib/components/ui/avatar/index.ts @@ -0,0 +1,13 @@ +import Root from './avatar.svelte'; +import Image from './avatar-image.svelte'; +import Fallback from './avatar-fallback.svelte'; + +export { + Root, + Image, + Fallback, + // + Root as Avatar, + Image as AvatarImage, + Fallback as AvatarFallback +}; diff --git a/packages/ui/src/lib/components/ui/badge/badge.svelte b/packages/ui/src/lib/components/ui/badge/badge.svelte new file mode 100644 index 000000000..e192aeb85 --- /dev/null +++ b/packages/ui/src/lib/components/ui/badge/badge.svelte @@ -0,0 +1,50 @@ + + + + + + {@render children?.()} + diff --git a/packages/ui/src/lib/components/ui/badge/index.ts b/packages/ui/src/lib/components/ui/badge/index.ts new file mode 100644 index 000000000..f05fb87fa --- /dev/null +++ b/packages/ui/src/lib/components/ui/badge/index.ts @@ -0,0 +1,2 @@ +export { default as Badge } from './badge.svelte'; +export { badgeVariants, type BadgeVariant } from './badge.svelte'; diff --git a/packages/ui/src/lib/components/ui/button/button.svelte b/packages/ui/src/lib/components/ui/button/button.svelte new file mode 100644 index 000000000..fad512e85 --- /dev/null +++ b/packages/ui/src/lib/components/ui/button/button.svelte @@ -0,0 +1,82 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/packages/ui/src/lib/components/ui/button/index.ts b/packages/ui/src/lib/components/ui/button/index.ts new file mode 100644 index 000000000..5414d9d34 --- /dev/null +++ b/packages/ui/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants +} from './button.svelte'; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant +}; diff --git a/packages/ui/src/lib/components/ui/card/card-action.svelte b/packages/ui/src/lib/components/ui/card/card-action.svelte new file mode 100644 index 000000000..bbaafd224 --- /dev/null +++ b/packages/ui/src/lib/components/ui/card/card-action.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/components/ui/card/card-content.svelte b/packages/ui/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 000000000..1d60124ad --- /dev/null +++ b/packages/ui/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/components/ui/card/card-description.svelte b/packages/ui/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 000000000..b46a1adee --- /dev/null +++ b/packages/ui/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,20 @@ + + +

+ {@render children?.()} +

diff --git a/packages/ui/src/lib/components/ui/card/card-footer.svelte b/packages/ui/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 000000000..4e390bfd3 --- /dev/null +++ b/packages/ui/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/components/ui/card/card-header.svelte b/packages/ui/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 000000000..9cdc602ba --- /dev/null +++ b/packages/ui/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/components/ui/card/card-title.svelte b/packages/ui/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 000000000..0eae8ec1a --- /dev/null +++ b/packages/ui/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/components/ui/card/card.svelte b/packages/ui/src/lib/components/ui/card/card.svelte new file mode 100644 index 000000000..10304357f --- /dev/null +++ b/packages/ui/src/lib/components/ui/card/card.svelte @@ -0,0 +1,23 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/components/ui/card/index.ts b/packages/ui/src/lib/components/ui/card/index.ts new file mode 100644 index 000000000..77d367477 --- /dev/null +++ b/packages/ui/src/lib/components/ui/card/index.ts @@ -0,0 +1,25 @@ +import Root from './card.svelte'; +import Content from './card-content.svelte'; +import Description from './card-description.svelte'; +import Footer from './card-footer.svelte'; +import Header from './card-header.svelte'; +import Title from './card-title.svelte'; +import Action from './card-action.svelte'; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction +}; diff --git a/packages/ui/src/lib/components/ui/input/index.ts b/packages/ui/src/lib/components/ui/input/index.ts new file mode 100644 index 000000000..15c0933dc --- /dev/null +++ b/packages/ui/src/lib/components/ui/input/index.ts @@ -0,0 +1,7 @@ +import Root from './input.svelte'; + +export { + Root, + // + Root as Input +}; diff --git a/packages/ui/src/lib/components/ui/input/input.svelte b/packages/ui/src/lib/components/ui/input/input.svelte new file mode 100644 index 000000000..9089134c2 --- /dev/null +++ b/packages/ui/src/lib/components/ui/input/input.svelte @@ -0,0 +1,52 @@ + + +{#if type === 'file'} + +{:else} + +{/if} diff --git a/packages/ui/src/lib/components/ui/label/index.ts b/packages/ui/src/lib/components/ui/label/index.ts new file mode 100644 index 000000000..808d14152 --- /dev/null +++ b/packages/ui/src/lib/components/ui/label/index.ts @@ -0,0 +1,7 @@ +import Root from './label.svelte'; + +export { + Root, + // + Root as Label +}; diff --git a/packages/ui/src/lib/components/ui/label/label.svelte b/packages/ui/src/lib/components/ui/label/label.svelte new file mode 100644 index 000000000..7fef72e9d --- /dev/null +++ b/packages/ui/src/lib/components/ui/label/label.svelte @@ -0,0 +1,20 @@ + + + diff --git a/packages/ui/src/lib/components/ui/progress/index.ts b/packages/ui/src/lib/components/ui/progress/index.ts new file mode 100644 index 000000000..97f57fcda --- /dev/null +++ b/packages/ui/src/lib/components/ui/progress/index.ts @@ -0,0 +1,7 @@ +import Root from './progress.svelte'; + +export { + Root, + // + Root as Progress +}; diff --git a/packages/ui/src/lib/components/ui/progress/progress.svelte b/packages/ui/src/lib/components/ui/progress/progress.svelte new file mode 100644 index 000000000..2e4afd053 --- /dev/null +++ b/packages/ui/src/lib/components/ui/progress/progress.svelte @@ -0,0 +1,27 @@ + + + +
+
diff --git a/packages/ui/src/lib/components/ui/select/index.ts b/packages/ui/src/lib/components/ui/select/index.ts new file mode 100644 index 000000000..305c47ad7 --- /dev/null +++ b/packages/ui/src/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import Root from './select.svelte'; +import Group from './select-group.svelte'; +import Label from './select-label.svelte'; +import Item from './select-item.svelte'; +import Content from './select-content.svelte'; +import Trigger from './select-trigger.svelte'; +import Separator from './select-separator.svelte'; +import ScrollDownButton from './select-scroll-down-button.svelte'; +import ScrollUpButton from './select-scroll-up-button.svelte'; +import GroupHeading from './select-group-heading.svelte'; +import Portal from './select-portal.svelte'; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + Portal, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, + Portal as SelectPortal +}; diff --git a/packages/ui/src/lib/components/ui/select/select-content.svelte b/packages/ui/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 000000000..7c0bd44e0 --- /dev/null +++ b/packages/ui/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,45 @@ + + + + + + + {@render children?.()} + + + + diff --git a/packages/ui/src/lib/components/ui/select/select-group-heading.svelte b/packages/ui/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 000000000..3dc028250 --- /dev/null +++ b/packages/ui/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/packages/ui/src/lib/components/ui/select/select-group.svelte b/packages/ui/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 000000000..ed67413d6 --- /dev/null +++ b/packages/ui/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/ui/src/lib/components/ui/select/select-item.svelte b/packages/ui/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 000000000..e5b31244e --- /dev/null +++ b/packages/ui/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,38 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/packages/ui/src/lib/components/ui/select/select-label.svelte b/packages/ui/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 000000000..8849c2a31 --- /dev/null +++ b/packages/ui/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/packages/ui/src/lib/components/ui/select/select-portal.svelte b/packages/ui/src/lib/components/ui/select/select-portal.svelte new file mode 100644 index 000000000..59c4b78e7 --- /dev/null +++ b/packages/ui/src/lib/components/ui/select/select-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/packages/ui/src/lib/components/ui/select/select-scroll-down-button.svelte b/packages/ui/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 000000000..350214839 --- /dev/null +++ b/packages/ui/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/packages/ui/src/lib/components/ui/select/select-scroll-up-button.svelte b/packages/ui/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 000000000..3d35e04c3 --- /dev/null +++ b/packages/ui/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/packages/ui/src/lib/components/ui/select/select-separator.svelte b/packages/ui/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 000000000..bfd1eb781 --- /dev/null +++ b/packages/ui/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/packages/ui/src/lib/components/ui/select/select-trigger.svelte b/packages/ui/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 000000000..f7ac42166 --- /dev/null +++ b/packages/ui/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/packages/ui/src/lib/components/ui/select/select.svelte b/packages/ui/src/lib/components/ui/select/select.svelte new file mode 100644 index 000000000..b9bf9ba58 --- /dev/null +++ b/packages/ui/src/lib/components/ui/select/select.svelte @@ -0,0 +1,11 @@ + + + diff --git a/packages/ui/src/lib/components/ui/separator/index.ts b/packages/ui/src/lib/components/ui/separator/index.ts new file mode 100644 index 000000000..768efac96 --- /dev/null +++ b/packages/ui/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from './separator.svelte'; + +export { + Root, + // + Root as Separator +}; diff --git a/packages/ui/src/lib/components/ui/separator/separator.svelte b/packages/ui/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 000000000..382861231 --- /dev/null +++ b/packages/ui/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,21 @@ + + + diff --git a/packages/ui/src/lib/components/ui/skeleton/index.ts b/packages/ui/src/lib/components/ui/skeleton/index.ts new file mode 100644 index 000000000..3120ce123 --- /dev/null +++ b/packages/ui/src/lib/components/ui/skeleton/index.ts @@ -0,0 +1,7 @@ +import Root from './skeleton.svelte'; + +export { + Root, + // + Root as Skeleton +}; diff --git a/packages/ui/src/lib/components/ui/skeleton/skeleton.svelte b/packages/ui/src/lib/components/ui/skeleton/skeleton.svelte new file mode 100644 index 000000000..cdd10e003 --- /dev/null +++ b/packages/ui/src/lib/components/ui/skeleton/skeleton.svelte @@ -0,0 +1,17 @@ + + +
diff --git a/packages/ui/src/lib/components/ui/sonner/index.ts b/packages/ui/src/lib/components/ui/sonner/index.ts new file mode 100644 index 000000000..fcaf06bfb --- /dev/null +++ b/packages/ui/src/lib/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from './sonner.svelte'; diff --git a/packages/ui/src/lib/components/ui/sonner/sonner.svelte b/packages/ui/src/lib/components/ui/sonner/sonner.svelte new file mode 100644 index 000000000..cb1f7c19e --- /dev/null +++ b/packages/ui/src/lib/components/ui/sonner/sonner.svelte @@ -0,0 +1,13 @@ + + + diff --git a/packages/ui/src/lib/components/ui/textarea/index.ts b/packages/ui/src/lib/components/ui/textarea/index.ts new file mode 100644 index 000000000..9ccb3bff3 --- /dev/null +++ b/packages/ui/src/lib/components/ui/textarea/index.ts @@ -0,0 +1,7 @@ +import Root from './textarea.svelte'; + +export { + Root, + // + Root as Textarea +}; diff --git a/packages/ui/src/lib/components/ui/textarea/textarea.svelte b/packages/ui/src/lib/components/ui/textarea/textarea.svelte new file mode 100644 index 000000000..5adc8f69e --- /dev/null +++ b/packages/ui/src/lib/components/ui/textarea/textarea.svelte @@ -0,0 +1,23 @@ + + + diff --git a/packages/ui/src/lib/index.ts b/packages/ui/src/lib/index.ts new file mode 100644 index 000000000..02dfb61f3 --- /dev/null +++ b/packages/ui/src/lib/index.ts @@ -0,0 +1,23 @@ +// @metastate-foundation/ui — Shared UI Components +// Re-export utilities +export { + cn, + type WithoutChild, + type WithoutChildren, + type WithoutChildrenOrChild, + type WithElementRef +} from './utils.js'; + +// Re-export all component modules (used by profile-editor) +export * as Avatar from './components/ui/avatar/index.js'; +export * as Badge from './components/ui/badge/index.js'; +export * as Button from './components/ui/button/index.js'; +export * as Card from './components/ui/card/index.js'; +export * as Input from './components/ui/input/index.js'; +export * as Label from './components/ui/label/index.js'; +export * as Progress from './components/ui/progress/index.js'; +export * as Select from './components/ui/select/index.js'; +export * as Separator from './components/ui/separator/index.js'; +export * as Skeleton from './components/ui/skeleton/index.js'; +export * as Sonner from './components/ui/sonner/index.js'; +export * as Textarea from './components/ui/textarea/index.js'; diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts new file mode 100644 index 000000000..f92bfcbb3 --- /dev/null +++ b/packages/ui/src/lib/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/packages/ui/src/routes/+page.svelte b/packages/ui/src/routes/+page.svelte new file mode 100644 index 000000000..154f225a3 --- /dev/null +++ b/packages/ui/src/routes/+page.svelte @@ -0,0 +1 @@ + diff --git a/packages/ui/svelte.config.js b/packages/ui/svelte.config.js new file mode 100644 index 000000000..91f85a6b3 --- /dev/null +++ b/packages/ui/svelte.config.js @@ -0,0 +1,8 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: [vitePreprocess()] +}; + +export default config; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 000000000..19cb1544f --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "sourceMap": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/platforms/dreamsync/api/package.json b/platforms/dreamsync/api/package.json index 3dddc7520..4890aa07f 100644 --- a/platforms/dreamsync/api/package.json +++ b/platforms/dreamsync/api/package.json @@ -9,7 +9,7 @@ "build": "tsc", "test-matchmaking": "ts-node test-matchmaking-mock.ts", "typeorm": "typeorm-ts-node-commonjs", - "migration:generate": "typeorm-ts-node-commonjs migration:generate -d src/database/data-source.ts", + "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": "typeorm-ts-node-commonjs migration:run -d src/database/data-source.ts", "migration:revert": "typeorm-ts-node-commonjs migration:revert -d src/database/data-source.ts", "migrate-summaries": "ts-node --project tsconfig.json scripts/migrate-summaries.ts" diff --git a/platforms/dreamsync/api/src/controllers/ProfessionalProfileController.ts b/platforms/dreamsync/api/src/controllers/ProfessionalProfileController.ts new file mode 100644 index 000000000..315b84408 --- /dev/null +++ b/platforms/dreamsync/api/src/controllers/ProfessionalProfileController.ts @@ -0,0 +1,95 @@ +import type { Request, Response } from "express"; +import { RegistryService } from "../services/RegistryService"; +import { EVaultProfileService } from "../services/EVaultProfileService"; + +const registryService = new RegistryService(); +const evaultService = new EVaultProfileService(registryService); + +/** + * Extract HTTP status from upstream errors (eVault, Registry, graphql-request). + * Returns 404 when the upstream indicates not found, otherwise 500. + */ +function getHttpStatusFromError(error: unknown): number { + if (!error || typeof error !== "object") return 500; + const e = error as Record; + const status = + (e.response as { status?: number })?.status ?? + (e.status as number) ?? + (e.statusCode as number); + if (typeof status === "number" && status >= 400 && status < 600) return status; + const msg = String(e.message ?? "").toLowerCase(); + if (msg.includes("404") || msg.includes("not found")) return 404; + return 500; +} + +export class ProfessionalProfileController { + async getProfile(req: Request, res: Response) { + try { + const ename = req.user?.ename; + if (!ename) { + return res.status(400).json({ error: "User ename not available" }); + } + + const w3id = ename.startsWith("@") ? ename : `@${ename}`; + console.log( + "[ProfessionalProfile] GET profile for ename:", + ename, + "-> w3id:", + w3id, + ); + + const profile = await evaultService.getProfile(ename); + res.json(profile.professional); + } catch (error: any) { + const status = getHttpStatusFromError(error); + const message = + status === 404 + ? "Professional profile not found" + : "Failed to fetch professional profile"; + console.error( + "[ProfessionalProfile] Error fetching profile:", + error.message, + "full:", + JSON.stringify(error?.response?.data ?? error), + ); + res.status(status).json({ error: message }); + } + } + + async updateProfile(req: Request, res: Response) { + try { + const ename = req.user?.ename; + if (!ename) { + return res.status(400).json({ error: "User ename not available" }); + } + + const allowedFields = [ + "headline", + "bio", + "location", + "skills", + "workExperience", + "education", + "isPublic", + ]; + + const updateData: Record = {}; + for (const field of allowedFields) { + if (req.body[field] !== undefined) { + updateData[field] = req.body[field]; + } + } + + const profile = await evaultService.upsertProfile(ename, updateData); + res.json(profile.professional); + } catch (error: any) { + const status = getHttpStatusFromError(error); + const message = + status === 404 + ? "Professional profile not found" + : "Failed to update professional profile"; + console.error("Error updating professional profile:", error.message); + res.status(status).json({ error: message }); + } + } +} diff --git a/platforms/dreamsync/api/src/controllers/WebhookController.ts b/platforms/dreamsync/api/src/controllers/WebhookController.ts index 694b97bcf..8cccd1ba8 100644 --- a/platforms/dreamsync/api/src/controllers/WebhookController.ts +++ b/platforms/dreamsync/api/src/controllers/WebhookController.ts @@ -4,6 +4,7 @@ import { GroupService } from "../services/GroupService"; import { MessageService } from "../services/MessageService"; import { ConsentService } from "../services/ConsentService"; import { WebhookProcessingService } from "../services/WebhookProcessingService"; +import { ProfessionalProfileService } from "../services/ProfessionalProfileService"; import { adapter } from "../web3adapter/watchers/subscriber"; import { User } from "../database/entities/User"; import { Group } from "../database/entities/Group"; @@ -16,6 +17,7 @@ export class WebhookController { messageService: MessageService; consentService: ConsentService; webhookProcessingService: WebhookProcessingService; + professionalProfileService: ProfessionalProfileService; adapter: typeof adapter; constructor() { @@ -24,6 +26,7 @@ export class WebhookController { this.messageService = new MessageService(); this.consentService = new ConsentService(); this.webhookProcessingService = new WebhookProcessingService(); + this.professionalProfileService = new ProfessionalProfileService(); this.adapter = adapter; } @@ -351,6 +354,17 @@ export class WebhookController { } } } + } else if (mapping.tableName === "professional_profiles") { + // Maintain local copy for matching (avoids per-user evault calls during matching) + const ename = req.body.w3id; + const data = req.body.data ?? {}; + const prof = await this.professionalProfileService.upsertFromWebhook(ename, data); + if (prof) { + console.log("Professional profile webhook - upserted for ename:", prof.ename); + finalLocalId = prof.id; + } else { + finalLocalId = null; + } } // Mark webhook as completed diff --git a/platforms/dreamsync/api/src/database/data-source.ts b/platforms/dreamsync/api/src/database/data-source.ts index be20862f4..982fc1740 100644 --- a/platforms/dreamsync/api/src/database/data-source.ts +++ b/platforms/dreamsync/api/src/database/data-source.ts @@ -9,6 +9,7 @@ import { Wishlist } from "./entities/Wishlist"; import { Match } from "./entities/Match"; import { UserEVaultMapping } from "./entities/UserEVaultMapping"; import { WebhookProcessing } from "./entities/WebhookProcessing"; +import { ProfessionalProfile } from "./entities/ProfessionalProfile"; import { PostgresSubscriber } from "../web3adapter/watchers/subscriber"; // Use absolute path for better CLI compatibility @@ -20,7 +21,7 @@ export const dataSourceOptions: DataSourceOptions = { type: "postgres", url: process.env.DREAMSYNC_DATABASE_URL, synchronize: false, // Auto-sync in development - entities: [User, Group, Message, Wishlist, Match, UserEVaultMapping, WebhookProcessing], + entities: [User, Group, Message, Wishlist, Match, UserEVaultMapping, WebhookProcessing, ProfessionalProfile], migrations: [path.join(__dirname, "migrations", "*.ts")], logging: process.env.NODE_ENV === "development", subscribers: [PostgresSubscriber], diff --git a/platforms/dreamsync/api/src/database/entities/ProfessionalProfile.ts b/platforms/dreamsync/api/src/database/entities/ProfessionalProfile.ts new file mode 100644 index 000000000..7f53d9ad1 --- /dev/null +++ b/platforms/dreamsync/api/src/database/entities/ProfessionalProfile.ts @@ -0,0 +1,61 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from "typeorm"; + +@Entity("professional_profiles") +export class ProfessionalProfile { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ unique: true }) + ename!: string; + + @Column({ nullable: true }) + displayName!: string; + + @Column({ nullable: true }) + headline!: string; + + @Column({ type: "text", nullable: true }) + bio!: string; + + @Column({ nullable: true }) + avatarFileId!: string; + + @Column({ nullable: true }) + bannerFileId!: string; + + @Column({ nullable: true }) + cvFileId!: string; + + @Column({ nullable: true }) + videoIntroFileId!: string; + + @Column({ nullable: true }) + location!: string; + + @Column("text", { array: true, nullable: true }) + skills!: string[]; + + @Column("jsonb", { nullable: true }) + workExperience!: object[]; + + @Column("jsonb", { nullable: true }) + education!: object[]; + + @Column({ default: true }) + isPublic!: boolean; + + @Column("jsonb", { nullable: true }) + socialLinks!: object[]; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/platforms/dreamsync/api/src/database/migrations/1773665365495-ProfessionalProfiles.ts b/platforms/dreamsync/api/src/database/migrations/1773665365495-ProfessionalProfiles.ts new file mode 100644 index 000000000..500b42004 --- /dev/null +++ b/platforms/dreamsync/api/src/database/migrations/1773665365495-ProfessionalProfiles.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ProfessionalProfiles1773665365495 implements MigrationInterface { + name = 'ProfessionalProfiles1773665365495' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "professional_profiles" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "ename" character varying NOT NULL, "displayName" character varying, "headline" character varying, "bio" text, "avatarFileId" character varying, "bannerFileId" character varying, "cvFileId" character varying, "videoIntroFileId" character varying, "location" character varying, "skills" text array, "workExperience" jsonb, "education" jsonb, "isPublic" boolean NOT NULL DEFAULT true, "socialLinks" jsonb, "createdAt" TIMESTAMP NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_bfb533ecd1e10f08359f04cf0e2" UNIQUE ("ename"), CONSTRAINT "PK_b2140d2f56b0910e4c58ab4d2a2" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "professional_profiles"`); + } + +} diff --git a/platforms/dreamsync/api/src/index.ts b/platforms/dreamsync/api/src/index.ts index 10a57ca0d..c8acaa73a 100644 --- a/platforms/dreamsync/api/src/index.ts +++ b/platforms/dreamsync/api/src/index.ts @@ -9,6 +9,7 @@ import { AuthController } from "./controllers/AuthController"; import { WebhookController } from "./controllers/WebhookController"; import { WishlistController } from "./controllers/WishlistController"; import { MatchController } from "./controllers/MatchController"; +import { ProfessionalProfileController } from "./controllers/ProfessionalProfileController"; import { authMiddleware, authGuard } from "./middleware/auth"; import { adapter } from "./web3adapter/watchers/subscriber"; import { MatchingJob } from "./services/MatchingJob"; @@ -85,6 +86,7 @@ const authController = new AuthController(); const webhookController = new WebhookController(); const wishlistController = new WishlistController(); const matchController = new MatchController(); +const professionalProfileController = new ProfessionalProfileController(); // Health check endpoint app.get("/api/health", (req, res) => { @@ -123,6 +125,10 @@ app.get("/api/wishlists/:id", authGuard, wishlistController.getWishlistById); app.put("/api/wishlists/:id", authGuard, wishlistController.updateWishlist); app.delete("/api/wishlists/:id", authGuard, wishlistController.deleteWishlist); +// Professional profile routes +app.get("/api/professional-profile", authGuard, professionalProfileController.getProfile); +app.patch("/api/professional-profile", authGuard, professionalProfileController.updateProfile); + // Match routes app.get("/api/matches", authGuard, matchController.getUserMatches); app.patch("/api/matches/:id", authGuard, matchController.updateMatchStatus); diff --git a/platforms/dreamsync/api/src/services/AIMatchingService.ts b/platforms/dreamsync/api/src/services/AIMatchingService.ts index b653c9e34..8237a634f 100644 --- a/platforms/dreamsync/api/src/services/AIMatchingService.ts +++ b/platforms/dreamsync/api/src/services/AIMatchingService.ts @@ -8,6 +8,8 @@ import { MatchingService, MatchResult, WishlistData, GroupData } from "./Matchin import { withOperationContext } from "../context/OperationContext"; import OpenAI from "openai"; import { WishlistSummaryService } from "./WishlistSummaryService"; +import { ProfessionalProfileService } from "./ProfessionalProfileService"; +import type { ProfessionalProfile } from "../types/profile"; export class AIMatchingService { private matchingService: MatchingService; @@ -17,6 +19,7 @@ export class AIMatchingService { private notificationService: MatchNotificationService; private openai: OpenAI; private wishlistSummaryService: WishlistSummaryService; + private professionalProfileService: ProfessionalProfileService; constructor() { this.matchingService = new MatchingService(); @@ -28,6 +31,7 @@ export class AIMatchingService { apiKey: process.env.OPENAI_API_KEY, }); this.wishlistSummaryService = WishlistSummaryService.getInstance(); + this.professionalProfileService = new ProfessionalProfileService(); } async findMatches(): Promise { @@ -51,25 +55,45 @@ export class AIMatchingService { const existingGroups = await this.getExistingGroups(); console.log(`🏠 Found ${existingGroups.length} existing groups to consider`); + // Load professional profiles from local DB (populated via webhooks) + const uniqueEnames = [...new Set(wishlists.map((w) => w.user.ename).filter(Boolean))]; + const profileCache = await this.professionalProfileService.getByEnames(uniqueEnames); + // Convert to shared service format, filtering out wishlists without summaries const wishlistData: WishlistData[] = wishlists .filter(wishlist => { - // Only include wishlists with valid summary arrays return wishlist.summaryWants && wishlist.summaryWants.length > 0 && wishlist.summaryOffers && wishlist.summaryOffers.length > 0; }) - .map(wishlist => ({ - id: wishlist.id, - content: wishlist.content, - summaryWants: wishlist.summaryWants || [], - summaryOffers: wishlist.summaryOffers || [], - userId: wishlist.userId, - user: { - id: wishlist.user.id, - name: wishlist.user.name || wishlist.user.ename, - ename: wishlist.user.ename - } - })); + .map(wishlist => { + const prof = profileCache.get(wishlist.user.ename) ?? undefined; + return { + id: wishlist.id, + content: wishlist.content, + summaryWants: wishlist.summaryWants || [], + summaryOffers: wishlist.summaryOffers || [], + userId: wishlist.userId, + user: { + id: wishlist.user.id, + name: wishlist.user.name || wishlist.user.ename, + ename: wishlist.user.ename, + professional: prof ? { + headline: prof.headline, + skills: prof.skills, + location: prof.location, + workExperience: prof.workExperience?.map((w) => ({ + company: w.company, + role: w.role, + })), + education: prof.education?.map((e) => ({ + institution: e.institution, + degree: e.degree, + fieldOfStudy: e.fieldOfStudy, + })), + } : undefined, + }, + }; + }); // Use matching service for parallel processing const matchResults = await this.matchingService.findMatches(wishlistData, existingGroups); diff --git a/platforms/dreamsync/api/src/services/EVaultProfileService.ts b/platforms/dreamsync/api/src/services/EVaultProfileService.ts new file mode 100644 index 000000000..1e990fb77 --- /dev/null +++ b/platforms/dreamsync/api/src/services/EVaultProfileService.ts @@ -0,0 +1,229 @@ +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"; +/** User/UserProfile ontology UUID (from user.json schema) - eVault stores this, not "User" */ +const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; + +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 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 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 class EVaultProfileService { + private registryService: RegistryService; + + constructor(registryService: RegistryService) { + this.registryService = registryService; + } + + private async getClient(eName: string): Promise { + const w3id = this.registryService.normalizeW3id(eName); + const endpoint = await this.registryService.getEvaultGraphqlUrl(eName); + const token = await this.registryService.ensurePlatformToken(); + return new GraphQLClient(endpoint, { + headers: { + Authorization: `Bearer ${token}`, + "X-ENAME": w3id, + }, + }); + } + + private async findMetaEnvelopeByOntology( + client: GraphQLClient, + ontologyId: string, + ): Promise { + const result = await client.request( + META_ENVELOPES_QUERY, + { + filter: { ontologyId }, + first: 1, + }, + ); + const edge = result.metaEnvelopes.edges[0]; + return edge?.node ?? null; + } + + async getProfessionalProfile(eName: string): Promise { + const client = await this.getClient(eName); + const node = await this.findMetaEnvelopeByOntology( + client, + PROFESSIONAL_PROFILE_ONTOLOGY, + ); + if (!node) { + return {}; + } + return node.parsed as ProfessionalProfile; + } + + async getProfile(eName: string): Promise { + 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; + + const name = + profData.displayName ?? userData.displayName ?? eName; + + return { + ename: eName, + name, + handle: userData.username, + isVerified: userData.isVerified, + professional: { + displayName: profData.displayName, + headline: profData.headline, + bio: profData.bio, + avatarFileId: profData.avatarFileId, + bannerFileId: profData.bannerFileId, + cvFileId: profData.cvFileId, + videoIntroFileId: profData.videoIntroFileId, + email: profData.email, + phone: profData.phone, + website: profData.website, + location: profData.location, + isPublic: profData.isPublic ?? true, + workExperience: profData.workExperience ?? [], + education: profData.education ?? [], + skills: profData.skills ?? [], + socialLinks: profData.socialLinks ?? [], + }, + }; + } + + async upsertProfile( + eName: string, + data: Partial, + ): Promise { + const client = await this.getClient(eName); + + const existing = await this.findMetaEnvelopeByOntology( + client, + PROFESSIONAL_PROFILE_ONTOLOGY, + ); + + const merged: ProfessionalProfile = { + ...(existing?.parsed as ProfessionalProfile | undefined), + ...data, + }; + + const normalizedEname = this.registryService.normalizeW3id(eName); + const acl = merged.isPublic !== false ? ["*"] : [normalizedEname]; + + if (existing) { + const result = await client.request(UPDATE_MUTATION, { + id: existing.id, + input: { + ontology: PROFESSIONAL_PROFILE_ONTOLOGY, + payload: merged, + 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: merged, + acl, + }, + }); + + if (result.createMetaEnvelope.errors?.length) { + throw new Error( + result.createMetaEnvelope.errors + .map((e) => e.message) + .join("; "), + ); + } + } + + return this.getProfile(eName); + } +} diff --git a/platforms/dreamsync/api/src/services/MatchingService.ts b/platforms/dreamsync/api/src/services/MatchingService.ts index 487018883..0096909a5 100644 --- a/platforms/dreamsync/api/src/services/MatchingService.ts +++ b/platforms/dreamsync/api/src/services/MatchingService.ts @@ -22,6 +22,17 @@ export interface WishlistData { id: string; name: string; ename: string; + professional?: { + headline?: string; + skills?: string[]; + location?: string; + workExperience?: { company: string; role: string }[]; + education?: { + institution: string; + degree: string; + fieldOfStudy?: string; + }[]; + }; }; } @@ -80,19 +91,45 @@ export class MatchingService { } } + private formatProfessionalInfo(professional?: WishlistData["user"]["professional"]): string { + if (!professional) return ""; + const parts: string[] = []; + if (professional.headline) parts.push(professional.headline); + if (professional.workExperience?.length) { + const roles = professional.workExperience + .slice(0, 3) + .map((w) => `${w.role} at ${w.company}`) + .join(", "); + parts.push(roles); + } + if (professional.skills?.length) { + parts.push(`skills: ${professional.skills.join(", ")}`); + } + if (professional.location) parts.push(professional.location); + if (professional.education?.length) { + const edu = professional.education + .slice(0, 2) + .map((e) => `${e.degree}${e.fieldOfStudy ? " " + e.fieldOfStudy : ""} (${e.institution})`) + .join(", "); + parts.push(edu); + } + return parts.join("; "); + } + private buildAllMatchesPrompt(wishlists: WishlistData[], existingGroups?: GroupData[]): string { const delimiter = "<|>"; - const wishlistHeader = `userId${delimiter}userEname${delimiter}userName${delimiter}wants${delimiter}offers`; + const wishlistHeader = `userId${delimiter}userEname${delimiter}userName${delimiter}wants${delimiter}offers${delimiter}professionalInfo`; const wishlistRows = wishlists.map((wishlist) => { - // Join array items with semicolons for CSV format const wants = (wishlist.summaryWants || []).join('; '); const offers = (wishlist.summaryOffers || []).join('; '); + const profInfo = this.formatProfessionalInfo(wishlist.user.professional); return [ this.sanitizeField(wishlist.userId), this.sanitizeField(wishlist.user.ename), this.sanitizeField(wishlist.user.name || wishlist.user.ename), this.sanitizeField(wants), this.sanitizeField(offers), + this.sanitizeField(profInfo), ].join(delimiter); }).join("\n"); @@ -121,12 +158,13 @@ Use the exact groupId from the table above in the format: "JOIN_EXISTING_GROUP:< } return ` -You are an AI matching assistant. Your task is to find meaningful connections between users based on their wishlists. +You are an AI matching assistant. Your task is to find meaningful connections between users based on their wishlists and professional backgrounds. The wishlists are provided as delimiter-separated rows (delimiter: "${delimiter}"). -Columns: userId${delimiter}userEname${delimiter}userName${delimiter}wants${delimiter}offers +Columns: userId${delimiter}userEname${delimiter}userName${delimiter}wants${delimiter}offers${delimiter}professionalInfo The "wants" and "offers" columns contain semicolon-separated arrays of short phrases extracted from each user's wishlist. +The "professionalInfo" column contains a condensed summary of the user's professional background (headline, roles, skills, location, education). It may be empty if the user has not shared their professional profile. ${wishlistHeader} ${wishlistRows} @@ -139,6 +177,10 @@ CRITICAL INSTRUCTIONS: - Look for complementary needs: when User A wants something that User B offers, or vice versa - Look for shared interests: when multiple users want or offer similar things - Look for skill exchanges: when one can teach what another wants to learn +- Consider professional background when matching: if a user's wants mention needing + a professional (e.g., "need a lawyer", "looking for a designer"), check the + professionalInfo column to find users whose skills, roles, or education match +- Professional info is supplementary context; wishlists remain the primary matching signal - You should actively search for connections - do NOT return an empty array unless there are truly ZERO possible connections - All wishlists provided have valid content - analyze them thoroughly diff --git a/platforms/dreamsync/api/src/services/ProfessionalProfileService.ts b/platforms/dreamsync/api/src/services/ProfessionalProfileService.ts new file mode 100644 index 000000000..8be7d7ce7 --- /dev/null +++ b/platforms/dreamsync/api/src/services/ProfessionalProfileService.ts @@ -0,0 +1,97 @@ +import { In, Repository } from "typeorm"; +import { AppDataSource } from "../database/data-source"; +import { ProfessionalProfile as ProfessionalProfileEntity } from "../database/entities/ProfessionalProfile"; +import type { ProfessionalProfile } from "../types/profile"; + +function normalizeEname(w3id: string | undefined): string | null { + if (!w3id || typeof w3id !== "string") return null; + return w3id.startsWith("@") ? w3id.slice(1) : w3id; +} + +export class ProfessionalProfileService { + private repository: Repository; + + constructor() { + this.repository = AppDataSource.getRepository(ProfessionalProfileEntity); + } + + /** + * Upsert professional profile from webhook payload. + * Data should have universal/evault field names (displayName, headline, etc.) + */ + async upsertFromWebhook(w3id: string | undefined, data: Record): Promise { + const ename = normalizeEname(w3id); + if (!ename) return null; + + let existing = await this.repository.findOne({ where: { ename } }); + + const payload: Partial = { + displayName: (data.displayName ?? data.name) as string | undefined, + headline: data.headline as string | undefined, + bio: data.bio as string | undefined, + avatarFileId: data.avatarFileId as string | undefined, + bannerFileId: data.bannerFileId as string | undefined, + cvFileId: data.cvFileId as string | undefined, + videoIntroFileId: data.videoIntroFileId as string | undefined, + location: data.location as string | undefined, + skills: Array.isArray(data.skills) ? (data.skills as string[]) : undefined, + workExperience: Array.isArray(data.workExperience) ? (data.workExperience as object[]) : undefined, + education: Array.isArray(data.education) ? (data.education as object[]) : undefined, + socialLinks: Array.isArray(data.socialLinks) ? (data.socialLinks as object[]) : undefined, + isPublic: data.isPublic === true, + }; + + if (existing) { + Object.assign(existing, payload); + return this.repository.save(existing); + } + + const created = this.repository.create({ + ename, + ...payload, + } as Partial); + return this.repository.save(created); + } + + /** + * Load professional profiles for multiple enames. Returns a Map for quick lookup. + */ + async getByEnames(enames: string[]): Promise> { + const normalized = enames.map((e) => (e.startsWith("@") ? e.slice(1) : e)); + const unique = [...new Set(normalized)].filter(Boolean); + if (unique.length === 0) return new Map(); + + const rows = await this.repository.find({ + where: { ename: In(unique) }, + }); + + const map = new Map(); + for (const row of rows) { + if (row.isPublic) { + const profile = this.entityToProfile(row); + map.set(row.ename, profile); + map.set(`@${row.ename}`, profile); // support lookup with or without @ prefix + } + } + return map; + } + + private entityToProfile(entity: ProfessionalProfileEntity): ProfessionalProfile { + const workExp = entity.workExperience as ProfessionalProfile["workExperience"]; + const edu = entity.education as ProfessionalProfile["education"]; + return { + displayName: entity.displayName ?? undefined, + headline: entity.headline ?? undefined, + bio: entity.bio ?? undefined, + avatarFileId: entity.avatarFileId ?? undefined, + bannerFileId: entity.bannerFileId ?? undefined, + cvFileId: entity.cvFileId ?? undefined, + videoIntroFileId: entity.videoIntroFileId ?? undefined, + location: entity.location ?? undefined, + skills: entity.skills ?? undefined, + workExperience: workExp ?? undefined, + education: edu ?? undefined, + isPublic: entity.isPublic, + } as ProfessionalProfile; + } +} diff --git a/platforms/dreamsync/api/src/services/RegistryService.ts b/platforms/dreamsync/api/src/services/RegistryService.ts new file mode 100644 index 000000000..6344f7d47 --- /dev/null +++ b/platforms/dreamsync/api/src/services/RegistryService.ts @@ -0,0 +1,70 @@ +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; + + 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.VITE_DREAMSYNC_BASE_URL; + if (!url) + throw new Error("VITE_DREAMSYNC_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; + } + + /** + * Ensure eName is in W3ID format (@uuid) for Registry/eVault. + * DreamSync stores ename without @; Registry expects @-prefixed w3id. + */ + normalizeW3id(eName: string): string { + if (!eName) return eName; + return eName.startsWith("@") ? eName : `@${eName}`; + } + + async resolveEName(eName: string): Promise<{ uri: string; evault: string }> { + const w3id = this.normalizeW3id(eName); + const response = await axios.get( + `${this.registryUrl}/resolve`, + { params: { w3id } }, + ); + return { uri: response.data.uri, evault: response.data.evault }; + } + + async getEvaultGraphqlUrl(eName: string): Promise { + const { uri } = await this.resolveEName(eName); + const base = uri.replace(/\/$/, ""); + return `${base}/graphql`; + } +} diff --git a/platforms/dreamsync/api/src/types/profile.ts b/platforms/dreamsync/api/src/types/profile.ts new file mode 100644 index 000000000..d279ec4b7 --- /dev/null +++ b/platforms/dreamsync/api/src/types/profile.ts @@ -0,0 +1,68 @@ +export interface WorkExperience { + 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; +} + +export interface SocialLink { + id?: string; + platform: string; + url: string; + label?: string; +} + +export interface ProfessionalProfile { + displayName?: string; + headline?: string; + bio?: string; + avatarFileId?: string; + bannerFileId?: 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; + avatarUrl?: string; + bannerUrl?: string; + ename?: string; + isVerified?: boolean; + isPrivate?: boolean; + location?: string; + website?: string; +} + +export interface FullProfile { + ename: string; + name?: string; + handle?: string; + isVerified?: boolean; + professional: ProfessionalProfile; +} diff --git a/platforms/dreamsync/api/src/utils/jwt.ts b/platforms/dreamsync/api/src/utils/jwt.ts index b38d5a336..0fddfb81a 100644 --- a/platforms/dreamsync/api/src/utils/jwt.ts +++ b/platforms/dreamsync/api/src/utils/jwt.ts @@ -2,7 +2,7 @@ import jwt from "jsonwebtoken"; const JWT_SECRET = process.env.DREAMSYNC_JWT_SECRET; -if (!JWT_SECRET) throw new Error("InternalError: Missing JWT Secret") +if (!JWT_SECRET) throw new Error("InternalError: Missing DREAMSYNC_JWT_SECRET") export const signToken = (payload: { userId: string }): string => { return jwt.sign(payload, JWT_SECRET, { expiresIn: "7d" }); diff --git a/platforms/dreamsync/api/src/web3adapter/mappings/professional-profile.mapping.json b/platforms/dreamsync/api/src/web3adapter/mappings/professional-profile.mapping.json new file mode 100644 index 000000000..415c03fcd --- /dev/null +++ b/platforms/dreamsync/api/src/web3adapter/mappings/professional-profile.mapping.json @@ -0,0 +1,18 @@ +{ + "tableName": "professional_profiles", + "schemaId": "550e8400-e29b-41d4-a716-446655440009", + "ownerEnamePath": "ename", + "ownedJunctionTables": [], + "localToUniversalMap": { + "name": "displayName", + "headline": "headline", + "bio": "bio", + "avatarFileId": "avatarFileId", + "bannerFileId": "bannerFileId", + "cvFileId": "cvFileId", + "videoIntroFileId": "videoIntroFileId", + "location": "location", + "skills": "skills", + "isPublic": "isPublic" + } +} diff --git a/platforms/dreamsync/client/client/src/components/auth/login-screen.tsx b/platforms/dreamsync/client/client/src/components/auth/login-screen.tsx index dfc1b0cb1..f72edddd3 100644 --- a/platforms/dreamsync/client/client/src/components/auth/login-screen.tsx +++ b/platforms/dreamsync/client/client/src/components/auth/login-screen.tsx @@ -56,7 +56,7 @@ export function LoginScreen() { const handleAutoLogin = async (ename: string, session: string, signature: string, appVersion: string) => { setIsConnecting(true); try { - const apiBaseUrl = import.meta.env.VITE_DREAMSYNC_BASE_URL || "http://localhost:8888"; + const apiBaseUrl = import.meta.env.VITE_DREAMSYNC_BASE_URL || "http://localhost:4001"; const response = await fetch(`${apiBaseUrl}/api/auth`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -90,7 +90,7 @@ export function LoginScreen() { if (!sessionId) return; const eventSource = new EventSource( - `${import.meta.env.VITE_DREAMSYNC_BASE_URL || "http://localhost:8888"}/api/auth/sessions/${sessionId}` + `${import.meta.env.VITE_DREAMSYNC_BASE_URL || "http://localhost:4001"}/api/auth/sessions/${sessionId}` ); eventSource.onmessage = (event) => { diff --git a/platforms/dreamsync/client/client/src/components/professional-profile-editor.tsx b/platforms/dreamsync/client/client/src/components/professional-profile-editor.tsx new file mode 100644 index 000000000..fa6146a86 --- /dev/null +++ b/platforms/dreamsync/client/client/src/components/professional-profile-editor.tsx @@ -0,0 +1,554 @@ +import React, { useState, useEffect, useCallback } from "react"; +import { Briefcase, Plus, X, Loader2 } from "lucide-react"; +import { + Accordion, + AccordionItem, + AccordionTrigger, + AccordionContent, +} from "@/components/ui/accordion"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; +import { Card, CardContent } from "@/components/ui/card"; +import { + fetchProfessionalProfile, + updateProfessionalProfile, +} from "@/lib/profileApi"; +import type { + ProfessionalProfile, + WorkExperience, + Education, +} from "@/types/profile"; + +interface ProfessionalProfileEditorProps { + onSaveStatus?: (saving: boolean) => void; +} + +export default function ProfessionalProfileEditor({ + onSaveStatus, +}: ProfessionalProfileEditorProps) { + const [profile, setProfile] = useState({}); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [skillInput, setSkillInput] = useState(""); + const [hasChanges, setHasChanges] = useState(false); + + useEffect(() => { + fetchProfessionalProfile() + .then((data) => { + setProfile(data); + }) + .catch((err) => { + console.error("Failed to load professional profile:", err); + }) + .finally(() => setIsLoading(false)); + }, []); + + const updateField = useCallback( + ( + field: K, + value: ProfessionalProfile[K], + ) => { + setProfile((prev) => ({ ...prev, [field]: value })); + setHasChanges(true); + }, + [], + ); + + const handleSave = async () => { + setIsSaving(true); + onSaveStatus?.(true); + try { + const updated = await updateProfessionalProfile({ + headline: profile.headline, + bio: profile.bio, + location: profile.location, + skills: profile.skills, + workExperience: profile.workExperience, + education: profile.education, + isPublic: profile.isPublic, + }); + setProfile(updated); + setHasChanges(false); + } catch (err) { + console.error("Failed to save professional profile:", err); + } finally { + setIsSaving(false); + onSaveStatus?.(false); + } + }; + + const addSkill = () => { + const trimmed = skillInput.trim(); + if (!trimmed) return; + if (profile.skills?.includes(trimmed)) return; + updateField("skills", [...(profile.skills ?? []), trimmed]); + setSkillInput(""); + }; + + const removeSkill = (skill: string) => { + updateField( + "skills", + (profile.skills ?? []).filter((s) => s !== skill), + ); + }; + + const addWorkExperience = () => { + const entry: WorkExperience = { + company: "", + role: "", + startDate: "", + sortOrder: (profile.workExperience?.length ?? 0) + 1, + }; + updateField("workExperience", [ + ...(profile.workExperience ?? []), + entry, + ]); + }; + + const updateWorkExperience = ( + idx: number, + field: keyof WorkExperience, + value: string, + ) => { + const list = [...(profile.workExperience ?? [])]; + (list[idx] as any)[field] = value; + updateField("workExperience", list); + }; + + const removeWorkExperience = (idx: number) => { + updateField( + "workExperience", + (profile.workExperience ?? []).filter((_, i) => i !== idx), + ); + }; + + const addEducation = () => { + const entry: Education = { + institution: "", + degree: "", + startDate: "", + sortOrder: (profile.education?.length ?? 0) + 1, + }; + updateField("education", [...(profile.education ?? []), entry]); + }; + + const updateEducation = ( + idx: number, + field: keyof Education, + value: string, + ) => { + const list = [...(profile.education ?? [])]; + (list[idx] as any)[field] = value; + updateField("education", list); + }; + + const removeEducation = (idx: number) => { + updateField( + "education", + (profile.education ?? []).filter((_, i) => i !== idx), + ); + }; + + if (isLoading) { + return ( +
+ + + Loading professional profile… + +
+ ); + } + + return ( + + + +
+ + Professional Profile +
+
+ +
+ {/* Public visibility - controls both discovery and DreamSync matching */} +
+
+ +

+ When enabled, your profile is visible in discovery + and your professional background is considered + during DreamSync AI matching +

+
+ + updateField("isPublic", checked) + } + /> +
+ + {/* Headline */} +
+ + + updateField("headline", e.target.value) + } + /> +
+ + {/* Bio */} +
+ +