From 8c46805d878f63270d8bfb461d5baa50396ce944 Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 3 Apr 2026 00:44:23 +0530 Subject: [PATCH 01/16] fix: performance issues --- .../src/controllers/DiscoveryController.ts | 29 ++++++------ .../api/src/controllers/ProfileController.ts | 42 ++++++++--------- .../api/src/services/UserSearchService.ts | 45 ++++++++++++++++++ .../profile/EducationSection.svelte | 32 ++++++++----- .../profile/ExperienceSection.svelte | 32 ++++++++----- .../client/src/lib/stores/discovery.ts | 3 +- .../client/src/lib/stores/profile.ts | 47 +++++++++---------- .../routes/(protected)/discover/+page.svelte | 33 +++++++++---- 8 files changed, 166 insertions(+), 97 deletions(-) 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..fd9223198 100644 --- a/platforms/profile-editor/api/src/controllers/ProfileController.ts +++ b/platforms/profile-editor/api/src/controllers/ProfileController.ts @@ -2,6 +2,7 @@ import { Request, Response } from "express"; import { EVaultProfileService } from "../services/EVaultProfileService"; import type { ProfileUpdatePayload, + ProfessionalProfile, WorkExperience, Education, SocialLink, @@ -29,6 +30,16 @@ export class ProfileController { } }; + /** + * Fire-and-forget helper: sends the eVault upsert in the background + * and logs any failures without blocking the HTTP response. + */ + private syncInBackground(ename: string, data: Partial) { + this.evaultService.upsertProfile(ename, data).catch((err) => { + console.error(`[eVault bg-sync] ${ename}:`, err.message); + }); + } + updateProfile = async (req: Request, res: Response) => { try { const ename = req.user?.ename; @@ -37,11 +48,8 @@ export class ProfileController { } const payload: ProfileUpdatePayload = req.body; - const profile = await this.evaultService.upsertProfile( - ename, - payload, - ); - res.json(profile); + this.syncInBackground(ename, payload); + res.json({ ok: true }); } catch (error: any) { console.error("Error updating profile:", error.message); res.status(500).json({ error: "Failed to update profile" }); @@ -62,10 +70,8 @@ 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); + this.syncInBackground(ename, { workExperience }); + res.json({ ok: true }); } catch (error: any) { console.error("Error updating work experience:", error.message); res.status(500).json({ error: "Failed to update work experience" }); @@ -86,10 +92,8 @@ export class ProfileController { .json({ error: "Body must be an array of education entries" }); } - const profile = await this.evaultService.upsertProfile(ename, { - education, - }); - res.json(profile); + this.syncInBackground(ename, { education }); + res.json({ ok: true }); } catch (error: any) { console.error("Error updating education:", error.message); res.status(500).json({ error: "Failed to update education" }); @@ -110,10 +114,8 @@ export class ProfileController { .json({ error: "Body must be an array of skill strings" }); } - const profile = await this.evaultService.upsertProfile(ename, { - skills, - }); - res.json(profile); + this.syncInBackground(ename, { skills }); + res.json({ ok: true }); } catch (error: any) { console.error("Error updating skills:", error.message); res.status(500).json({ error: "Failed to update skills" }); @@ -134,10 +136,8 @@ 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); + this.syncInBackground(ename, { socialLinks }); + res.json({ ok: true }); } catch (error: any) { console.error("Error updating social links:", error.message); res.status(500).json({ error: "Failed to update social links" }); diff --git a/platforms/profile-editor/api/src/services/UserSearchService.ts b/platforms/profile-editor/api/src/services/UserSearchService.ts index c60155437..84d61d14a 100644 --- a/platforms/profile-editor/api/src/services/UserSearchService.ts +++ b/platforms/profile-editor/api/src/services/UserSearchService.ts @@ -114,6 +114,51 @@ export class UserSearchService { }; } + 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.avatarFileId", + "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, + avatarFileId: user.avatarFileId, + 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 }); } diff --git a/platforms/profile-editor/client/src/lib/components/profile/EducationSection.svelte b/platforms/profile-editor/client/src/lib/components/profile/EducationSection.svelte index dcfc867c7..f18e9f68d 100644 --- a/platforms/profile-editor/client/src/lib/components/profile/EducationSection.svelte +++ b/platforms/profile-editor/client/src/lib/components/profile/EducationSection.svelte @@ -155,19 +155,25 @@ async function remove(index: number) { {/if}
-

- {entry.degree}{entry.fieldOfStudy - ? `, ${entry.fieldOfStudy}` - : ""} -

-

- {entry.institution} -

-

- {entry.startDate}{entry.endDate - ? ` — ${entry.endDate}` - : " — Present"} -

+ {#if entry.degree || entry.fieldOfStudy} +

+ {entry.degree}{entry.fieldOfStudy + ? `, ${entry.fieldOfStudy}` + : ""} +

+ {/if} + {#if entry.institution} +

+ {entry.institution} +

+ {/if} + {#if entry.startDate} +

+ {entry.startDate}{entry.endDate + ? ` — ${entry.endDate}` + : " — Present"} +

+ {/if} {#if entry.description}

{entry.description} diff --git a/platforms/profile-editor/client/src/lib/components/profile/ExperienceSection.svelte b/platforms/profile-editor/client/src/lib/components/profile/ExperienceSection.svelte index f10ed14f4..ec48f6db9 100644 --- a/platforms/profile-editor/client/src/lib/components/profile/ExperienceSection.svelte +++ b/platforms/profile-editor/client/src/lib/components/profile/ExperienceSection.svelte @@ -159,19 +159,25 @@ async function remove(index: number) { {/if}

-

- {entry.role} -

-

- {entry.company} -

-

- {entry.startDate}{entry.endDate - ? ` — ${entry.endDate}` - : " — Present"} - {#if entry.location} - · {entry.location}{/if} -

+ {#if entry.role} +

+ {entry.role} +

+ {/if} + {#if entry.company} +

+ {entry.company} +

+ {/if} + {#if entry.startDate} +

+ {entry.startDate}{entry.endDate + ? ` — ${entry.endDate}` + : " — Present"} + {#if entry.location} + · {entry.location}{/if} +

+ {/if} {#if entry.description}

{entry.description} diff --git a/platforms/profile-editor/client/src/lib/stores/discovery.ts b/platforms/profile-editor/client/src/lib/stores/discovery.ts index 13500adc5..5301cf920 100644 --- a/platforms/profile-editor/client/src/lib/stores/discovery.ts +++ b/platforms/profile-editor/client/src/lib/stores/discovery.ts @@ -36,7 +36,8 @@ export async function searchProfiles( ): Promise { searchLoading.set(true); try { - const params: Record = { q: query, _: String(Date.now()) }; + const params: Record = { _: String(Date.now()) }; + if (query) params.q = query; if (options?.skills?.length) params.skills = options.skills.join(','); if (options?.page) params.page = String(options.page); if (options?.limit) params.limit = String(options.limit); diff --git a/platforms/profile-editor/client/src/lib/stores/profile.ts b/platforms/profile-editor/client/src/lib/stores/profile.ts index 40110e511..a049fec24 100644 --- a/platforms/profile-editor/client/src/lib/stores/profile.ts +++ b/platforms/profile-editor/client/src/lib/stores/profile.ts @@ -76,40 +76,37 @@ export async function fetchProfile(): Promise { } } -export async function updateProfile(data: Record): Promise { - const response = await apiClient.patch('/api/profile', data); - const updated = response.data; - profile.set(updated); - // Sync name to header when updating own profile - const user = get(currentUser); - if (user?.ename === updated.ename && updated.name) { - currentUser.update((u) => (u ? { ...u, name: updated.name } : u)); +export async function updateProfile(data: Record): Promise { + // Optimistic update + profile.update((p) => p ? { ...p, ...data, professional: { ...p.professional, ...data } } : p); + if (data.displayName || data.name) { + const user = get(currentUser); + const name = (data.displayName ?? data.name) as string; + if (user && name) { + currentUser.update((u) => (u ? { ...u, name } : u)); + } } - return updated; + await apiClient.patch('/api/profile', data); } -export async function updateWorkExperience(entries: WorkExperience[]): Promise { - const response = await apiClient.put('/api/profile/work-experience', entries); - profile.set(response.data); - return response.data; +export async function updateWorkExperience(entries: WorkExperience[]): Promise { + profile.update((p) => p ? { ...p, professional: { ...p.professional, workExperience: entries } } : p); + await apiClient.put('/api/profile/work-experience', entries); } -export async function updateEducation(entries: Education[]): Promise { - const response = await apiClient.put('/api/profile/education', entries); - profile.set(response.data); - return response.data; +export async function updateEducation(entries: Education[]): Promise { + profile.update((p) => p ? { ...p, professional: { ...p.professional, education: entries } } : p); + await apiClient.put('/api/profile/education', entries); } -export async function updateSkills(skills: string[]): Promise { - const response = await apiClient.put('/api/profile/skills', skills); - profile.set(response.data); - return response.data; +export async function updateSkills(skills: string[]): Promise { + profile.update((p) => p ? { ...p, professional: { ...p.professional, skills } } : p); + await apiClient.put('/api/profile/skills', skills); } -export async function updateSocialLinks(links: SocialLink[]): Promise { - const response = await apiClient.put('/api/profile/social-links', links); - profile.set(response.data); - return response.data; +export async function updateSocialLinks(links: SocialLink[]): Promise { + profile.update((p) => p ? { ...p, professional: { ...p.professional, socialLinks: links } } : p); + await apiClient.put('/api/profile/social-links', links); } export async function fetchPublicProfile(ename: string): Promise { diff --git a/platforms/profile-editor/client/src/routes/(protected)/discover/+page.svelte b/platforms/profile-editor/client/src/routes/(protected)/discover/+page.svelte index 536453467..5a3f9ff45 100644 --- a/platforms/profile-editor/client/src/routes/(protected)/discover/+page.svelte +++ b/platforms/profile-editor/client/src/routes/(protected)/discover/+page.svelte @@ -1,4 +1,5 @@ - - -

- - {#if avatarProxyUrl()} - - {/if} - - {(result.name || result.ename || '?')[0]?.toUpperCase()} - - + + + + {#if avatarProxyUrl()} + + {/if} + + {(result.name || result.ename || '?')[0]?.toUpperCase()} + + -
-
-

{result.name || result.ename}

- {#if result.isVerified} - Verified - {/if} -
- {#if result.headline} -

{result.headline}

- {/if} - {#if result.location} -

{result.location}

- {/if} - {#if result.skills?.length} -
- {#each result.skills.slice(0, 4) as skill} - {skill} - {/each} - {#if result.skills.length > 4} - +{result.skills.length - 4} - {/if} -
+
+

{result.name || result.ename}

+ {#if result.isVerified} + Verified + {/if} +
+ + {#if result.headline} +

{result.headline}

+ {/if} + {#if result.location} +

{result.location}

+ {/if} + {#if result.skills?.length} +
+ {#each result.skills.slice(0, 3) as skill} + {skill} + {/each} + {#if result.skills.length > 3} + +{result.skills.length - 3} {/if}
-
+ {/if}
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 be9f524f7..eb915586e 100644 --- a/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte +++ b/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte @@ -19,15 +19,20 @@ let uploadingBanner = $state(false); let bannerInput = $state(null); let avatarInput = $state(null); + /** Local blob previews — shown instantly before upload completes */ + let avatarPreview = $state(null); + let bannerPreview = $state(null); - function avatarUrl(): string | null { + function avatarSrc(): string | null { + if (avatarPreview) return avatarPreview; if (profile.professional.avatarFileId) { return getProfileAssetUrl(profile.ename, 'avatar'); } return null; } - function bannerUrl(): string | null { + function bannerSrc(): string | null { + if (bannerPreview) return bannerPreview; if (profile.professional.bannerFileId) { return getProfileAssetUrl(profile.ename, 'banner'); } @@ -39,6 +44,9 @@ const file = input.files?.[0]; if (!file) return; + // Show local preview instantly + avatarPreview = URL.createObjectURL(file); + uploadingAvatar = true; try { const result = await uploadFile(file); @@ -46,6 +54,7 @@ toast.success('Avatar updated'); } catch { toast.error('Failed to upload avatar'); + avatarPreview = null; // revert on failure } finally { uploadingAvatar = false; } @@ -56,6 +65,9 @@ const file = input.files?.[0]; if (!file) return; + // Show local preview instantly + bannerPreview = URL.createObjectURL(file); + uploadingBanner = true; try { const result = await uploadFile(file); @@ -63,6 +75,7 @@ toast.success('Banner updated'); } catch { toast.error('Failed to upload banner'); + bannerPreview = null; // revert on failure } finally { uploadingBanner = false; } @@ -82,8 +95,8 @@
- {#if bannerUrl()} - Banner + {#if bannerSrc()} + Banner {/if} {#if editable}
@@ -101,8 +114,8 @@
- {#if avatarUrl()} - + {#if avatarSrc()} + {/if} {(profile.name ?? profile.ename ?? '?')[0]?.toUpperCase()} diff --git a/platforms/profile-editor/client/src/lib/stores/profile.ts b/platforms/profile-editor/client/src/lib/stores/profile.ts index a049fec24..67e08449e 100644 --- a/platforms/profile-editor/client/src/lib/stores/profile.ts +++ b/platforms/profile-editor/client/src/lib/stores/profile.ts @@ -76,37 +76,39 @@ export async function fetchProfile(): Promise { } } -export async function updateProfile(data: Record): Promise { - // Optimistic update - profile.update((p) => p ? { ...p, ...data, professional: { ...p.professional, ...data } } : p); - if (data.displayName || data.name) { - const user = get(currentUser); - const name = (data.displayName ?? data.name) as string; - if (user && name) { - currentUser.update((u) => (u ? { ...u, name } : u)); - } +export async function updateProfile(data: Record): Promise { + const response = await apiClient.patch('/api/profile', data); + const updated = response.data; + profile.set(updated); + const user = get(currentUser); + if (user?.ename === updated.ename && updated.name) { + currentUser.update((u) => (u ? { ...u, name: updated.name } : u)); } - await apiClient.patch('/api/profile', data); + return updated; } -export async function updateWorkExperience(entries: WorkExperience[]): Promise { - profile.update((p) => p ? { ...p, professional: { ...p.professional, workExperience: entries } } : p); - await apiClient.put('/api/profile/work-experience', entries); +export async function updateWorkExperience(entries: WorkExperience[]): Promise { + const response = await apiClient.put('/api/profile/work-experience', entries); + profile.set(response.data); + return response.data; } -export async function updateEducation(entries: Education[]): Promise { - profile.update((p) => p ? { ...p, professional: { ...p.professional, education: entries } } : p); - await apiClient.put('/api/profile/education', entries); +export async function updateEducation(entries: Education[]): Promise { + const response = await apiClient.put('/api/profile/education', entries); + profile.set(response.data); + return response.data; } -export async function updateSkills(skills: string[]): Promise { - profile.update((p) => p ? { ...p, professional: { ...p.professional, skills } } : p); - await apiClient.put('/api/profile/skills', skills); +export async function updateSkills(skills: string[]): Promise { + const response = await apiClient.put('/api/profile/skills', skills); + profile.set(response.data); + return response.data; } -export async function updateSocialLinks(links: SocialLink[]): Promise { - profile.update((p) => p ? { ...p, professional: { ...p.professional, socialLinks: links } } : p); - await apiClient.put('/api/profile/social-links', links); +export async function updateSocialLinks(links: SocialLink[]): Promise { + const response = await apiClient.put('/api/profile/social-links', links); + profile.set(response.data); + return response.data; } export async function fetchPublicProfile(ename: string): Promise { From 61e0916215cf873f7f57d0b10b9f886a477c58a7 Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 3 Apr 2026 01:11:32 +0530 Subject: [PATCH 03/16] feat: profile-editor blocking on images --- .../api/src/controllers/ProfileController.ts | 4 +-- .../api/src/services/EVaultProfileService.ts | 32 +++++++++++++------ .../api/src/utils/file-proxy.ts | 2 +- .../profile/DocumentsSection.svelte | 24 +++++++++++++- .../components/profile/ProfileHeader.svelte | 18 ++++++++--- .../client/src/lib/utils/file-manager.ts | 6 ++-- 6 files changed, 67 insertions(+), 19 deletions(-) diff --git a/platforms/profile-editor/api/src/controllers/ProfileController.ts b/platforms/profile-editor/api/src/controllers/ProfileController.ts index 958c7e14d..a1f70d1cf 100644 --- a/platforms/profile-editor/api/src/controllers/ProfileController.ts +++ b/platforms/profile-editor/api/src/controllers/ProfileController.ts @@ -269,7 +269,7 @@ export class ProfileController { const { proxyFileFromFileManager } = await import( "../utils/file-proxy" ); - await proxyFileFromFileManager(fileId, ename, res, "download"); + await proxyFileFromFileManager(fileId, ename, res); } catch (error: any) { console.error("Error proxying CV:", error.message); res.status(500).json({ error: "Failed to fetch CV" }); @@ -293,7 +293,7 @@ export class ProfileController { const { proxyFileFromFileManager } = await import( "../utils/file-proxy" ); - await proxyFileFromFileManager(fileId, ename, res, "download"); + await proxyFileFromFileManager(fileId, 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/services/EVaultProfileService.ts b/platforms/profile-editor/api/src/services/EVaultProfileService.ts index 013e2dfd6..8020f99d0 100644 --- a/platforms/profile-editor/api/src/services/EVaultProfileService.ts +++ b/platforms/profile-editor/api/src/services/EVaultProfileService.ts @@ -106,13 +106,26 @@ export class EVaultProfileService { private registryService: RegistryService; /** In-memory profile cache: serves reads instantly and absorbs writes. */ private profileCache = new Map(); + /** + * Generation counter per ename. Incremented on every local write so that + * a slow eVault refresh that started before the write cannot overwrite the + * newer local state. + */ + private cacheGen = new Map(); constructor(registryService: RegistryService) { this.registryService = registryService; } + private gen(eName: string): number { + return this.cacheGen.get(eName) ?? 0; + } + /** Update the cache with a partial professional-profile merge. */ patchCache(eName: string, data: Partial): FullProfile { + // Bump generation so any in-flight eVault refresh won't overwrite this. + this.cacheGen.set(eName, this.gen(eName) + 1); + const existing = this.profileCache.get(eName); if (existing) { const merged: FullProfile = { @@ -169,9 +182,12 @@ export class EVaultProfileService { /** * Fetch the canonical profile from eVault (slow — multiple network hops). - * Updates the local cache on success. + * Only writes to cache if no newer local write happened while the fetch + * was in flight (generation check). */ async fetchProfileFromEvault(eName: string): Promise { + const genBefore = this.gen(eName); + const client = await this.getClient(eName); const [professionalNode, userNode] = await Promise.all([ @@ -210,21 +226,19 @@ export class EVaultProfileService { }, }; - this.profileCache.set(eName, profile); + // Only update cache if no local write happened while we were fetching. + if (this.gen(eName) === genBefore) { + this.profileCache.set(eName, profile); + } return profile; } /** - * Return the profile for eName. Serves from cache when available (instant), - * and kicks off a background eVault refresh so the cache stays warm. + * Return the profile for eName. Serves from cache when available (instant). */ async getProfile(eName: string): Promise { const cached = this.profileCache.get(eName); - if (cached) { - // Refresh cache in background so it doesn't go stale - this.fetchProfileFromEvault(eName).catch(() => {}); - return cached; - } + if (cached) return cached; // Cache miss — must fetch from eVault (first load) return this.fetchProfileFromEvault(eName); } diff --git a/platforms/profile-editor/api/src/utils/file-proxy.ts b/platforms/profile-editor/api/src/utils/file-proxy.ts index 61470bcdd..4e47d944a 100644 --- a/platforms/profile-editor/api/src/utils/file-proxy.ts +++ b/platforms/profile-editor/api/src/utils/file-proxy.ts @@ -41,7 +41,7 @@ export async function proxyFileFromFileManager( if (contentLength) { res.set("Content-Length", contentLength); } - res.set("Cache-Control", "public, max-age=3600"); + res.set("Cache-Control", "public, max-age=300"); response.data.pipe(res); } catch (error: any) { 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 aa9315339..aab84a661 100644 --- a/platforms/profile-editor/client/src/lib/components/profile/DocumentsSection.svelte +++ b/platforms/profile-editor/client/src/lib/components/profile/DocumentsSection.svelte @@ -88,6 +88,17 @@
{#if cvFileId} +
{#if editable} @@ -121,8 +132,19 @@
{#if videoIntroFileId} +
+ + +
- + {#if editable} {/if} 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 eb915586e..4669b2390 100644 --- a/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte +++ b/platforms/profile-editor/client/src/lib/components/profile/ProfileHeader.svelte @@ -22,11 +22,16 @@ /** Local blob previews — shown instantly before upload completes */ let avatarPreview = $state(null); let bannerPreview = $state(null); + /** Cache-bust counters — incremented after successful upload */ + let avatarCacheBust = $state(0); + let bannerCacheBust = $state(0); function avatarSrc(): string | null { if (avatarPreview) return avatarPreview; if (profile.professional.avatarFileId) { - return getProfileAssetUrl(profile.ename, 'avatar'); + // Use cacheBust to force re-read; also needed on initial load + void avatarCacheBust; + return getProfileAssetUrl(profile.ename, 'avatar', avatarCacheBust > 0); } return null; } @@ -34,7 +39,8 @@ function bannerSrc(): string | null { if (bannerPreview) return bannerPreview; if (profile.professional.bannerFileId) { - return getProfileAssetUrl(profile.ename, 'banner'); + void bannerCacheBust; + return getProfileAssetUrl(profile.ename, 'banner', bannerCacheBust > 0); } return null; } @@ -51,10 +57,12 @@ try { const result = await uploadFile(file); await updateProfile({ avatarFileId: result.id }); + avatarPreview = null; + avatarCacheBust++; toast.success('Avatar updated'); } catch { toast.error('Failed to upload avatar'); - avatarPreview = null; // revert on failure + avatarPreview = null; } finally { uploadingAvatar = false; } @@ -72,10 +80,12 @@ try { const result = await uploadFile(file); await updateProfile({ bannerFileId: result.id }); + bannerPreview = null; + bannerCacheBust++; toast.success('Banner updated'); } catch { toast.error('Failed to upload banner'); - bannerPreview = null; // revert on failure + bannerPreview = null; } finally { uploadingBanner = false; } diff --git a/platforms/profile-editor/client/src/lib/utils/file-manager.ts b/platforms/profile-editor/client/src/lib/utils/file-manager.ts index bc0f0d98f..a3c97269d 100644 --- a/platforms/profile-editor/client/src/lib/utils/file-manager.ts +++ b/platforms/profile-editor/client/src/lib/utils/file-manager.ts @@ -24,6 +24,8 @@ export async function uploadFile( export type ProfileAssetType = 'avatar' | 'banner' | 'cv' | 'video'; -export function getProfileAssetUrl(ename: string, type: ProfileAssetType): string { - return `${API_BASE()}/api/profiles/${encodeURIComponent(ename)}/${type}`; +export function getProfileAssetUrl(ename: string, type: ProfileAssetType, bustCache = false): string { + const base = `${API_BASE()}/api/profiles/${encodeURIComponent(ename)}/${type}`; + if (bustCache) return `${base}?t=${Date.now()}`; + return base; } From 8525147653a38e459065a1195a0334f15cb35799 Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 3 Apr 2026 01:16:43 +0530 Subject: [PATCH 04/16] fix: sync bug and cache --- .../api/src/controllers/ProfileController.ts | 33 ++-- .../api/src/services/EVaultProfileService.ts | 166 +++++++++++------- 2 files changed, 122 insertions(+), 77 deletions(-) diff --git a/platforms/profile-editor/api/src/controllers/ProfileController.ts b/platforms/profile-editor/api/src/controllers/ProfileController.ts index a1f70d1cf..afd901efb 100644 --- a/platforms/profile-editor/api/src/controllers/ProfileController.ts +++ b/platforms/profile-editor/api/src/controllers/ProfileController.ts @@ -4,6 +4,7 @@ import type { EVaultSyncService } from "../services/EVaultSyncService"; import type { ProfileUpdatePayload, ProfessionalProfile, + FullProfile, WorkExperience, Education, SocialLink, @@ -40,16 +41,23 @@ export class ProfileController { * Update the local cache immediately and fire the eVault write in the * background. Returns the patched profile so the response carries the * authoritative local state. + * + * When `blocking` is true the returned promise resolves only after the + * eVault write completes (used for visibility toggles where the user + * needs confirmation the change persisted). */ - private patchAndSync(ename: string, data: Partial) { + private patchAndSync( + ename: string, + data: Partial, + ): { profile: FullProfile; persisted: Promise } { const patched = this.evaultService.patchCache(ename, data); - // Sync to eVault in background - this.evaultService.upsertProfile(ename, data).catch((err) => { + // Sync full cached state to eVault (serialised per user) + const persisted = this.evaultService.syncToEvault(ename).catch((err) => { console.error(`[eVault bg-sync] ${ename}:`, err.message); - }); + }) as Promise; // Also update the local search DB so discover page reflects changes this.syncService?.syncUserToSearchDb(patched); - return patched; + return { profile: patched, persisted }; } updateProfile = async (req: Request, res: Response) => { @@ -60,7 +68,12 @@ export class ProfileController { } const payload: ProfileUpdatePayload = req.body; - const profile = this.patchAndSync(ename, payload); + // Visibility changes must be blocking so the ACL is persisted + const isVisibilityChange = "isPublic" in payload; + const { profile, persisted } = this.patchAndSync(ename, payload); + if (isVisibilityChange) { + await persisted; + } res.json(profile); } catch (error: any) { console.error("Error updating profile:", error.message); @@ -82,7 +95,7 @@ export class ProfileController { .json({ error: "Body must be an array of work experience entries" }); } - const profile = this.patchAndSync(ename, { workExperience }); + const { profile } = this.patchAndSync(ename, { workExperience }); res.json(profile); } catch (error: any) { console.error("Error updating work experience:", error.message); @@ -104,7 +117,7 @@ export class ProfileController { .json({ error: "Body must be an array of education entries" }); } - const profile = this.patchAndSync(ename, { education }); + const { profile } = this.patchAndSync(ename, { education }); res.json(profile); } catch (error: any) { console.error("Error updating education:", error.message); @@ -126,7 +139,7 @@ export class ProfileController { .json({ error: "Body must be an array of skill strings" }); } - const profile = this.patchAndSync(ename, { skills }); + const { profile } = this.patchAndSync(ename, { skills }); res.json(profile); } catch (error: any) { console.error("Error updating skills:", error.message); @@ -148,7 +161,7 @@ export class ProfileController { .json({ error: "Body must be an array of social link entries" }); } - const profile = this.patchAndSync(ename, { socialLinks }); + const { profile } = this.patchAndSync(ename, { socialLinks }); res.json(profile); } catch (error: any) { console.error("Error updating social links:", error.message); diff --git a/platforms/profile-editor/api/src/services/EVaultProfileService.ts b/platforms/profile-editor/api/src/services/EVaultProfileService.ts index 8020f99d0..1a653bb0f 100644 --- a/platforms/profile-editor/api/src/services/EVaultProfileService.ts +++ b/platforms/profile-editor/api/src/services/EVaultProfileService.ts @@ -112,6 +112,8 @@ export class EVaultProfileService { * newer local state. */ private cacheGen = new Map(); + /** Per-user write queue to serialise eVault writes and prevent clobbering. */ + private writeQueue = new Map>(); constructor(registryService: RegistryService) { this.registryService = registryService; @@ -121,6 +123,23 @@ export class EVaultProfileService { return this.cacheGen.get(eName) ?? 0; } + /** + * Serialise an async operation per eName so concurrent writes don't race. + * Each queued fn runs only after the previous one settles. + */ + private enqueueWrite(eName: string, fn: () => Promise): Promise { + const prev = this.writeQueue.get(eName) ?? Promise.resolve(); + const next = prev.catch(() => {}).then(fn); + this.writeQueue.set(eName, next); + // Clean up ref when this is still the tail + next.catch(() => {}).then(() => { + if (this.writeQueue.get(eName) === next) { + this.writeQueue.delete(eName); + } + }); + return next; + } + /** Update the cache with a partial professional-profile merge. */ patchCache(eName: string, data: Partial): FullProfile { // Bump generation so any in-flight eVault refresh won't overwrite this. @@ -251,93 +270,106 @@ export class EVaultProfileService { return profile; } - 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 acl = merged.isPublic === true ? ["*"] : [normalizeEName(eName)]; - - 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) { - const errors = result.createMetaEnvelope.errors; - const couldBeConflict = errors.some( - (e) => e.code === "CREATE_FAILED" || e.code === "ONTOLOGY_ALREADY_EXISTS", - ); + /** + * Persist the current cached profile to eVault. + * + * Uses the CACHE as the source of truth (not re-reading from eVault) so + * concurrent writes never clobber each other. Writes are serialised per + * eName via the write queue. + */ + async syncToEvault(eName: string): Promise { + return this.enqueueWrite(eName, async () => { + const cached = this.profileCache.get(eName); + if (!cached) return; - if (!couldBeConflict) { - throw new Error(errors.map((e) => e.message).join("; ")); - } + const payload: ProfessionalProfile = { ...cached.professional }; + const acl = + payload.isPublic === true ? ["*"] : [normalizeEName(eName)]; - // Re-query in case a concurrent create won the race (TOCTOU) - const raced = await this.findMetaEnvelopeByOntology( + const client = await this.getClient(eName); + const existing = await this.findMetaEnvelopeByOntology( client, PROFESSIONAL_PROFILE_ONTOLOGY, ); - if (raced) { - const updateResult = await client.request( + + if (existing) { + const result = await client.request( UPDATE_MUTATION, { - id: raced.id, + id: existing.id, input: { ontology: PROFESSIONAL_PROFILE_ONTOLOGY, - payload: merged, + payload, acl, }, }, ); - if (updateResult.updateMetaEnvelope.errors?.length) { + + if (result.updateMetaEnvelope.errors?.length) { throw new Error( - updateResult.updateMetaEnvelope.errors + result.updateMetaEnvelope.errors .map((e) => e.message) .join("; "), ); } } else { - // No existing envelope found — this wasn't a conflict, surface original errors - throw new Error(errors.map((e) => e.message).join("; ")); - } - } - } + const result = await client.request( + CREATE_MUTATION, + { + input: { + ontology: PROFESSIONAL_PROFILE_ONTOLOGY, + payload, + acl, + }, + }, + ); - // Refresh cache from eVault after successful write - return this.fetchProfileFromEvault(eName); + 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("; "), + ); + } + + // Re-query in case a concurrent create won the race (TOCTOU) + 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("; "), + ); + } + } + } + }); } async getProfileByEnvelope( From a23f2d70beb9099bd119740491f631ec8720bf6f Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 3 Apr 2026 01:23:27 +0530 Subject: [PATCH 05/16] feat: video preview --- .../api/src/utils/file-proxy.ts | 55 ++++++++++++++----- .../client/src/lib/utils/file-manager.ts | 4 +- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/platforms/profile-editor/api/src/utils/file-proxy.ts b/platforms/profile-editor/api/src/utils/file-proxy.ts index 4e47d944a..4a3e1ef7e 100644 --- a/platforms/profile-editor/api/src/utils/file-proxy.ts +++ b/platforms/profile-editor/api/src/utils/file-proxy.ts @@ -15,33 +15,58 @@ export async function proxyFileFromFileManager( fileId: string, ename: string, res: Response, - mode: "preview" | "download" = "preview", + disposition: "inline" | "attachment" = "inline", ): Promise { try { const token = mintFmToken(ename); - const endpoint = mode === "download" ? "download" : "preview"; - const url = `${FILE_MANAGER_BASE_URL()}/api/files/${fileId}/${endpoint}`; - const response = await axios.get(url, { - responseType: "stream", - timeout: 60000, - headers: { - Authorization: `Bearer ${token}`, - }, - }); + // 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 contentDisposition = response.headers["content-disposition"]; const contentLength = response.headers["content-length"]; res.set("Content-Type", contentType); - if (contentDisposition) { - res.set("Content-Disposition", contentDisposition); - } + // 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); } - res.set("Cache-Control", "public, max-age=300"); + res.set("Cache-Control", "no-cache, must-revalidate"); response.data.pipe(res); } catch (error: any) { diff --git a/platforms/profile-editor/client/src/lib/utils/file-manager.ts b/platforms/profile-editor/client/src/lib/utils/file-manager.ts index a3c97269d..ed5341539 100644 --- a/platforms/profile-editor/client/src/lib/utils/file-manager.ts +++ b/platforms/profile-editor/client/src/lib/utils/file-manager.ts @@ -11,7 +11,9 @@ export async function uploadFile( formData.append('file', file); const response = await apiClient.post('/api/files', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, + // Let the browser set Content-Type with the multipart boundary automatically. + // Explicitly setting it strips the boundary and breaks multer parsing. + headers: { 'Content-Type': undefined as unknown as string }, onUploadProgress: (e) => { if (onProgress && e.total) { onProgress(Math.round((e.loaded * 100) / e.total)); From 49965c5d5d7279225720740a10c4df29e0232785 Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 3 Apr 2026 01:34:24 +0530 Subject: [PATCH 06/16] chore: remove cache --- .../api/src/controllers/ProfileController.ts | 82 ++--- .../api/src/services/EVaultProfileService.ts | 312 ++++++++---------- .../api/src/services/EVaultSyncService.ts | 3 +- .../api/src/utils/file-proxy.ts | 2 - .../components/profile/ProfileHeader.svelte | 12 +- .../client/src/lib/utils/file-manager.ts | 6 +- 6 files changed, 164 insertions(+), 253 deletions(-) diff --git a/platforms/profile-editor/api/src/controllers/ProfileController.ts b/platforms/profile-editor/api/src/controllers/ProfileController.ts index afd901efb..4d6ef8b14 100644 --- a/platforms/profile-editor/api/src/controllers/ProfileController.ts +++ b/platforms/profile-editor/api/src/controllers/ProfileController.ts @@ -3,8 +3,6 @@ import { EVaultProfileService } from "../services/EVaultProfileService"; import type { EVaultSyncService } from "../services/EVaultSyncService"; import type { ProfileUpdatePayload, - ProfessionalProfile, - FullProfile, WorkExperience, Education, SocialLink, @@ -37,29 +35,6 @@ export class ProfileController { } }; - /** - * Update the local cache immediately and fire the eVault write in the - * background. Returns the patched profile so the response carries the - * authoritative local state. - * - * When `blocking` is true the returned promise resolves only after the - * eVault write completes (used for visibility toggles where the user - * needs confirmation the change persisted). - */ - private patchAndSync( - ename: string, - data: Partial, - ): { profile: FullProfile; persisted: Promise } { - const patched = this.evaultService.patchCache(ename, data); - // Sync full cached state to eVault (serialised per user) - const persisted = this.evaultService.syncToEvault(ename).catch((err) => { - console.error(`[eVault bg-sync] ${ename}:`, err.message); - }) as Promise; - // Also update the local search DB so discover page reflects changes - this.syncService?.syncUserToSearchDb(patched); - return { profile: patched, persisted }; - } - updateProfile = async (req: Request, res: Response) => { try { const ename = req.user?.ename; @@ -68,15 +43,12 @@ export class ProfileController { } const payload: ProfileUpdatePayload = req.body; - // Visibility changes must be blocking so the ACL is persisted - const isVisibilityChange = "isPublic" in payload; - const { profile, persisted } = this.patchAndSync(ename, payload); - if (isVisibilityChange) { - await persisted; - } + console.log(`[profile] PATCH /api/profile ${ename}:`, Object.keys(payload)); + const profile = await this.evaultService.upsertProfile(ename, payload); + this.syncService?.syncUserToSearchDb(profile); res.json(profile); } catch (error: any) { - console.error("Error updating profile:", error.message); + console.error(`[profile] PATCH /api/profile failed for ${req.user?.ename}:`, error.message); res.status(500).json({ error: "Failed to update profile" }); } }; @@ -95,10 +67,12 @@ export class ProfileController { .json({ error: "Body must be an array of work experience entries" }); } - const { profile } = this.patchAndSync(ename, { workExperience }); + console.log(`[profile] PUT /api/profile/work-experience ${ename}: ${workExperience.length} entries`); + const profile = await this.evaultService.upsertProfile(ename, { workExperience }); + this.syncService?.syncUserToSearchDb(profile); res.json(profile); } catch (error: any) { - console.error("Error updating work experience:", error.message); + console.error(`[profile] PUT /api/profile/work-experience failed for ${req.user?.ename}:`, error.message); res.status(500).json({ error: "Failed to update work experience" }); } }; @@ -117,10 +91,14 @@ export class ProfileController { .json({ error: "Body must be an array of education entries" }); } - const { profile } = this.patchAndSync(ename, { education }); + console.log(`[profile] PUT /api/profile/education ${ename}: ${education.length} entries`); + console.log(`[profile] education payload:`, JSON.stringify(education)); + const profile = await this.evaultService.upsertProfile(ename, { education }); + console.log(`[profile] education after upsert: ${profile.professional.education?.length ?? 0} entries`); + this.syncService?.syncUserToSearchDb(profile); res.json(profile); } catch (error: any) { - console.error("Error updating education:", error.message); + console.error(`[profile] PUT /api/profile/education failed for ${req.user?.ename}:`, error.message, error.stack); res.status(500).json({ error: "Failed to update education" }); } }; @@ -139,10 +117,12 @@ export class ProfileController { .json({ error: "Body must be an array of skill strings" }); } - const { profile } = this.patchAndSync(ename, { skills }); + console.log(`[profile] PUT /api/profile/skills ${ename}: ${skills.length} skills`); + const profile = await this.evaultService.upsertProfile(ename, { skills }); + this.syncService?.syncUserToSearchDb(profile); res.json(profile); } catch (error: any) { - console.error("Error updating skills:", error.message); + console.error(`[profile] PUT /api/profile/skills failed for ${req.user?.ename}:`, error.message); res.status(500).json({ error: "Failed to update skills" }); } }; @@ -161,10 +141,12 @@ export class ProfileController { .json({ error: "Body must be an array of social link entries" }); } - const { profile } = this.patchAndSync(ename, { socialLinks }); + console.log(`[profile] PUT /api/profile/social-links ${ename}: ${socialLinks.length} links`); + const profile = await this.evaultService.upsertProfile(ename, { socialLinks }); + this.syncService?.syncUserToSearchDb(profile); res.json(profile); } catch (error: any) { - console.error("Error updating social links:", error.message); + console.error(`[profile] PUT /api/profile/social-links failed for ${req.user?.ename}:`, error.message); res.status(500).json({ error: "Failed to update social links" }); } }; @@ -188,32 +170,12 @@ export class ProfileController { } }; - private canAccessProfile( - 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; - } - - /** - * 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 /