diff --git a/infrastructure/evault-core/src/core/db/db.service.ts b/infrastructure/evault-core/src/core/db/db.service.ts index 88285921a..28536dfce 100644 --- a/infrastructure/evault-core/src/core/db/db.service.ts +++ b/infrastructure/evault-core/src/core/db/db.service.ts @@ -565,6 +565,36 @@ export class DbService { { id, ontology: meta.ontology, acl, eName }, ); + // Deduplicate envelopes — if multiple Envelope nodes share the + // same ontology (field name), keep the first and delete the rest. + // This prevents non-deterministic reads where collect(e) returns + // duplicates in undefined order and reduce picks the wrong one. + const seen = new Map(); // ontology → kept envelope id + const dupsToDelete: string[] = []; + for (const env of existing.envelopes) { + if (seen.has(env.ontology)) { + dupsToDelete.push(env.id); + } else { + seen.set(env.ontology, env.id); + } + } + if (dupsToDelete.length > 0) { + console.warn( + `[eVault] Cleaning ${dupsToDelete.length} duplicate envelope(s) for MetaEnvelope ${id}`, + ); + for (const dupId of dupsToDelete) { + await this.runQueryInternal( + `MATCH (e:Envelope { id: $envelopeId }) DETACH DELETE e`, + { envelopeId: dupId }, + ); + } + // Remove deleted dupes from the existing list so the update + // loop below doesn't try to reference them. + existing.envelopes = existing.envelopes.filter( + (e) => !dupsToDelete.includes(e.id), + ); + } + const createdEnvelopes: Envelope[] = []; let counter = 0; @@ -601,21 +631,18 @@ export class DbService { valueType, }); } else { - // Create new envelope + // Create new envelope — use MERGE on the relationship + // + ontology to prevent duplicate Envelopes if two + // concurrent updates race. const envW3id = await new W3IDBuilder().build(); const envelopeId = envW3id.id; await this.runQueryInternal( ` MATCH (m:MetaEnvelope { id: $metaId, eName: $eName }) - CREATE (${alias}:Envelope { - id: $${alias}_id, - ontology: $${alias}_ontology, - value: $${alias}_value, - valueType: $${alias}_type - }) - WITH m, ${alias} - MERGE (m)-[:LINKS_TO]->(${alias}) + MERGE (m)-[:LINKS_TO]->(${alias}:Envelope { ontology: $${alias}_ontology }) + ON CREATE SET ${alias}.id = $${alias}_id, ${alias}.value = $${alias}_value, ${alias}.valueType = $${alias}_type + ON MATCH SET ${alias}.value = $${alias}_value, ${alias}.valueType = $${alias}_type `, { metaId: id, diff --git a/infrastructure/evault-core/src/core/protocol/graphql-server.ts b/infrastructure/evault-core/src/core/protocol/graphql-server.ts index 32c31b9c2..0ecfe4827 100644 --- a/infrastructure/evault-core/src/core/protocol/graphql-server.ts +++ b/infrastructure/evault-core/src/core/protocol/graphql-server.ts @@ -374,12 +374,19 @@ export class GraphQLServer { context.eName, ); - // Build the full metaEnvelope response + // Build parsed from actual written envelopes, not input + const parsedFromEnvelopes = result.envelopes.reduce( + (acc: Record, env: any) => { + acc[env.ontology] = env.value; + return acc; + }, + {}, + ); const metaEnvelope = { id: result.metaEnvelope.id, ontology: result.metaEnvelope.ontology, envelopes: result.envelopes, - parsed: input.payload, + parsed: parsedFromEnvelopes, }; // Deliver webhooks for create operation @@ -508,12 +515,19 @@ export class GraphQLServer { context.eName, ); - // Build the full metaEnvelope response + // Build parsed from actual written envelopes, not input + const parsedFromEnvelopes = result.envelopes.reduce( + (acc: Record, env: any) => { + acc[env.ontology] = env.value; + return acc; + }, + {}, + ); const metaEnvelope = { id: result.metaEnvelope.id, ontology: result.metaEnvelope.ontology, envelopes: result.envelopes, - parsed: input.payload, + parsed: parsedFromEnvelopes, }; // Deliver webhooks for update operation diff --git a/platforms/profile-editor/api/src/controllers/DiscoveryController.ts b/platforms/profile-editor/api/src/controllers/DiscoveryController.ts index 0940685c2..e6fe5bc04 100644 --- a/platforms/profile-editor/api/src/controllers/DiscoveryController.ts +++ b/platforms/profile-editor/api/src/controllers/DiscoveryController.ts @@ -12,26 +12,25 @@ export class DiscoveryController { try { const { q, page, limit, sortBy } = req.query; - if (!q) { - res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); - res.setHeader("Pragma", "no-cache"); - return res - .status(400) - .json({ error: 'Query parameter "q" is required' }); - } - const pageNum = Math.max(1, parseInt(page as string) || 1); const limitNum = Math.min( 100, - Math.max(1, parseInt(limit as string) || 10), + Math.max(1, parseInt(limit as string) || 12), ); - const results = await this.userSearchService.searchUsers( - q as string, - pageNum, - limitNum, - (sortBy as string) || "relevance", - ); + 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", diff --git a/platforms/profile-editor/api/src/controllers/ProfileController.ts b/platforms/profile-editor/api/src/controllers/ProfileController.ts index 6b30599e2..2597a4c9f 100644 --- a/platforms/profile-editor/api/src/controllers/ProfileController.ts +++ b/platforms/profile-editor/api/src/controllers/ProfileController.ts @@ -1,7 +1,9 @@ import { Request, Response } from "express"; import { EVaultProfileService } from "../services/EVaultProfileService"; +import type { EVaultSyncService } from "../services/EVaultSyncService"; import type { ProfileUpdatePayload, + ProfessionalProfile, WorkExperience, Education, SocialLink, @@ -9,19 +11,61 @@ import type { 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(",")}] avatarFileId=${(data as any).avatarFileId ?? "N/A"} bannerFileId=${(data as any).bannerFileId ?? "N/A"}`); + const { profile, persisted } = await this.evaultService.prepareUpdate(ename, data); + console.log(`[controller] optimisticUpdate ${ename}: returning avatarFileId=${profile.professional.avatarFileId ?? "NONE"} bannerFileId=${profile.professional.bannerFileId ?? "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 = req.user?.ename; + const ename = this.resolveEname(req); if (!ename) { return res.status(401).json({ error: "Authentication required" }); } - const profile = await this.evaultService.getProfile(ename); + 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); @@ -31,26 +75,24 @@ export class ProfileController { updateProfile = async (req: Request, res: Response) => { try { - const ename = req.user?.ename; + const ename = this.resolveEname(req); if (!ename) { return res.status(401).json({ error: "Authentication required" }); } const payload: ProfileUpdatePayload = req.body; - const profile = await this.evaultService.upsertProfile( - ename, - payload, - ); - res.json(profile); + console.log(`[profile] PATCH ${ename}:`, Object.keys(payload)); + + await this.optimisticUpdate(ename, payload, res); } catch (error: any) { - console.error("Error updating profile:", error.message); + 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 = req.user?.ename; + const ename = this.resolveEname(req); if (!ename) { return res.status(401).json({ error: "Authentication required" }); } @@ -62,19 +104,16 @@ export class ProfileController { .json({ error: "Body must be an array of work experience entries" }); } - const profile = await this.evaultService.upsertProfile(ename, { - workExperience, - }); - res.json(profile); + await this.optimisticUpdate(ename, { workExperience }, res); } catch (error: any) { - console.error("Error updating work experience:", error.message); + 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 = req.user?.ename; + const ename = this.resolveEname(req); if (!ename) { return res.status(401).json({ error: "Authentication required" }); } @@ -86,19 +125,17 @@ export class ProfileController { .json({ error: "Body must be an array of education entries" }); } - const profile = await this.evaultService.upsertProfile(ename, { - education, - }); - res.json(profile); + console.log(`[profile] education ${ename}: ${education.length} entries`); + await this.optimisticUpdate(ename, { education }, res); } catch (error: any) { - console.error("Error updating education:", error.message); + 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 = req.user?.ename; + const ename = this.resolveEname(req); if (!ename) { return res.status(401).json({ error: "Authentication required" }); } @@ -110,19 +147,16 @@ export class ProfileController { .json({ error: "Body must be an array of skill strings" }); } - const profile = await this.evaultService.upsertProfile(ename, { - skills, - }); - res.json(profile); + await this.optimisticUpdate(ename, { skills }, res); } catch (error: any) { - console.error("Error updating skills:", error.message); + 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 = req.user?.ename; + const ename = this.resolveEname(req); if (!ename) { return res.status(401).json({ error: "Authentication required" }); } @@ -134,12 +168,9 @@ export class ProfileController { .json({ error: "Body must be an array of social link entries" }); } - const profile = await this.evaultService.upsertProfile(ename, { - socialLinks, - }); - res.json(profile); + await this.optimisticUpdate(ename, { socialLinks }, res); } catch (error: any) { - console.error("Error updating social links:", error.message); + console.error(`[profile] social-links failed:`, error.message); res.status(500).json({ error: "Failed to update social links" }); } }; @@ -163,33 +194,22 @@ export class ProfileController { } }; - private canAccessProfile( + 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 false; + return true; } /** - * Asset proxy endpoints (avatar, banner, cv, video) use a relaxed access - * check: the file is served whenever the profile is public OR the caller is - * the owner. Because /