From a83e172e6d5b9c14d2976f1b84cc06ab40fab1cb Mon Sep 17 00:00:00 2001 From: Victor Jack <67293106+thedon-dev@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:24:07 +0100 Subject: [PATCH] fix: show clear error when creating or updating organization site name to an existing one (#793) * fixed error of not showing the org name API response error * Fixed the error also for organization settings page * modified * Keep field-specific mapping in OrgApi --------- Co-authored-by: Rotimi Best --- apps/api/src/services/organization.ts | 9 +++- .../onboarding/api/onboarding.svelte.ts | 13 ++++- .../src/lib/features/org/api/org.svelte.ts | 49 ++++++++++++++++--- .../features/settings/pages/domains.svelte | 2 + .../src/lib/utils/services/api/base.svelte.ts | 18 +++++-- .../src/routes/(auth)/onboarding/+page.svelte | 1 + .../src/queries/organization/organization.ts | 14 ++++-- 7 files changed, 89 insertions(+), 17 deletions(-) diff --git a/apps/api/src/services/organization.ts b/apps/api/src/services/organization.ts index a76074c9..66241f0d 100644 --- a/apps/api/src/services/organization.ts +++ b/apps/api/src/services/organization.ts @@ -4,6 +4,7 @@ import type { TGetAudienceQuery, TGetOrganizationCoursesQuery } from '@cio/utils import type { TNewOrganizationPlan, TOrganization, TOrganizationPlan } from '@db/types'; import { cancelOrganizationPlan, + checkSiteNameExists, createOrganizationPlan, deleteOrganizationAudienceMember, deleteOrganizationMember, @@ -648,7 +649,13 @@ export async function createOrgPlan(data: TNewOrganizationPlan) { */ export async function updateOrg(orgId: string, data: Partial) { try { - // Enforce plan restriction: only paid plans can use non-minimal landing page themes + if (data.siteName) { + const exists = await checkSiteNameExists(data.siteName, orgId); // exclude current org + if (exists) { + throw new AppError('Site name already exists', ErrorCodes.SITENAME_EXISTS, 409, 'siteName'); + } + } + if (data.landingpage && typeof data.landingpage === 'object') { const landingpage = data.landingpage as Record; const theme = landingpage.theme; diff --git a/apps/dashboard/src/lib/features/onboarding/api/onboarding.svelte.ts b/apps/dashboard/src/lib/features/onboarding/api/onboarding.svelte.ts index 5d47f57a..a9382d23 100644 --- a/apps/dashboard/src/lib/features/onboarding/api/onboarding.svelte.ts +++ b/apps/dashboard/src/lib/features/onboarding/api/onboarding.svelte.ts @@ -55,8 +55,19 @@ export class OnboardingApi extends BaseApiWithErrors { return; } - // map api validation back to frontend fields if ('error' in result) { + // Specific handling for siteName / orgName + if (result.code === 'SITENAME_EXISTS' || result.field === 'siteName') { + this.errors = { ...this.errors, siteName: result.error }; + return; + } + + if (result.code === 'ORGNAME_EXISTS' || result.field === 'orgName') { + this.errors = { ...this.errors, orgName: result.error }; + return; + } + + // Fallback to generic this.handleValidationError(result); } } diff --git a/apps/dashboard/src/lib/features/org/api/org.svelte.ts b/apps/dashboard/src/lib/features/org/api/org.svelte.ts index 1675a55f..f352aeef 100644 --- a/apps/dashboard/src/lib/features/org/api/org.svelte.ts +++ b/apps/dashboard/src/lib/features/org/api/org.svelte.ts @@ -44,6 +44,7 @@ import { snackbar } from '$features/ui/snackbar/store'; import { t } from '$lib/utils/functions/translations'; import { uploadImage } from '$lib/utils/services/upload'; import { DEFAULT_ORG_AUDIENCE_QUERY, toAudienceRequestQuery } from '../utils/audience-query-utils'; +import type { ZodError } from 'zod'; export interface TOrgUpdateForm { name?: string; @@ -309,11 +310,8 @@ class OrgApi extends BaseApiWithErrors { snackbar.error(result); return; } - if ('error' in result && 'field' in result) { - // Field-specific error automatically mapped to this.errors[field] - this.errors[result.field as string] = result.error; - } else if ('error' in result) { - this.errors.general = result.error; + if ('error' in result) { + this.handleValidationError(result); } } }); @@ -340,6 +338,11 @@ class OrgApi extends BaseApiWithErrors { if (!result.success) { this.errors = mapZodErrorsToTranslations(result.error, 'organization'); + // Show the first validation error to the user + const firstError = Object.values(this.errors)[0]; + if (firstError) { + snackbar.error(firstError); + } return; } @@ -423,9 +426,41 @@ class OrgApi extends BaseApiWithErrors { onError: (error) => { console.error('Error updating organization:', error); - const message = error instanceof Error ? error.message : `${error}`; - this.errors.general = message; + if (typeof error === 'object' && error !== null && 'error' in error) { + const apiError = error as { + success: false; + error: string; + code?: string; + field?: string; + }; + if (apiError.field === 'siteName' || apiError.code === 'SITENAME_EXISTS') { + const message = typeof apiError.error === 'string' ? apiError.error : 'Site name already exists'; + this.errors = { ...this.errors, siteName: message }; + snackbar.error(message); + return; + } + + if (apiError.field && typeof apiError.error === 'string') { + this.errors = { ...this.errors, [apiError.field]: apiError.error }; + snackbar.error(apiError.error); + return; + } + + if (typeof apiError.error === 'string') { + this.errors = { ...this.errors, general: apiError.error }; + snackbar.error(apiError.error); + return; + } + + this.errors = { ...this.errors, general: t.get('snackbar.update_failed') }; + snackbar.error(t.get('snackbar.update_failed')); + return; + } + + const message = + error instanceof Error ? error.message : typeof error === 'string' ? error : t.get('snackbar.update_failed'); + this.errors = { ...this.errors, general: message }; snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`); } }); diff --git a/apps/dashboard/src/lib/features/settings/pages/domains.svelte b/apps/dashboard/src/lib/features/settings/pages/domains.svelte index d0a86304..217bc4eb 100644 --- a/apps/dashboard/src/lib/features/settings/pages/domains.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/domains.svelte @@ -141,6 +141,8 @@ if (orgApi.success) { $currentOrg.siteName = siteName; goto(`/org/${$currentOrg.siteName}/settings/domains`); + } else if (orgApi.errors.siteName) { + errors.siteName = orgApi.errors.siteName; } else if (orgApi.errors.general) { errors.siteName = orgApi.errors.general; } else { diff --git a/apps/dashboard/src/lib/utils/services/api/base.svelte.ts b/apps/dashboard/src/lib/utils/services/api/base.svelte.ts index 14418ed4..c30f7ea6 100644 --- a/apps/dashboard/src/lib/utils/services/api/base.svelte.ts +++ b/apps/dashboard/src/lib/utils/services/api/base.svelte.ts @@ -126,15 +126,25 @@ export class BaseApi { export class BaseApiWithErrors extends BaseApi { errors = $state>({}); - handleValidationError(result: { success: false; error: string; code: string; field?: string }): Promise | void { + handleValidationError(result: { + success: false; + error: string; + code?: string; + field?: string; + }): Promise | void { const zodValidationError = result.error as unknown as ZodValidationError; - if (zodValidationError.name === 'ZodError') { + + // Handle Zod validation errors + if (zodValidationError && typeof zodValidationError === 'object' && zodValidationError.name === 'ZodError') { const issues = JSON.parse(zodValidationError.message) as ZodValidatorMesssage; - this.errors = mapZodErrorsToTranslations(new ZodError(issues)); - return; } + + // Generic fallback only — no field-specific mapping here + if (typeof result.error === 'string') { + this.errors = { ...this.errors, general: result.error }; + } } override resetErrors() { diff --git a/apps/dashboard/src/routes/(auth)/onboarding/+page.svelte b/apps/dashboard/src/routes/(auth)/onboarding/+page.svelte index 4730aca4..6dd0a87c 100644 --- a/apps/dashboard/src/routes/(auth)/onboarding/+page.svelte +++ b/apps/dashboard/src/routes/(auth)/onboarding/+page.svelte @@ -102,6 +102,7 @@ suffix=".classroomio.com" oninput={() => { isSiteNameTouched = true; + onboardingApi.errors.siteName = ''; }} /> {#if onboardingApi.errors.siteName} diff --git a/packages/db/src/queries/organization/organization.ts b/packages/db/src/queries/organization/organization.ts index d76b1519..5ed0f521 100644 --- a/packages/db/src/queries/organization/organization.ts +++ b/packages/db/src/queries/organization/organization.ts @@ -19,6 +19,8 @@ import { ilike, inArray, isNotNull, + ne, + not, notInArray, or, sql @@ -214,15 +216,19 @@ export async function insertOrganizationMembersOnConflictDoNothing( } } -export const checkSiteNameExists = async (siteName: string): Promise => { +export const checkSiteNameExists = async (siteName: string, excludeOrgId?: string): Promise => { + const conditions = [eq(schema.organization.siteName, siteName)]; + + if (excludeOrgId) { + conditions.push(ne(schema.organization.id, excludeOrgId)); + } + const result = await db .select({ id: schema.organization.id }) .from(schema.organization) - .where(eq(schema.organization.siteName, siteName)) + .where(and(...conditions)) .limit(1); - console.debug('checkSiteNameExists result:', result); - return result.length > 0; };