feat: migrate auth from supabase to better auth (#596)
* feat: add README for @cio/db package and remove existingAccounts.json * load session, load profile and organization, add create profile hook * chore: cache session request The trick was passing all the cookies to the backend. Previously I was only passing the better-auth.session_token and not better-auth.session_data. Read about the difference here https://www.better-auth.com/docs/concepts/cookies#custom-cookies * feat: login, logout, onboarding, verify email, reset password. * feat: complete change email and email verification * feat: standardise how we make rpc calls and create org endpoints * fix ui errors * fix: handle potential undefined customerId and standardize avatar URL property * refactor: standardize custom domain handling and update telegram chat ID references * chore: add tailwind prefix to @cio/ui this fixed the media query issues because @cio/ui classes were overriding this dashboard tailwind build * chore: clean up org sidebar * chore: persist mode state * feat: implement order breadcrumbs * chore: upgrade to better auth 1.4 * chore: address common auth flow bugs and setup app for others to setup locally.
This commit is contained in:
@@ -5,16 +5,7 @@ export const SIGNUP_FIELDS = {
|
||||
confirmPassword: ''
|
||||
};
|
||||
|
||||
export const RESET_FIELDS = {
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
};
|
||||
|
||||
export const LOGIN_FIELDS = {
|
||||
email: '',
|
||||
password: ''
|
||||
};
|
||||
|
||||
export const FORGOT_PASSWORD_FIELDS = {
|
||||
email: ''
|
||||
};
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export const ROLE = {
|
||||
ADMIN: 1,
|
||||
TUTOR: 2,
|
||||
STUDENT: 3
|
||||
};
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
export const ROLE_LABEL = {
|
||||
[ROLE.ADMIN]: 'course.navItem.people.roles.admin',
|
||||
|
||||
@@ -15,11 +15,12 @@ export const ROUTE = {
|
||||
DISCUSSIONS: '/discussions',
|
||||
ASK: '/ask',
|
||||
FORGOT: '/forgot',
|
||||
RESET: '/reset'
|
||||
RESET: '/reset',
|
||||
LOGOUT: '/logout'
|
||||
};
|
||||
|
||||
export const PUBLIC_ROUTES = [
|
||||
`^${ROUTE.HOME}$`,
|
||||
// `^${ROUTE.HOME}$`,
|
||||
ROUTE.LOGIN,
|
||||
ROUTE.SIGN_UP,
|
||||
`^${ROUTE.INVITE_TEACHER}/.*`,
|
||||
@@ -30,6 +31,15 @@ export const PUBLIC_ROUTES = [
|
||||
'/404'
|
||||
];
|
||||
|
||||
export const PUBLIC_API_ROUTES = [
|
||||
'/api/completion',
|
||||
'student_prove_payment',
|
||||
'teacher_student_buycourse',
|
||||
'/api/polar',
|
||||
'/api/lmz',
|
||||
'/api/verify'
|
||||
];
|
||||
|
||||
export const ROUTES_TO_HIDE_NAV = [
|
||||
`^${ROUTE.LOGIN}$`,
|
||||
`^${ROUTE.SIGN_UP}$`,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { LOCALE } from '../types';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
|
||||
export const LANGUAGE = {
|
||||
[LOCALE.DE]: 'German',
|
||||
[LOCALE.EN]: 'English',
|
||||
[LOCALE.ES]: 'Spanish',
|
||||
[LOCALE.FR]: 'French',
|
||||
[LOCALE.HI]: 'Hindi',
|
||||
[LOCALE.PL]: 'Polish',
|
||||
[LOCALE.PT]: 'Portuguese',
|
||||
[LOCALE.RU]: 'Russian',
|
||||
[LOCALE.VI]: 'Vietnamese',
|
||||
[LOCALE.DA]: 'Danish'
|
||||
export const LANGUAGE: Record<TLocale, string> = {
|
||||
da: 'Danish',
|
||||
de: 'German',
|
||||
en: 'English',
|
||||
es: 'Spanish',
|
||||
fr: 'French',
|
||||
hi: 'Hindi',
|
||||
pl: 'Polish',
|
||||
pt: 'Portuguese',
|
||||
ru: 'Russian',
|
||||
vi: 'Vietnamese'
|
||||
};
|
||||
|
||||
export const LANGUAGES = Object.keys(LANGUAGE).map((lang) => ({
|
||||
|
||||
@@ -25,5 +25,20 @@ export function isQuizPage(path) {
|
||||
}
|
||||
|
||||
export function isLMSPage(path) {
|
||||
return /lms[\/a-z 0-9 -]*/.test(path);
|
||||
return /lms[/a-z 0-9 -]*/.test(path);
|
||||
}
|
||||
|
||||
export function isActive(pagePath: string, itemPath: string, exact: boolean = false) {
|
||||
const pageLinkItems = pagePath.split('/');
|
||||
const itemLinkItems = itemPath.split('/');
|
||||
|
||||
if (exact) {
|
||||
return pagePath === itemPath;
|
||||
}
|
||||
|
||||
if (itemLinkItems.length !== pageLinkItems.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pagePath.includes(itemPath);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { identifyPosthogUser, initPosthog } from '$lib/utils/services/posthog';
|
||||
import { initSentry, setSentryUser } from '$lib/utils/services/sentry';
|
||||
import { profile, user } from '$lib/utils/store/user';
|
||||
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import { ROUTE } from '$lib/utils/constants/routes';
|
||||
import { dev } from '$app/environment';
|
||||
import { get } from 'svelte/store';
|
||||
import { getOrganizations } from '$lib/utils/services/org';
|
||||
import { goto } from '$app/navigation';
|
||||
import { handleLocaleChange } from '$lib/utils/functions/translations';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import isPublicRoute from '$lib/utils/functions/routes/isPublicRoute';
|
||||
import { page } from '$app/stores';
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAuth';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { initPosthog } from '$lib/utils/services/posthog';
|
||||
import { initSentry } from '$lib/utils/services/sentry';
|
||||
|
||||
export function setupAnalytics() {
|
||||
// Set up sentry
|
||||
@@ -29,193 +14,3 @@ export function setupAnalytics() {
|
||||
localStorage.setItem('umami.disabled', '1');
|
||||
}
|
||||
}
|
||||
|
||||
function setAnalyticsUser() {
|
||||
const profileStore = get(profile);
|
||||
|
||||
if (!profileStore.id) return;
|
||||
|
||||
setSentryUser({
|
||||
id: profileStore.id,
|
||||
username: profileStore.username,
|
||||
email: profileStore.email,
|
||||
fullname: profileStore.fullname
|
||||
});
|
||||
|
||||
identifyPosthogUser(profileStore.id, {
|
||||
email: profileStore.email,
|
||||
name: profileStore.fullname
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProfile({
|
||||
path,
|
||||
queryParam,
|
||||
isOrgSite,
|
||||
orgSiteName
|
||||
}: {
|
||||
path: string;
|
||||
queryParam: string;
|
||||
isOrgSite: boolean;
|
||||
orgSiteName: string;
|
||||
}) {
|
||||
const pageStore = get(page);
|
||||
const profileStore = get(profile);
|
||||
const currentOrgStore = get(currentOrg);
|
||||
const currentOrgDomainStore = get(currentOrgDomain);
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// Get user profile
|
||||
const {
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession();
|
||||
const { user: authUser } = session || {};
|
||||
console.log('Get user', authUser);
|
||||
|
||||
if (!authUser && !isPublicRoute(pageStore.url?.pathname)) {
|
||||
return goto('/login?redirect=/' + path + queryParam);
|
||||
}
|
||||
|
||||
// Skip refetching profile, if already in store
|
||||
if (profileStore.id) {
|
||||
handleLocaleChange(profileStore.locale);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has profile
|
||||
const {
|
||||
data: profileData,
|
||||
error,
|
||||
status
|
||||
} = await supabase.from('profile').select(`*`).eq('id', authUser?.id).single();
|
||||
console.log('Get profile', profileData);
|
||||
|
||||
if (error && !profileData && status === 406 && authUser) {
|
||||
// User wasn't found, create profile
|
||||
console.log(`User wasn't found, create profile`);
|
||||
|
||||
const [regexUsernameMatch] = [...(authUser.email?.matchAll(/(.*)@/g) || [])];
|
||||
|
||||
const isGoogleAuth = !!authUser.app_metadata?.providers?.includes('google');
|
||||
|
||||
const { data: newProfileData, error } = await supabase
|
||||
.from('profile')
|
||||
.insert({
|
||||
id: authUser.id,
|
||||
username: regexUsernameMatch[1] + `${new Date().getTime()}`,
|
||||
fullname: regexUsernameMatch[1],
|
||||
email: authUser.email,
|
||||
is_email_verified: isGoogleAuth,
|
||||
verified_at: isGoogleAuth ? new Date().toDateString() : undefined
|
||||
})
|
||||
.select();
|
||||
|
||||
// Profile created, go to onboarding or lms
|
||||
if (!error && newProfileData) {
|
||||
user.update((_user) => ({
|
||||
..._user,
|
||||
fetchingUser: false,
|
||||
isLoggedIn: true,
|
||||
currentSession: authUser
|
||||
}));
|
||||
|
||||
profile.set(newProfileData[0]);
|
||||
|
||||
setAnalyticsUser();
|
||||
|
||||
// Fetch language
|
||||
handleLocaleChange(newProfileData[0].locale);
|
||||
|
||||
if (isOrgSite) {
|
||||
const { data, error } = await supabase
|
||||
.from('organizationmember')
|
||||
.insert({
|
||||
organization_id: currentOrgStore.id,
|
||||
profile_id: profileStore.id,
|
||||
role_id: 3
|
||||
})
|
||||
.select();
|
||||
if (error) {
|
||||
console.error('Error adding user to organisation', error);
|
||||
} else {
|
||||
console.log('Success adding user to organisation', data);
|
||||
const memberId = data?.[0]?.id || '';
|
||||
|
||||
currentOrg.update((_currentOrg) => ({
|
||||
..._currentOrg,
|
||||
memberId
|
||||
}));
|
||||
}
|
||||
|
||||
if (params.get('redirect')) {
|
||||
goto(params.get('redirect') || '');
|
||||
} else if (shouldRedirectOnAuth(path)) {
|
||||
goto('/lms');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// On invite page, don't go to onboarding
|
||||
if (!path.includes('invite')) {
|
||||
goto(ROUTE.ONBOARDING);
|
||||
}
|
||||
}
|
||||
|
||||
user.update((_user) => ({
|
||||
..._user,
|
||||
fetchingUser: false
|
||||
}));
|
||||
} else if (profileData) {
|
||||
// Profile exists, go to profile page
|
||||
user.update((_user) => ({
|
||||
..._user,
|
||||
fetchingUser: false,
|
||||
isLoggedIn: true,
|
||||
currentSession: authUser
|
||||
}));
|
||||
|
||||
profile.set(profileData);
|
||||
|
||||
// Set user in sentry
|
||||
setAnalyticsUser();
|
||||
|
||||
handleLocaleChange(profileData.locale);
|
||||
|
||||
const orgRes = await getOrganizations(profileData.id, isOrgSite, orgSiteName);
|
||||
|
||||
const isStudentAccount = orgRes.currentOrg.role_id == ROLE.STUDENT;
|
||||
|
||||
// student redirect
|
||||
if (isOrgSite) {
|
||||
if (params.has('redirect')) {
|
||||
goto(params.get('redirect') || '');
|
||||
} else if (shouldRedirectOnAuth(path)) {
|
||||
goto('/lms');
|
||||
}
|
||||
} else {
|
||||
if (isStudentAccount) {
|
||||
// Check if the student logged into the dashboard.
|
||||
console.log('Student logged into dashboard');
|
||||
if (dev) {
|
||||
goto('/lms');
|
||||
} else {
|
||||
window.location.replace(`${currentOrgDomainStore}/lms`);
|
||||
}
|
||||
} else if (isEmpty(orgRes.orgs) && !path.includes('invite')) {
|
||||
// Not on invite page or no org, go to onboarding
|
||||
goto(ROUTE.ONBOARDING);
|
||||
} else if (params.has('redirect')) {
|
||||
goto(params.get('redirect') || '');
|
||||
} else if (shouldRedirectOnAuth(path)) {
|
||||
// By default redirect to first organization
|
||||
goto(`/org/${orgRes.currentOrg.siteName}`);
|
||||
}
|
||||
}
|
||||
|
||||
setTheme(orgRes?.currentOrg?.theme);
|
||||
}
|
||||
|
||||
if (!profileData && !isPublicRoute(pageStore.url?.pathname)) {
|
||||
goto('/login?redirect=/' + path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import posthog from 'posthog-js';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { appInitApi } from '$lib/features/app/init.svelte';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { user, profile, defaultProfileState, defaultUserState } from '$lib/utils/store/user';
|
||||
import { goto } from '$app/navigation';
|
||||
import posthog from 'posthog-js';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
export async function logout(redirect = true) {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
const { error } = await authClient.signOut();
|
||||
|
||||
if (error) {
|
||||
console.error('Error logging out: ', error);
|
||||
}
|
||||
|
||||
user.set(defaultUserState);
|
||||
profile.set(defaultProfileState);
|
||||
appInitApi.reset();
|
||||
|
||||
capturePosthogEvent('user_logged_out');
|
||||
posthog.reset();
|
||||
|
||||
if (redirect) {
|
||||
goto('/login');
|
||||
goto(resolve('/login', {}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
export function genQuizPin(): number {
|
||||
const minm = 100000;
|
||||
@@ -20,5 +21,9 @@ export function openUpgradeModal() {
|
||||
|
||||
const searchParams = url.searchParams.toString();
|
||||
|
||||
goto(window.location.pathname + `?${searchParams}`);
|
||||
goto(resolve(window.location.pathname + `?${searchParams}`, {}));
|
||||
}
|
||||
|
||||
export function getShortOrgName(name: string) {
|
||||
return name?.substring(0, 2)?.toUpperCase() || '';
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { PUBLIC_ROUTES } from '$lib/utils/constants/routes';
|
||||
import { PUBLIC_API_ROUTES, PUBLIC_ROUTES } from '$lib/utils/constants/routes';
|
||||
|
||||
export default (route = '') => {
|
||||
export const isPublicRoute = (route = '') => {
|
||||
return PUBLIC_ROUTES.some((publicRoute) => {
|
||||
const regex = new RegExp(publicRoute, 'g');
|
||||
return regex.test(route);
|
||||
});
|
||||
};
|
||||
|
||||
export default isPublicRoute;
|
||||
|
||||
export const isPublicApiRoute = (pathname = '') => {
|
||||
return PUBLIC_API_ROUTES.some((route) => pathname.includes(route));
|
||||
};
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export default (path: string): boolean => {
|
||||
return ['login', 'signup', 'onboarding'].some((r) => path.includes(r)) || path === '';
|
||||
return ['login', 'signup', 'onboarding'].some((r) => path.includes(r)) || path === '/';
|
||||
};
|
||||
|
||||
@@ -11,6 +11,8 @@ export const getSupabase = () => {
|
||||
return supabase;
|
||||
};
|
||||
|
||||
getSupabase();
|
||||
|
||||
export const hasSession = async () => {
|
||||
const { data } = await getSupabase().auth.getSession();
|
||||
console.log('has session', data);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LOCALE } from '$lib/utils/types';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import i18n from '@sveltekit-i18n/base';
|
||||
import parser from '@sveltekit-i18n/parser-icu';
|
||||
import { writable } from 'svelte/store';
|
||||
@@ -72,7 +72,7 @@ loading.subscribe(async ($loading) => {
|
||||
}
|
||||
});
|
||||
|
||||
export function handleLocaleChange(newLocale: LOCALE) {
|
||||
export function handleLocaleChange(newLocale: TLocale) {
|
||||
if (!newLocale) {
|
||||
return;
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function handleLocaleChange(newLocale: LOCALE) {
|
||||
selectedLocale.set(newLocale);
|
||||
}
|
||||
|
||||
export function lessonFallbackNote(note: string, translation: Record<LOCALE, string>, locale: LOCALE) {
|
||||
export function lessonFallbackNote(note: string, translation: Record<TLocale, string>, locale: TLocale) {
|
||||
if (!translation) {
|
||||
return note;
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export function lessonFallbackNote(note: string, translation: Record<LOCALE, str
|
||||
const content = translation[locale];
|
||||
|
||||
// if locale is english and no translated content for english but note exists
|
||||
if (locale === LOCALE.EN && !content && note?.length) {
|
||||
if (locale === 'en' && !content && note?.length) {
|
||||
return note;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import z from 'zod';
|
||||
import { get } from 'svelte/store';
|
||||
import isNumber from 'lodash/isNumber';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { validateEmail } from './validateEmail';
|
||||
import { t, initialized } from '$lib/utils/functions/translations';
|
||||
import z from 'zod';
|
||||
|
||||
function getOrgNameValidation() {
|
||||
return z
|
||||
@@ -55,7 +54,7 @@ export const processErrors = (error, mapToId?: boolean) => {
|
||||
|
||||
if (mapToId) {
|
||||
let value = '';
|
||||
path.forEach((p, i) => {
|
||||
path.forEach((p) => {
|
||||
const formatP = isNumber(p) ? `[${p}]` : p;
|
||||
|
||||
value += !value ? formatP : `.${formatP}`;
|
||||
@@ -113,28 +112,6 @@ export const coursePaymentValidation = (fields = {}) => {
|
||||
return processErrors(error);
|
||||
};
|
||||
|
||||
export const resetValidation = (fields = {}) => {
|
||||
const schema = z.object({
|
||||
password: z.string().min(6, {
|
||||
message: `${t.get('validations.reset.password.min_char')}`
|
||||
})
|
||||
});
|
||||
const { error } = schema.safeParse(fields);
|
||||
|
||||
return processErrors(error);
|
||||
};
|
||||
|
||||
export const forgotValidation = (fields = {}) => {
|
||||
const schema = z.object({
|
||||
email: z.string().email({
|
||||
message: `${t.get('validations.forgot.invalid_email')}`
|
||||
})
|
||||
});
|
||||
const { error } = schema.safeParse(fields);
|
||||
|
||||
return processErrors(error);
|
||||
};
|
||||
|
||||
export const orgLandingpageValidation = (fields = {}) => {
|
||||
const schema = z.object({
|
||||
name: z.string().min(6, {
|
||||
@@ -155,58 +132,6 @@ export const orgLandingpageValidation = (fields = {}) => {
|
||||
return processErrors(error);
|
||||
};
|
||||
|
||||
export const onboardingValidation = (fields = {}, step) => {
|
||||
const onboardingValidationSchema = {
|
||||
stepOne: z.object({
|
||||
fullname: z.string().min(5, { message: `${t.get('validations.onboarding.step_one.full_name.min_char')}` }),
|
||||
orgName: getOrgNameValidation(),
|
||||
siteName: getSiteNameValidation()
|
||||
}),
|
||||
stepTwo: z.object({
|
||||
goal: z
|
||||
.string({
|
||||
required_error: `${t.get('validations.onboarding.step_two.goal.required')}`
|
||||
})
|
||||
.min(1),
|
||||
source: z
|
||||
.string({
|
||||
required_error: `${t.get('validations.onboarding.step_two.source.required')}`
|
||||
})
|
||||
.min(1)
|
||||
})
|
||||
};
|
||||
|
||||
const schema = step === 1 ? onboardingValidationSchema.stepOne : onboardingValidationSchema.stepTwo;
|
||||
const { error } = schema.safeParse(fields);
|
||||
|
||||
return processErrors(error);
|
||||
};
|
||||
|
||||
export const updateProfileValidation = (fields = {}) => {
|
||||
const schema = z.object({
|
||||
email: z.string().email({ message: 'validations.user_profile.email' }),
|
||||
username: z.string().nonempty({ message: 'validations.user_profile.username' }),
|
||||
fullname: z.string().min(5, { message: 'validations.user_profile.fullname' })
|
||||
});
|
||||
const { error } = schema.safeParse(fields);
|
||||
|
||||
return processErrors(error);
|
||||
};
|
||||
|
||||
// export const createTemplateExerciseValidation = (fields = {}) => {
|
||||
// const schema = z.object({
|
||||
// orgName: z.string().min(5, {
|
||||
// message: 'Must be 5 or more characters long',
|
||||
// }),
|
||||
// siteName: z.string().min(5, {
|
||||
// message: 'Must be 5 or more characters long',
|
||||
// })
|
||||
// });
|
||||
// const { error } = schema.safeParse(fields);
|
||||
|
||||
// return processErrors(error);
|
||||
// };
|
||||
|
||||
export const createNewsfeedValidation = (newPost) => {
|
||||
const schema = z.object({
|
||||
newPost: getNewsfeedValidation()
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { getErrMsg } from './utils';
|
||||
import type { InferResponseType } from '@cio/api/rpc-types';
|
||||
import { ApiError, type ZodValidationError, type ZodValidatorMesssage } from './types';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation/validation';
|
||||
import { ZodError } from 'zod/v4';
|
||||
|
||||
// Extract the data type from a success response
|
||||
type ExtractData<T> = Extract<InferResponseType<T>, { success: true }>;
|
||||
type ExtractError<T> = Extract<InferResponseType<T>, { success: false }>;
|
||||
|
||||
interface ExecuteOptions<TrpcType> {
|
||||
requestFn: () => Promise<Response>;
|
||||
errorMessage?: string;
|
||||
onSuccess?: (data: ExtractData<TrpcType>) => Promise<void> | void;
|
||||
onError?: (error: ExtractError<TrpcType> | string) => Promise<void> | void;
|
||||
logContext?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base API class that provides shared state management and request handling
|
||||
* for all API classes in the application.
|
||||
*/
|
||||
export class BaseApi {
|
||||
isLoading = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
|
||||
/**
|
||||
* Execute an API request with automatic loading and error state management
|
||||
* Type is automatically inferred from the RPC call (e.g., classroomio.organization.plan.$post)
|
||||
* @param options Configuration for the request
|
||||
* @returns The data from the response, or the default return value on error
|
||||
*/
|
||||
protected async execute<TrpcType>(options: ExecuteOptions<TrpcType>): Promise<ExtractData<TrpcType> | undefined> {
|
||||
const { requestFn, onSuccess, logContext = 'API request' } = options;
|
||||
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await requestFn();
|
||||
const result = (await response.json()) as unknown;
|
||||
|
||||
if (typeof result === 'object' && result !== null && 'success' in result) {
|
||||
const apiResult = result as { success: boolean; data?: unknown; error?: string; message?: string };
|
||||
|
||||
if (apiResult.success === false) {
|
||||
console.log('apiResult false', apiResult);
|
||||
options.onError?.(apiResult as ExtractError<TrpcType>);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const typedResult = apiResult as ExtractData<TrpcType>;
|
||||
|
||||
this.resetErrors();
|
||||
|
||||
if (onSuccess) {
|
||||
await onSuccess(typedResult);
|
||||
}
|
||||
|
||||
return typedResult;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error in ${logContext}:`, error);
|
||||
this.error = getErrMsg(error, `Error ${logContext}:`);
|
||||
|
||||
const errorResult = this.getServerError<ExtractError<TrpcType>>(error);
|
||||
options.onError?.(errorResult);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
getServerError<T>(error: unknown): T | string {
|
||||
if (error instanceof ApiError) {
|
||||
console.log('instance of ApiError', error);
|
||||
try {
|
||||
const json = JSON.parse(error.message) as T;
|
||||
return json;
|
||||
} catch (e) {
|
||||
console.log('error parsing json', e);
|
||||
return error.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
|
||||
return `${error}`;
|
||||
}
|
||||
|
||||
resetErrors() {
|
||||
this.error = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the API state
|
||||
*/
|
||||
reset() {
|
||||
this.isLoading = false;
|
||||
this.error = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended base API class that supports errors object (Record<string, string>)
|
||||
* for form validation scenarios
|
||||
*/
|
||||
export class BaseApiWithErrors extends BaseApi {
|
||||
errors = $state<Record<string, string>>({});
|
||||
success = $state(false);
|
||||
|
||||
handleValidationError(result: { success: false; error: string; code: string; field?: string }): Promise<void> | void {
|
||||
const zodValidationError = result.error as unknown as ZodValidationError;
|
||||
if (zodValidationError.name === 'ZodError') {
|
||||
const issues = JSON.parse(zodValidationError.message) as ZodValidatorMesssage;
|
||||
|
||||
this.errors = mapZodErrorsToTranslations(new ZodError(issues));
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
override resetErrors() {
|
||||
this.errors = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the API state including errors and success
|
||||
*/
|
||||
override reset() {
|
||||
super.reset();
|
||||
this.errors = {};
|
||||
this.success = false;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { type ApiClientConfig, ApiError, type RequestConfig } from './types';
|
||||
import { DEFAULT_CONFIG } from './constants';
|
||||
import { delay } from './utils';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { getAccessToken } from '$lib/utils/functions/supabase';
|
||||
import { hcWithType } from '@cio/api/rpc-types';
|
||||
|
||||
class ApiClient {
|
||||
@@ -14,13 +13,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
private async makeRequest(input: RequestInfo | URL, requestConfig: RequestConfig = {}): Promise<Response> {
|
||||
console.log('makeRequest', input, requestConfig);
|
||||
const {
|
||||
timeout = this.config.timeout,
|
||||
retries = this.config.retries,
|
||||
skipAuth = false,
|
||||
...fetchConfig
|
||||
} = requestConfig;
|
||||
const { timeout = this.config.timeout, retries = this.config.retries, ...fetchConfig } = requestConfig;
|
||||
|
||||
const url = typeof input === 'string' ? input : input.toString();
|
||||
const fullUrl = url.startsWith('http') ? url : `${this.config.baseURL}${url}`;
|
||||
@@ -48,19 +41,6 @@ class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Add authentication if not skipped
|
||||
if (!skipAuth) {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to get access token:', error);
|
||||
// Continue without auth if token retrieval fails
|
||||
}
|
||||
}
|
||||
|
||||
// Create abort controller for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
@@ -94,16 +74,11 @@ class ApiClient {
|
||||
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
throw new ApiError(`Client error: ${errorText}`, response.status, response.statusText, response);
|
||||
throw new ApiError(errorText, response.status, response.statusText, response);
|
||||
}
|
||||
|
||||
if (response.status >= 500) {
|
||||
const error = new ApiError(
|
||||
`Server error: ${response.statusText}`,
|
||||
response.status,
|
||||
response.statusText,
|
||||
response
|
||||
);
|
||||
const error = new ApiError(response.statusText, response.status, response.statusText, response);
|
||||
|
||||
if (attempt === retries) {
|
||||
throw error;
|
||||
@@ -165,6 +140,9 @@ export const apiClient = new ApiClient();
|
||||
export const classroomio = hcWithType(env.PUBLIC_SERVER_URL, {
|
||||
fetch: async (input: RequestInfo | URL, requestInit?: RequestInit) => {
|
||||
return apiClient.request(input, requestInit);
|
||||
},
|
||||
init: {
|
||||
credentials: 'include' // Required for sending cookies cross-origin
|
||||
}
|
||||
});
|
||||
|
||||
@@ -188,3 +166,6 @@ export {
|
||||
} from './utils';
|
||||
|
||||
export type { InferResponseType, InferRequestType } from '@cio/api/rpc-types';
|
||||
|
||||
// Export base API classes
|
||||
export { BaseApi, BaseApiWithErrors } from './base.svelte';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Types for better type safety
|
||||
import type { ZodError } from 'zod';
|
||||
|
||||
export interface ApiClientConfig {
|
||||
baseURL?: string;
|
||||
timeout?: number;
|
||||
@@ -10,6 +11,13 @@ export interface ApiClientConfig {
|
||||
onResponse?: (response: Response) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export type ZodValidationError = {
|
||||
message: string; // stringified error.issues from zod
|
||||
name: 'ZodError';
|
||||
};
|
||||
|
||||
export type ZodValidatorMesssage = ZodError['issues'];
|
||||
|
||||
export interface RequestConfig extends RequestInit {
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
|
||||
@@ -3,6 +3,23 @@ import { ApiError } from './types';
|
||||
// Utility function for exponential backoff
|
||||
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export const getErrMsg = (error: unknown, fallback = 'Something went wrong'): string => {
|
||||
if (error instanceof ApiError) {
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error;
|
||||
}
|
||||
if (error instanceof Object && 'message' in error) {
|
||||
return error.message as string;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
};
|
||||
|
||||
// Helper function to handle JSON responses
|
||||
export const handleJsonResponse = async <T = any>(response: Response): Promise<T> => {
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createAuthClient } from 'better-auth/svelte';
|
||||
import { env } from '$env/dynamic/public';
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: env.PUBLIC_SERVER_URL,
|
||||
fetchOptions: {
|
||||
credentials: 'include' // Include cookies in requests
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import { authClient } from './client';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
export const getSessionData = async (cookies: Cookies): Promise<App.Locals | null> => {
|
||||
try {
|
||||
const cioCookies = cookies
|
||||
.getAll()
|
||||
.filter((c) => c.name.includes('classroomio'))
|
||||
.map((c) => `${c.name}=${c.value}`)
|
||||
.join('; ');
|
||||
|
||||
if (!cioCookies) return null;
|
||||
|
||||
const locals = await getThroughAuthClient(cioCookies);
|
||||
|
||||
if (!locals) return null;
|
||||
|
||||
locals.fromSessions = !!cookies.get('classroomio.session_data');
|
||||
|
||||
return locals;
|
||||
} catch (error) {
|
||||
console.error('Session verification failed:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export async function getThroughTrpc(allCookies: string) {
|
||||
const session = await classroomio.session.$get(undefined, {
|
||||
headers: {
|
||||
cookie: allCookies
|
||||
}
|
||||
});
|
||||
|
||||
const data = (await session.json()) as App.Locals;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function getThroughAuthClient(allCookies: string) {
|
||||
const session = await authClient.getSession({
|
||||
fetchOptions: {
|
||||
headers: {
|
||||
cookie: allCookies
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return session.data as App.Locals | null;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { getAccessToken } from '$lib/utils/functions/supabase';
|
||||
|
||||
export const NOTIFICATION_NAME = {
|
||||
WELCOME_TO_APP: 'WELCOME TO APP',
|
||||
VERIFY_EMAIL: 'VERFIY_EMAIL',
|
||||
INVITE_TEACHER: 'INVITE TEACHER',
|
||||
WELCOME_TEACHER_TO_COURSE: 'WELCOME TEACHER TO COURSE',
|
||||
SEND_TEACHER_STUDENT_BUY_REQUEST: 'SEND TEACHER STUDENT BUY REQUEST',
|
||||
@@ -15,8 +13,6 @@ export const NOTIFICATION_NAME = {
|
||||
};
|
||||
|
||||
const NAME_TO_PATH = {
|
||||
[NOTIFICATION_NAME.WELCOME_TO_APP]: '/api/email/welcome',
|
||||
[NOTIFICATION_NAME.VERIFY_EMAIL]: '/api/email/verify_email',
|
||||
[NOTIFICATION_NAME.INVITE_TEACHER]: '/api/email/invite',
|
||||
[NOTIFICATION_NAME.WELCOME_TEACHER_TO_COURSE]: '/api/email/course/teacher_welcome',
|
||||
[NOTIFICATION_NAME.SEND_TEACHER_STUDENT_BUY_REQUEST]: '/api/email/course/teacher_student_buycourse',
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { CurrentOrg, OrgTeamMember } from '$lib/utils/types/org';
|
||||
import { ROLE, ROLE_LABEL } from '$lib/utils/constants/roles';
|
||||
import { currentOrg, orgAudience, orgTeam, orgs } from '$lib/utils/store/org';
|
||||
|
||||
import type { OrganizationPlan } from '$lib/utils/types';
|
||||
import type { PostgrestError } from '@supabase/supabase-js';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { ROLE_LABEL } from '$lib/utils/constants/roles';
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
|
||||
@@ -1,46 +1,56 @@
|
||||
import type { CurrentOrg, OrgAudience, OrgTeamMember } from '../types/org';
|
||||
import type { OrgAudience, OrgTeamMember } from '../types/org';
|
||||
import { browser, dev } from '$app/environment';
|
||||
import { derived, writable } from 'svelte/store';
|
||||
|
||||
import type { AccountOrg } from '$lib/features/app/types';
|
||||
import { PLAN } from '@cio/utils/plans';
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { STEPS } from '../constants/quiz';
|
||||
import type { UserLessonDataType } from '$lib/utils/types';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
// Trigger build
|
||||
export const defaultCurrentOrgState: CurrentOrg = {
|
||||
id: '',
|
||||
name: '',
|
||||
shortName: '',
|
||||
siteName: '',
|
||||
avatar_url: '',
|
||||
memberId: '',
|
||||
role_id: 0,
|
||||
landingpage: {},
|
||||
export const defaultCurrentOrgState: AccountOrg = {
|
||||
avatarUrl: '',
|
||||
createdAt: '',
|
||||
customCode: '',
|
||||
customDomain: '',
|
||||
customization: {
|
||||
apps: { poll: true, comments: true },
|
||||
course: { grading: true, newsfeed: true },
|
||||
dashboard: { exercise: true, community: true, bannerText: '', bannerImage: '' }
|
||||
},
|
||||
theme: '',
|
||||
organization_plan: [],
|
||||
is_restricted: false
|
||||
favicon: '',
|
||||
id: '',
|
||||
isCustomDomainVerified: false,
|
||||
isRestricted: false,
|
||||
landingpage: {},
|
||||
name: '',
|
||||
plans: [],
|
||||
roleId: 0,
|
||||
settings: {},
|
||||
siteName: '',
|
||||
theme: ''
|
||||
};
|
||||
|
||||
export const orgs = writable<CurrentOrg[]>([]);
|
||||
export const currentOrg: Writable<CurrentOrg> = writable(defaultCurrentOrgState);
|
||||
export const orgs = writable<AccountOrg[]>([]);
|
||||
|
||||
export const currentOrg: Writable<AccountOrg> = writable(defaultCurrentOrgState);
|
||||
export const orgAudience = writable<OrgAudience[]>([]);
|
||||
export const orgTeam = writable<OrgTeamMember[]>([]);
|
||||
export const isOrgAdmin = derived(currentOrg, ($currentOrg) => {
|
||||
if ($currentOrg.role_id === 0) return null;
|
||||
if ($currentOrg.roleId === 0) return null;
|
||||
|
||||
return $currentOrg.role_id === ROLE.ADMIN;
|
||||
return $currentOrg.roleId === ROLE.ADMIN;
|
||||
});
|
||||
export const isOrgStudent = derived(currentOrg, ($currentOrg) => {
|
||||
if ($currentOrg.roleId === 0) return null;
|
||||
|
||||
return $currentOrg.roleId === ROLE.STUDENT;
|
||||
});
|
||||
|
||||
const getActivePlan = (org: CurrentOrg) => {
|
||||
return org.organization_plan.find((p) => p.is_active);
|
||||
const getActivePlan = (org: AccountOrg) => {
|
||||
return org.plans.find((p) => p.isActive);
|
||||
};
|
||||
|
||||
export const currentOrgPlan = derived(currentOrg, ($currentOrg) => getActivePlan($currentOrg));
|
||||
@@ -78,15 +88,15 @@ export const isFreePlan = derived(currentOrg, ($currentOrg) => {
|
||||
|
||||
const plan = getActivePlan($currentOrg);
|
||||
|
||||
return !plan || plan.plan_name === PLAN.BASIC;
|
||||
return !plan || plan.planName === PLAN.BASIC;
|
||||
});
|
||||
|
||||
export const currentOrgMaxAudience = derived(currentOrgPlan, ($plan) =>
|
||||
!$plan
|
||||
? 20
|
||||
: $plan.plan_name === PLAN.EARLY_ADOPTER
|
||||
: $plan.planName === PLAN.EARLY_ADOPTER
|
||||
? 10000
|
||||
: $plan.plan_name === PLAN.ENTERPRISE
|
||||
: $plan.planName === PLAN.ENTERPRISE
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: 20
|
||||
);
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
import { LOCALE } from '../types';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
import type { TProfile, TUser } from '@cio/db/types';
|
||||
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
interface UserStore {
|
||||
openAuthModal: boolean;
|
||||
fetchingUser: boolean;
|
||||
isLoggedIn: boolean;
|
||||
currentSession: User | undefined;
|
||||
currentSession: TUser | undefined;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface ProfileStore {
|
||||
// Using TProfile type from @cio/utils/types with optional id for store initialization
|
||||
export type ProfileStore = Omit<TProfile, 'id'> & {
|
||||
id: string | undefined;
|
||||
fullname: string;
|
||||
avatar_url: string;
|
||||
profile_url?: string;
|
||||
username: string;
|
||||
user_id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
goal: string;
|
||||
source: string;
|
||||
telegram_chat_id: number | null;
|
||||
locale: LOCALE;
|
||||
is_email_verified: boolean;
|
||||
verified_at: string;
|
||||
}
|
||||
};
|
||||
|
||||
export const defaultUserState: UserStore = {
|
||||
openAuthModal: false,
|
||||
@@ -35,22 +23,26 @@ export const defaultUserState: UserStore = {
|
||||
expiresAt: 0
|
||||
};
|
||||
|
||||
export const defaultProfileState: ProfileStore = {
|
||||
id: undefined,
|
||||
export const defaultProfileState: TProfile = {
|
||||
id: '',
|
||||
fullname: '',
|
||||
avatar_url: '',
|
||||
avatarUrl: '',
|
||||
username: '',
|
||||
user_id: '',
|
||||
email: '',
|
||||
role: '',
|
||||
goal: '',
|
||||
source: '',
|
||||
telegram_chat_id: null,
|
||||
locale: LOCALE.EN,
|
||||
is_email_verified: false,
|
||||
verified_at: ''
|
||||
email: null,
|
||||
role: null,
|
||||
goal: null,
|
||||
source: null,
|
||||
telegramChatId: null,
|
||||
locale: 'en',
|
||||
isEmailVerified: false,
|
||||
verifiedAt: null,
|
||||
canAddCourse: true,
|
||||
isRestricted: false,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
metadata: null
|
||||
};
|
||||
|
||||
export const user = writable<UserStore>(defaultUserState);
|
||||
|
||||
export const profile = writable<ProfileStore>(defaultProfileState);
|
||||
export const profile = writable<TProfile>(defaultProfileState);
|
||||
|
||||
@@ -99,6 +99,21 @@
|
||||
"email": "Ugyldig e -mail -adresse",
|
||||
"username": "Brugernavn kan ikke være tomt",
|
||||
"fullname": "Navnet skal være mindst 5 tegn"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Ugyldig type{forventet, vælg, udefineret{} anden{. Forventet {expected}}}{received, select, undefined{} other{ (received {received})}}",
|
||||
"too_big": "Værdien er for stor{maksimum, vælg, udefineret{} andet{. Maksimum {maksimum}}}",
|
||||
"too_small": "Værdien er for lille{minimum, vælg, udefineret{} andet{. Minimum {minimum}}}",
|
||||
"invalid_format": "Ugyldigt format{validering, vælg, udefineret{} andet{ for {validation}}}",
|
||||
"not_multiple_of": "Værdien skal være et multiplum af {multipleOf}",
|
||||
"unrecognized_keys": "Indeholder ikke-genkendte nøgler{nøgler, vælg, udefineret{} andet{: {nøgler}}}",
|
||||
"invalid_union": "Ugyldig værdi for angivne muligheder{unionErrors, select, undefined{} other{. {unionErrors} valgmulighed(er) mislykkedes}}",
|
||||
"invalid_key": "Ugyldig nøgle{nøgle, vælg, udefineret{} anden{: {nøgle}}}",
|
||||
"invalid_element": "Ugyldigt element{indeks, vælg, udefineret{} andet{ ved indeks {indeks}}}",
|
||||
"invalid_value": "Ugyldig værdi{forventet, vælg, udefineret{} anden{. Forventet {expected}}}{received, select, undefined{} other{ (received {received})}}",
|
||||
"custom": "{besked}",
|
||||
"required_field": "Dette felt er påkrævet",
|
||||
"invalid_field": "Ugyldig værdi"
|
||||
}
|
||||
},
|
||||
"generic": {
|
||||
|
||||
@@ -1555,6 +1555,21 @@
|
||||
"email": "Ungültige E-Mail-Adresse",
|
||||
"username": "Benutzername darf nicht leer sein",
|
||||
"fullname": "Der Name sollte mindestens 5 Zeichen lang sein"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Ungültiger Typ {erwartet, ausgewählt, undefiniert{} andere{. Erwartet {erwartet}}}{received, select, undefiniert{} other{ (received {received})}}",
|
||||
"too_big": "Der Wert ist zu groß{maximum, select, undefiniert{} other{. Maximum {maximum}}}",
|
||||
"too_small": "Der Wert ist zu klein{minimum, select, undefiniert{} other{. Minimum {Minimum}}}",
|
||||
"invalid_format": "Ungültiges Format{validation, select, undefiniert{} other{ für {validation}}}",
|
||||
"not_multiple_of": "Der Wert muss ein Vielfaches von {multipleOf} sein",
|
||||
"unrecognized_keys": "Enthält nicht erkannte Schlüssel{keys, select, undefiniert{} other{: {keys}}}",
|
||||
"invalid_union": "Ungültiger Wert für die bereitgestellten Optionen {unionErrors, select, undefined{} other{. {unionErrors}-Option(en) fehlgeschlagen}}",
|
||||
"invalid_key": "Ungültiger Schlüssel{key, select, undefiniert{} other{: {key}}}",
|
||||
"invalid_element": "Ungültiges Element{index, select, undefiniert{} other{ am Index {index}}}",
|
||||
"invalid_value": "Invalid value{expected, select, undefined{} other{. Erwartet {erwartet}}}{received, select, undefiniert{} other{ (received {received})}}",
|
||||
"custom": "{Nachricht}",
|
||||
"required_field": "Dieses Feld ist erforderlich",
|
||||
"invalid_field": "Ungültiger Wert"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
"full_name": "Full Name",
|
||||
"password": "Your Password",
|
||||
"confirm_password": "Confirm Password",
|
||||
"password_helper_message": "Password must be more than 6 characters",
|
||||
"password_helper_message": "Password must be more than 8 characters",
|
||||
"create_account": "Create Account"
|
||||
},
|
||||
"validation": {
|
||||
@@ -117,7 +117,10 @@
|
||||
"heading": "Personal Information",
|
||||
"full_name": "Full Name",
|
||||
"username": "Username",
|
||||
"email": "Email"
|
||||
"email": "Email",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"email_change_verification_note": "A verification email has been sent to your current email address. The new email will not be in effect until you accept the verification email."
|
||||
},
|
||||
"update_profile": "Update Profile",
|
||||
"logout": "Log out"
|
||||
@@ -272,6 +275,7 @@
|
||||
},
|
||||
|
||||
"org_navigation": {
|
||||
"platform": "Platform",
|
||||
"dashboard": "Dashboard",
|
||||
"courses": "Courses",
|
||||
"community": "Community",
|
||||
@@ -1661,6 +1665,21 @@
|
||||
},
|
||||
"email": {
|
||||
"not_valid": "Not valid email"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Invalid type{expected, select, undefined{} other{. Expected {expected}}}{received, select, undefined{} other{ (received {received})}}",
|
||||
"too_big": "Value is too large{maximum, select, undefined{} other{. Maximum {maximum}}}",
|
||||
"too_small": "Value is too small{minimum, select, undefined{} other{. Minimum {minimum}}}",
|
||||
"invalid_format": "Invalid format{validation, select, undefined{} other{ for {validation}}}",
|
||||
"not_multiple_of": "Value must be a multiple of {multipleOf}",
|
||||
"unrecognized_keys": "Contains unrecognized keys{keys, select, undefined{} other{: {keys}}}",
|
||||
"invalid_union": "Invalid value for provided options{unionErrors, select, undefined{} other{. {unionErrors} option(s) failed}}",
|
||||
"invalid_key": "Invalid key{key, select, undefined{} other{: {key}}}",
|
||||
"invalid_element": "Invalid element{index, select, undefined{} other{ at index {index}}}",
|
||||
"invalid_value": "Invalid value{expected, select, undefined{} other{. Expected {expected}}}{received, select, undefined{} other{ (received {received})}}",
|
||||
"custom": "{message}",
|
||||
"required_field": "This field is required",
|
||||
"invalid_field": "Invalid value"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1555,6 +1555,21 @@
|
||||
"email": "Dirección de correo electrónico no válida",
|
||||
"username": "El nombre de usuario no puede estar vacío",
|
||||
"fullname": "El nombre debe tener un mínimo de 5 caracteres."
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Invalid type{expected, select, undefined{} other{. Esperado {esperado}}}{recibido, seleccionado, indefinido{} otro{ (recibido {recibido})}}",
|
||||
"too_big": "El valor es demasiado grande{máximo, selección, undefinido{} otro{. Máximo {máximo}}}",
|
||||
"too_small": "El valor es demasiado pequeño{mínimo, seleccionado, undefinido{} otro{. Mínimo {mínimo}}}",
|
||||
"invalid_format": "Formato no válido{validación, selección, indefinido{} otro{ para {validación}}}",
|
||||
"not_multiple_of": "El valor debe ser múltiplo de {multipleOf}",
|
||||
"unrecognized_keys": "Contiene claves no reconocidas{keys, select, undefined{} other{: {keys}}}",
|
||||
"invalid_union": "Valor no válido para las opciones proporcionadas{unionErrors, select, undefined{} other{. {unionErrors} opción(es) fallida(s)}}",
|
||||
"invalid_key": "Clave no válida{clave, selección, undefinido{} otro{: {clave}}}",
|
||||
"invalid_element": "Elemento no válido{índice, selección, undefinido{} otro{ en el índice {índice}}}",
|
||||
"invalid_value": "Valor no válido{esperado, seleccionado, indefinido{} otro{. Esperado {esperado}}}{recibido, seleccionado, indefinido{} otro{ (recibido {recibido})}}",
|
||||
"custom": "{mensaje}",
|
||||
"required_field": "Este campo es obligatorio",
|
||||
"invalid_field": "Invalid value"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
|
||||
@@ -1555,6 +1555,21 @@
|
||||
"email": "Adresse e-mail invalide",
|
||||
"username": "Le nom d'utilisateur ne peut pas être vide",
|
||||
"fullname": "Le nom doit comporter au minimum 5 caractères"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Type non valide{attendu, sélectionné, non défini{} autre{. Attendu {attendu}}}{reçu, sélectionné, non défini{} autre{ (reçu {reçu})}}",
|
||||
"too_big": "La valeur est trop grande{maximum, select, undefined{} autre{. Maximum {maximum}}}",
|
||||
"too_small": "La valeur est trop petite{minimum, select, undefined{} autre{. Minimum {minimum}}}",
|
||||
"invalid_format": "Format invalide{validation, select, non défini{} autre{ pour {validation}}}",
|
||||
"not_multiple_of": "La valeur doit être un multiple de {multipleOf}",
|
||||
"unrecognized_keys": "Contient des clés non reconnues{keys, select, undefined{} other{: {keys}}}",
|
||||
"invalid_union": "Valeur non valide pour les options fournies {unionErrors, select, undefined{} other{. {unionErrors} option(s) a échoué}}",
|
||||
"invalid_key": "Clé invalide{key, select, undefined{} other{: {key}}}",
|
||||
"invalid_element": "Élément invalide{index, select, undefined{} autre{ à l'index {index}}}",
|
||||
"invalid_value": "Valeur non valide{attendue, sélectionnée, non définie{} autre{. Attendu {attendu}}}{reçu, sélectionné, non défini{} autre{ (reçu {reçu})}}",
|
||||
"custom": "{message}",
|
||||
"required_field": "Ce champ est obligatoire",
|
||||
"invalid_field": "Valeur invalide"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
|
||||
@@ -1555,6 +1555,21 @@
|
||||
"email": "अमान्य ईमेल पता",
|
||||
"username": "उपयोक्तानाम खाली नहीं हो सकता",
|
||||
"fullname": "नाम कम से कम 5 अक्षर का होना चाहिए"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "अमान्य प्रकार{अपेक्षित, चयन, अपरिभाषित{} अन्य{। अपेक्षित {अपेक्षित}} {प्राप्त, चयन, अपरिभाषित {} अन्य { (प्राप्त {प्राप्त})}}",
|
||||
"too_big": "मान बहुत बड़ा है {अधिकतम, चयन करें, अपरिभाषित {} अन्य {। अधिकतम {अधिकतम}}}",
|
||||
"too_small": "मान बहुत छोटा है {न्यूनतम, चयन करें, अपरिभाषित {} अन्य {। न्यूनतम {न्यूनतम}}}",
|
||||
"invalid_format": "अमान्य प्रारूप{सत्यापन, चयन, अपरिभाषित{} अन्य{ {सत्यापन}}} के लिए",
|
||||
"not_multiple_of": "मान {multipleOf} का गुणज होना चाहिए",
|
||||
"unrecognized_keys": "अज्ञात कुंजियाँ शामिल हैं {कुंजियाँ, चयन करें, अपरिभाषित{} अन्य{: {कुंजियाँ}}}",
|
||||
"invalid_union": "प्रदत्त विकल्पों के लिए अमान्य मान {संघत्रुटि, चयन, अपरिभाषित{} अन्य{। {unionErrors} विकल्प विफल रहे}}",
|
||||
"invalid_key": "अमान्य कुंजी{कुंजी, चयन, अपरिभाषित{} अन्य{: {कुंजी}}}",
|
||||
"invalid_element": "अमान्य तत्व {सूचकांक, चयन, अपरिभाषित {} अन्य {सूचकांक {सूचकांक}}} पर",
|
||||
"invalid_value": "अमान्य मान{अपेक्षित, चयन, अपरिभाषित{} अन्य{। अपेक्षित {अपेक्षित}} {प्राप्त, चयन, अपरिभाषित {} अन्य { (प्राप्त {प्राप्त})}}",
|
||||
"custom": "{संदेश}",
|
||||
"required_field": "यह फ़ील्ड आवश्यक है",
|
||||
"invalid_field": "अमान्य मान"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
|
||||
@@ -94,6 +94,21 @@
|
||||
"email": "Nieprawidłowy adres e -mail",
|
||||
"username": "Nazwa użytkownika nie może być pusta",
|
||||
"fullname": "Nazwa powinna mieć minimum 5 znaków"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Nieprawidłowy typ{oczekiwany, wybierz, niezdefiniowany{} inny{. Oczekiwany {oczekiwany}}}{otrzymany, wybierz, niezdefiniowany{} inny{ (otrzymany {otrzymany})}}",
|
||||
"too_big": "Wartość jest za duża{maksymalna, wybierz, niezdefiniowana{} inna{. Maksymalnie {maksimum}}}",
|
||||
"too_small": "Wartość jest za mała{minimum, wybierz, niezdefiniowane{} inne{. Minimum {minimum}}}",
|
||||
"invalid_format": "Nieprawidłowy format{validacja, wybierz, niezdefiniowany{} other{ for {validation}}}",
|
||||
"not_multiple_of": "Wartość musi być wielokrotnością {multipleOf}",
|
||||
"unrecognized_keys": "Zawiera nierozpoznane klucze{klucze, wybierz, niezdefiniowane{} inne{: {klucze}}}",
|
||||
"invalid_union": "Nieprawidłowa wartość podanych opcji{unionErrors, wybierz, niezdefiniowane{} inne{. Niepowodzenie opcji: {unionErrors}}}",
|
||||
"invalid_key": "Nieprawidłowy klucz{klucz, wybierz, niezdefiniowany{} inny{: {klucz}}}",
|
||||
"invalid_element": "Nieprawidłowy element{index, wybierz, niezdefiniowany{} other{ w indeksie {index}}}",
|
||||
"invalid_value": "Nieprawidłowa wartość{oczekiwana, wybierz, niezdefiniowana{} inna{. Oczekiwany {oczekiwany}}}{otrzymany, wybierz, niezdefiniowany{} inny{ (otrzymany {otrzymany})}}",
|
||||
"custom": "{wiadomość}",
|
||||
"required_field": "To pole jest wymagane",
|
||||
"invalid_field": "Nieprawidłowa wartość"
|
||||
}
|
||||
},
|
||||
"generic": {
|
||||
|
||||
@@ -1555,6 +1555,21 @@
|
||||
"email": "Endereço de email invalido",
|
||||
"username": "O nome de usuário não pode ficar vazio",
|
||||
"fullname": "O nome deve ter no mínimo 5 caracteres"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Tipo inválido{esperado, selecionado, indefinido{} outro{. Esperado {esperado}}}{recebido, selecionado, indefinido{} outro{ (recebido {recebido})}}",
|
||||
"too_big": "O valor é muito grande{máximo, seleção, indefinido{} outro{. Máximo {máximo}}}",
|
||||
"too_small": "O valor é muito pequeno{mínimo, selecione, indefinido{} outro{. Mínimo {mínimo}}}",
|
||||
"invalid_format": "Formato inválido{validação, seleção, indefinido{} outro{ para {validação}}}",
|
||||
"not_multiple_of": "O valor precisa ser um múltiplo de {multipleOf}",
|
||||
"unrecognized_keys": "Contém chaves não reconhecidas{keys, select, undefined{} other{: {keys}}}",
|
||||
"invalid_union": "Valor inválido para opções fornecidas{unionErrors, select, indefinido{} other{. Falha na(s) opção(ões) {unionErrors}}}",
|
||||
"invalid_key": "Chave inválida{chave, seleção, indefinida{} outra{: {chave}}}",
|
||||
"invalid_element": "Elemento inválido{index, select, indefinido{} other{ no índice {index}}}",
|
||||
"invalid_value": "Valor inválido{esperado, selecionado, indefinido{} outro{. Esperado {esperado}}}{recebido, selecionado, indefinido{} outro{ (recebido {recebido})}}",
|
||||
"custom": "{mensagem}",
|
||||
"required_field": "Este campo é obrigatório",
|
||||
"invalid_field": "Valor inválido"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
|
||||
@@ -1555,6 +1555,21 @@
|
||||
"email": "Неверный адрес электронной почты",
|
||||
"username": "Имя пользователя не может быть пустым",
|
||||
"fullname": "Имя должно состоять минимум из 5 символов."
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Неверный тип {ожидаемый, выбранный, неопределенный{} другое{. Ожидается {ожидается}}}{получено, выберите, неопределенное{} другое { (получено {получено})}}",
|
||||
"too_big": "Значение слишком велико{максимум, выберите, неопределенное{} другое{. Максимум {максимум}}}",
|
||||
"too_small": "Значение слишком мало{минимальное, выберите, неопределенное{} другое{. Минимум {минимум}}}",
|
||||
"invalid_format": "Неверный формат{проверка, выбор, неопределенное{} другое{ для {проверка}}}",
|
||||
"not_multiple_of": "Значение должно быть кратно {multipleOf}.",
|
||||
"unrecognized_keys": "Содержит нераспознанные ключи{keys, select, undefine{}other{: {keys}}}",
|
||||
"invalid_union": "Недопустимое значение для предоставленных параметров{unionErrors, select, undefine{}other{. Вариант(ы) {unionErrors} не выполнен}}",
|
||||
"invalid_key": "Неверный ключ{key, select, undefine{}other{: {key}}}",
|
||||
"invalid_element": "Неверный элемент{index, select, undefined{}other{ по индексу {index}}}",
|
||||
"invalid_value": "Недопустимое значение{ожидаемое, выбранное, неопределенное{} другое{. Ожидается {ожидается}}}{получено, выберите, неопределенное{} другое { (получено {получено})}}",
|
||||
"custom": "{сообщение}",
|
||||
"required_field": "Это поле обязательно для заполнения",
|
||||
"invalid_field": "Недопустимое значение"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
|
||||
@@ -1555,6 +1555,21 @@
|
||||
"email": "địa chỉ email không hợp lệ",
|
||||
"username": "Tên ngươi dung không được để trông",
|
||||
"fullname": "Tên phải có tối thiểu 5 ký tự"
|
||||
},
|
||||
"generic": {
|
||||
"invalid_type": "Loại không hợp lệ{dự kiến, chọn, không xác định{} khác{. Dự kiến {mong đợi}}}{đã nhận, chọn, không xác định{} khác{ (đã nhận {đã nhận})}}",
|
||||
"too_big": "Giá trị quá lớn{tối đa, chọn, không xác định{} khác{. Tối đa {tối đa}}}",
|
||||
"too_small": "Giá trị quá nhỏ{tối thiểu, chọn, không xác định{} khác{. Tối thiểu {tối thiểu}}}",
|
||||
"invalid_format": "Định dạng không hợp lệ{xác thực, chọn, không xác định{} khác{ cho {xác thực}}}",
|
||||
"not_multiple_of": "Giá trị phải là bội số của {multipleOf}",
|
||||
"unrecognized_keys": "Chứa các khóa không được nhận dạng{keys, select, undefind{} other{: {keys}}}",
|
||||
"invalid_union": "Giá trị không hợp lệ cho các tùy chọn được cung cấp{unionErrors, select, unknown{} other{. {unionErrors} tùy chọn không thành công}}",
|
||||
"invalid_key": "Khóa không hợp lệ{khóa, chọn, không xác định{} khác{: {key}}}",
|
||||
"invalid_element": "Phần tử không hợp lệ{chỉ mục, chọn, không xác định{} khác{ tại chỉ mục {index}}}",
|
||||
"invalid_value": "Giá trị không hợp lệ{dự kiến, chọn, không xác định{} khác{. Dự kiến {mong đợi}}}{đã nhận, chọn, không xác định{} khác{ (đã nhận {đã nhận})}}",
|
||||
"custom": "{tin nhắn}",
|
||||
"required_field": "Trường này là bắt buộc",
|
||||
"invalid_field": "Giá trị không hợp lệ"
|
||||
}
|
||||
},
|
||||
"explore": {
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
export * from './config';
|
||||
export * from './dashboard';
|
||||
|
||||
export enum LOCALE {
|
||||
EN = 'en',
|
||||
HI = 'hi',
|
||||
FR = 'fr',
|
||||
PL = 'pl',
|
||||
PT = 'pt',
|
||||
DE = 'de',
|
||||
VI = 'vi',
|
||||
RU = 'ru',
|
||||
ES = 'es',
|
||||
DA = 'da'
|
||||
}
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
|
||||
//===============Custom Type===============
|
||||
|
||||
@@ -304,7 +293,7 @@ export interface LessonPage {
|
||||
title: string;
|
||||
totalExercises: number;
|
||||
totalComments: number;
|
||||
locale: LOCALE;
|
||||
locale: TLocale;
|
||||
isSaving: boolean;
|
||||
isFetching: boolean;
|
||||
materials: {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { Icon } from '@lucide/svelte';
|
||||
|
||||
import type { CurrentOrg } from './org';
|
||||
|
||||
export interface SidebarData {
|
||||
teams: CurrentOrg[];
|
||||
navMain: Array<{
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: typeof Icon;
|
||||
isActive: boolean;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}>;
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
# Convention-Based Validation with Translations
|
||||
|
||||
## Overview
|
||||
|
||||
We use **convention-based translation keys** for Zod validation errors. This approach:
|
||||
- ✅ No metadata needed - just follow the naming pattern
|
||||
- ✅ Clean shared schemas without hardcoded messages
|
||||
- ✅ Automatic fallback to generic translations
|
||||
- ✅ Works with multiple validation rules per field
|
||||
|
||||
## Convention
|
||||
|
||||
Translation keys follow this pattern:
|
||||
|
||||
```
|
||||
validations.{fieldName}.{errorCode}
|
||||
```
|
||||
|
||||
### Common Error Codes
|
||||
|
||||
| Error Code | When It Occurs | Example Field |
|
||||
|------------|----------------|---------------|
|
||||
| `too_small` | String too short, number too small, array too few items | username.too_small |
|
||||
| `too_big` | String too long, number too large, array too many items | bio.too_big |
|
||||
| `invalid_email` | Invalid email format | email.invalid_email |
|
||||
| `invalid_url` | Invalid URL format | website.invalid_url |
|
||||
| `invalid_type` | Wrong type (e.g., undefined when string expected) | username.invalid_type |
|
||||
| `invalid_enum_value` | Value not in allowed options | role.invalid_enum_value |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Import Schema and Error Mapper
|
||||
|
||||
```typescript
|
||||
import { ZEmailData } from '@cio/utils/validation/mail';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
|
||||
let formData = { to: '', subject: '', content: '' };
|
||||
let errors: Record<string, string> = {};
|
||||
|
||||
function handleSubmit() {
|
||||
const result = ZEmailData.safeParse(formData);
|
||||
|
||||
if (!result.success) {
|
||||
errors = mapZodErrorsToTranslations(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use validated data
|
||||
sendEmail(result.data);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Add Translation Keys
|
||||
|
||||
```json
|
||||
{
|
||||
"validations": {
|
||||
"to": {
|
||||
"invalid_email": "Invalid email address",
|
||||
"invalid_type": "Email is required"
|
||||
},
|
||||
"subject": {
|
||||
"too_small": "Subject is required",
|
||||
"too_big": "Subject is too long"
|
||||
},
|
||||
"content": {
|
||||
"too_small": "Content must be at least 1 character",
|
||||
"too_big": "Content is too long"
|
||||
},
|
||||
|
||||
"generic": {
|
||||
"required": "This field is required",
|
||||
"invalid_email": "Invalid email address",
|
||||
"invalid_url": "Invalid URL",
|
||||
"too_small_string": "Must be at least {min} characters",
|
||||
"too_big_string": "Must be at most {max} characters"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Complete Example: User Profile Form
|
||||
|
||||
### Schema (Shared)
|
||||
|
||||
```typescript
|
||||
// packages/utils/src/validation/user/user.ts
|
||||
export const ZUserProfile = z.object({
|
||||
email: z.string().email(),
|
||||
username: z.string().min(3).max(20),
|
||||
bio: z.string().min(10).max(500).optional(),
|
||||
age: z.number().min(13).max(120)
|
||||
});
|
||||
```
|
||||
|
||||
### Component
|
||||
|
||||
```typescript
|
||||
// Component.svelte
|
||||
<script lang="ts">
|
||||
import { ZUserProfile } from '@cio/utils/validation/user';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
|
||||
let formData = {
|
||||
email: '',
|
||||
username: '',
|
||||
bio: '',
|
||||
age: 0
|
||||
};
|
||||
|
||||
let errors: Record<string, string> = {};
|
||||
|
||||
function handleSubmit() {
|
||||
const result = ZUserProfile.safeParse(formData);
|
||||
|
||||
if (!result.success) {
|
||||
errors = mapZodErrorsToTranslations(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// Success!
|
||||
saveProfile(result.data);
|
||||
}
|
||||
</script>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit}>
|
||||
<div>
|
||||
<label for="email">Email</label>
|
||||
<input id="email" type="email" bind:value={formData.email} />
|
||||
{#if errors.email}
|
||||
<span class="error">{errors.email}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="username">Username</label>
|
||||
<input id="username" bind:value={formData.username} />
|
||||
{#if errors.username}
|
||||
<span class="error">{errors.username}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="bio">Bio</label>
|
||||
<textarea id="bio" bind:value={formData.bio}></textarea>
|
||||
{#if errors.bio}
|
||||
<span class="error">{errors.bio}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="age">Age</label>
|
||||
<input id="age" type="number" bind:value={formData.age} />
|
||||
{#if errors.age}
|
||||
<span class="error">{errors.age}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button type="submit">Save Profile</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
### Translations
|
||||
|
||||
```json
|
||||
{
|
||||
"validations": {
|
||||
"email": {
|
||||
"invalid_email": "Invalid email address",
|
||||
"invalid_type": "Email is required"
|
||||
},
|
||||
"username": {
|
||||
"too_small": "Username must be at least 3 characters",
|
||||
"too_big": "Username cannot exceed 20 characters",
|
||||
"invalid_type": "Username is required"
|
||||
},
|
||||
"bio": {
|
||||
"too_small": "Bio must be at least 10 characters",
|
||||
"too_big": "Bio cannot exceed 500 characters"
|
||||
},
|
||||
"age": {
|
||||
"too_small": "You must be at least 13 years old",
|
||||
"too_big": "Invalid age",
|
||||
"invalid_type": "Age is required"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Multiple Errors Per Field
|
||||
|
||||
Zod validates in order and returns the **first error** it encounters:
|
||||
|
||||
```typescript
|
||||
// Schema
|
||||
z.string().min(3).max(20)
|
||||
|
||||
// Input: ""
|
||||
// Error: too_small (stops at first rule)
|
||||
// Translation: validations.username.too_small
|
||||
|
||||
// Input: "ab"
|
||||
// Error: too_small (still fails first rule)
|
||||
// Translation: validations.username.too_small
|
||||
|
||||
// Input: "this_is_way_too_long_username"
|
||||
// Error: too_big (passed min, failed max)
|
||||
// Translation: validations.username.too_big
|
||||
```
|
||||
|
||||
## Fallback Strategy
|
||||
|
||||
The error mapper tries translations in this order:
|
||||
|
||||
1. **Specific field + error code**: `validations.email.invalid_email`
|
||||
2. **Generic error type**: `validations.generic.invalid_email`
|
||||
3. **Zod's default message**: "Invalid email"
|
||||
|
||||
## Advanced: Helper Function
|
||||
|
||||
For cleaner code, use the helper:
|
||||
|
||||
```typescript
|
||||
import { validateWithTranslation } from '$lib/utils/validation';
|
||||
|
||||
function handleSubmit() {
|
||||
const result = validateWithTranslation(ZUserProfile, formData);
|
||||
|
||||
if ('errors' in result) {
|
||||
errors = result.errors;
|
||||
return;
|
||||
}
|
||||
|
||||
// Success - result.data is typed!
|
||||
saveProfile(result.data);
|
||||
}
|
||||
```
|
||||
|
||||
## Adding New Fields
|
||||
|
||||
1. **Add to schema** (in packages/utils):
|
||||
```typescript
|
||||
export const ZUserProfile = z.object({
|
||||
// ... existing fields
|
||||
website: z.string().url().optional()
|
||||
});
|
||||
```
|
||||
|
||||
2. **Add translations**:
|
||||
```json
|
||||
{
|
||||
"validations": {
|
||||
"website": {
|
||||
"invalid_url": "Please enter a valid URL",
|
||||
"invalid_type": "Website is required"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **That's it!** No metadata, no reimplementation.
|
||||
|
||||
## Generic Fallbacks
|
||||
|
||||
If you don't add specific translations, the mapper uses generic ones:
|
||||
|
||||
```json
|
||||
{
|
||||
"validations": {
|
||||
"generic": {
|
||||
"required": "This field is required",
|
||||
"invalid_email": "Invalid email address",
|
||||
"invalid_url": "Invalid URL",
|
||||
"invalid_format": "Invalid format",
|
||||
"too_small_string": "Must be at least {min} characters",
|
||||
"too_big_string": "Must be at most {max} characters",
|
||||
"too_small_number": "Must be at least {min}",
|
||||
"too_big_number": "Must be at most {max}",
|
||||
"invalid_enum": "Must be one of: {options}",
|
||||
"error": "Validation error"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Just remove the message parameter and add the translation key following the convention!
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Validation Utilities
|
||||
*
|
||||
* Exports utilities for handling Zod validation with translations
|
||||
*/
|
||||
|
||||
export { mapZodErrorsToTranslations, isValidationError, validateWithTranslation } from './validation';
|
||||
@@ -0,0 +1,299 @@
|
||||
import { ZodError, z } from 'zod';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import enTranslations from '$lib/utils/translations/en.json' assert { type: 'json' };
|
||||
|
||||
import { mapZodErrorsToTranslations } from './validation';
|
||||
|
||||
const { mockGet } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn<(key: string, params?: Record<string, unknown>) => string | null>()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/utils/functions/translations', () => ({
|
||||
t: {
|
||||
get: mockGet
|
||||
}
|
||||
}));
|
||||
|
||||
const createError = (issue: z.core.$ZodIssue) =>
|
||||
new ZodError([
|
||||
{
|
||||
...issue,
|
||||
path: issue.path ?? ['field'],
|
||||
message: issue.message ?? 'Issue occurred'
|
||||
} as z.core.$ZodIssue
|
||||
]);
|
||||
|
||||
function renderTemplate(template: string, params: Record<string, unknown> = {}): string {
|
||||
let output = template;
|
||||
|
||||
output = output.replace(
|
||||
/\{(\w+),\s*select,\s*undefined\{\}\s*other\{((?:[^{}]|\{[^{}]*\})*)\}\}/g,
|
||||
(_match, key: string, content: string) => {
|
||||
const value = params[key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const renderedContent = content.replace(/\{(\w+)\}/g, (_nestedMatch: string, innerKey: string) => {
|
||||
const innerValue = params[innerKey];
|
||||
return innerValue === undefined || innerValue === null ? '' : String(innerValue);
|
||||
});
|
||||
|
||||
return renderedContent;
|
||||
}
|
||||
);
|
||||
|
||||
output = output.replace(/\{(\w+)\}/g, (_match, key: string) => {
|
||||
const value = params[key];
|
||||
return value === undefined || value === null ? '' : String(value);
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
describe('mapZodErrorsToTranslations fallback keys', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
it('prefers entity-specific translation before other fallbacks', () => {
|
||||
mockGet.mockImplementation((key) => {
|
||||
if (key === 'validations.course.title.too_small') {
|
||||
return 'Course-specific translation';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const error = createError({
|
||||
code: 'too_small',
|
||||
minimum: 3,
|
||||
inclusive: true,
|
||||
path: ['title'],
|
||||
origin: 'zod',
|
||||
message: 'Title must be at least 3 characters long'
|
||||
});
|
||||
|
||||
expect(mapZodErrorsToTranslations(error, 'course')).toEqual({ title: 'Course-specific translation' });
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith(
|
||||
'validations.course.title.too_small',
|
||||
expect.objectContaining({
|
||||
entity: 'course',
|
||||
field: 'title',
|
||||
minimum: 3,
|
||||
inclusive: true
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to field translation when entity-specific key is missing', () => {
|
||||
mockGet.mockImplementation((key) => {
|
||||
if (key === 'validations.title.too_small') {
|
||||
return 'Field translation';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const error = createError({
|
||||
code: 'too_small',
|
||||
minimum: 3,
|
||||
inclusive: true,
|
||||
path: ['title'],
|
||||
origin: 'zod',
|
||||
message: 'Title must be at least 3 characters long'
|
||||
});
|
||||
|
||||
expect(mapZodErrorsToTranslations(error, 'course')).toEqual({ title: 'Field translation' });
|
||||
});
|
||||
|
||||
it('falls back to generic translation when no specific keys are found', () => {
|
||||
mockGet.mockImplementation((key, params = {}) => {
|
||||
if (key === 'validations.generic.too_small') {
|
||||
return renderTemplate(enTranslations.validations.generic.too_small, params as Record<string, unknown>);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const error = createError({
|
||||
code: 'too_small',
|
||||
minimum: 3,
|
||||
inclusive: true,
|
||||
path: ['title'],
|
||||
origin: 'zod',
|
||||
message: 'Title must be at least 3 characters long'
|
||||
});
|
||||
|
||||
expect(mapZodErrorsToTranslations(error)).toEqual({
|
||||
title: renderTemplate(enTranslations.validations.generic.too_small, {
|
||||
field: 'title',
|
||||
code: 'too_small',
|
||||
minimum: 3,
|
||||
inclusive: true
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the original Zod message when no translations are available', () => {
|
||||
mockGet.mockReturnValue(null);
|
||||
|
||||
const schema = z.object({
|
||||
title: z.string().min(5)
|
||||
});
|
||||
const result = schema.safeParse({ title: 'abc' });
|
||||
|
||||
if (result.success) {
|
||||
throw new Error('Expected validation to fail');
|
||||
}
|
||||
|
||||
const zodMessage = result.error.issues[0].message;
|
||||
|
||||
expect(mapZodErrorsToTranslations(result.error)).toEqual({
|
||||
title: zodMessage
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapZodErrorsToTranslations generic parameter coverage', () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
const cases: Array<{
|
||||
code: z.core.$ZodIssue['code'];
|
||||
issue: Partial<z.core.$ZodIssue> & Record<string, unknown>;
|
||||
expectedParams: Record<string, unknown>;
|
||||
}> = [
|
||||
{
|
||||
code: 'invalid_type',
|
||||
issue: {
|
||||
expected: 'string',
|
||||
received: 'number',
|
||||
path: ['field'],
|
||||
origin: 'zod',
|
||||
message: 'Field must be a string'
|
||||
},
|
||||
expectedParams: { expected: 'string', received: 'number' }
|
||||
},
|
||||
{
|
||||
code: 'too_small',
|
||||
issue: {
|
||||
minimum: 3,
|
||||
inclusive: true,
|
||||
exact: false,
|
||||
path: ['field'],
|
||||
origin: 'zod',
|
||||
message: 'Field must be at least 3 characters long'
|
||||
},
|
||||
expectedParams: { minimum: 3, inclusive: true, exact: false }
|
||||
},
|
||||
{
|
||||
code: 'too_big',
|
||||
issue: {
|
||||
maximum: 10,
|
||||
inclusive: false,
|
||||
exact: false,
|
||||
path: ['field'],
|
||||
origin: 'zod',
|
||||
message: 'Field must be less than 10 characters long'
|
||||
},
|
||||
expectedParams: { maximum: 10, inclusive: false, exact: false }
|
||||
},
|
||||
{
|
||||
code: 'invalid_format',
|
||||
issue: {
|
||||
validation: 'email',
|
||||
regex: /@/,
|
||||
path: ['field'],
|
||||
origin: 'zod',
|
||||
message: 'Field must be a valid email address'
|
||||
},
|
||||
expectedParams: { validation: 'email', regex: /@/ }
|
||||
},
|
||||
{
|
||||
code: 'not_multiple_of',
|
||||
issue: { multipleOf: 5, path: ['field'], origin: 'zod', message: 'Field must be a multiple of 5' },
|
||||
expectedParams: { multipleOf: 5 }
|
||||
},
|
||||
{
|
||||
code: 'unrecognized_keys',
|
||||
issue: {
|
||||
keys: ['foo', 'bar'],
|
||||
path: ['field'],
|
||||
origin: 'zod',
|
||||
message: 'Field must not contain unrecognized keys'
|
||||
},
|
||||
expectedParams: { keys: 'foo, bar' }
|
||||
},
|
||||
{
|
||||
code: 'invalid_union',
|
||||
issue: {
|
||||
unionErrors: [new ZodError([]), new ZodError([])],
|
||||
path: ['field'],
|
||||
origin: 'zod',
|
||||
message: 'Field must be a valid union'
|
||||
},
|
||||
expectedParams: { unionErrors: 2 }
|
||||
},
|
||||
{
|
||||
code: 'invalid_key',
|
||||
issue: { key: 'unexpected', path: ['field'], origin: 'zod', message: 'Field must not contain unexpected keys' },
|
||||
expectedParams: { key: 'unexpected' }
|
||||
},
|
||||
{
|
||||
code: 'invalid_element',
|
||||
issue: {
|
||||
index: 1,
|
||||
element: { foo: 'bar' },
|
||||
path: ['field'],
|
||||
origin: 'zod',
|
||||
message: 'Field must not contain invalid elements'
|
||||
},
|
||||
expectedParams: { index: 1, element: { foo: 'bar' } }
|
||||
},
|
||||
{
|
||||
code: 'invalid_value',
|
||||
issue: { expected: 'A', received: 'B', path: ['field'], origin: 'zod', message: 'Field must be a valid value' },
|
||||
expectedParams: { expected: 'A', received: 'B' }
|
||||
},
|
||||
{
|
||||
code: 'custom',
|
||||
issue: { params: { reason: 'Invalid' }, message: 'Custom message', path: ['field'], origin: 'zod' },
|
||||
expectedParams: { params: JSON.stringify({ reason: 'Invalid' }), message: 'Custom message' }
|
||||
}
|
||||
];
|
||||
|
||||
it.each(cases)('passes expected params to generic translation for %s', ({ code, issue, expectedParams }) => {
|
||||
mockGet.mockImplementation((key, params = {}) =>
|
||||
key === `validations.generic.${code}`
|
||||
? renderTemplate(
|
||||
enTranslations.validations.generic[code as keyof typeof enTranslations.validations.generic],
|
||||
params as Record<string, unknown>
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
const error = createError({
|
||||
code,
|
||||
...issue
|
||||
} as z.core.$ZodIssue);
|
||||
|
||||
const result = mapZodErrorsToTranslations(error);
|
||||
const call = mockGet.mock.calls.find(([calledKey]) => calledKey === `validations.generic.${code}`);
|
||||
|
||||
expect(call?.[0]).toBe(`validations.generic.${code}`);
|
||||
expect(call?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
field: 'field',
|
||||
code,
|
||||
...expectedParams
|
||||
})
|
||||
);
|
||||
|
||||
const renderedMessage = renderTemplate(
|
||||
enTranslations.validations.generic[code as keyof typeof enTranslations.validations.generic],
|
||||
(call?.[1] ?? {}) as Record<string, unknown>
|
||||
);
|
||||
|
||||
expect(result).toEqual({ field: renderedMessage });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
import type { ZodError } from 'zod';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
/**
|
||||
* Maps Zod validation errors to translated messages using convention-based keys.
|
||||
*
|
||||
* Convention:
|
||||
* - With entity: validations.{entityName}.{fieldName}.{errorCode}
|
||||
* - Without entity: validations.{fieldName}.{errorCode}
|
||||
*
|
||||
* Examples:
|
||||
* - Entity: "course", Field: "title", Error: "too_small"
|
||||
* → First tries: "validations.course.title.too_small"
|
||||
* → Falls back to: "validations.title.too_small"
|
||||
*
|
||||
* - Field: "email", Error: "invalid_string"
|
||||
* → Translation key: "validations.email.invalid_string"
|
||||
*
|
||||
* This allows entity-specific error messages like "Course title is required"
|
||||
* vs "Lesson title is required" while still supporting generic messages.
|
||||
*
|
||||
* @param error - Zod validation error object
|
||||
* @param entityName - Optional entity prefix (e.g., 'course', 'lesson')
|
||||
* @returns Object mapping field paths to translated error messages
|
||||
*/
|
||||
export function mapZodErrorsToTranslations(error: ZodError, entityName?: string): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!error?.issues) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
error.issues.forEach((issue: z.core.$ZodIssue) => {
|
||||
const fieldPath = issue.path.join('.');
|
||||
const fieldName = issue.path[issue.path.length - 1] as string;
|
||||
|
||||
if (!fieldName) return;
|
||||
|
||||
// Build translation keys with entity-specific and generic fallbacks
|
||||
const translationKeys = buildTranslationKeys(fieldName, issue, entityName);
|
||||
const translationParams = buildTranslationParams(issue, fieldName, entityName);
|
||||
|
||||
// Try each translation key in order (entity-specific first, then generic)
|
||||
let translatedMessage: string | null = null;
|
||||
for (const key of translationKeys) {
|
||||
if (key === 'validations.generic.custom') {
|
||||
translatedMessage = t.get(issue.message);
|
||||
break;
|
||||
}
|
||||
|
||||
if (issue.code === 'invalid_type' && translationParams.received === 'undefined') {
|
||||
translatedMessage = t.get('validations.generic.required_field');
|
||||
break;
|
||||
}
|
||||
|
||||
const translation = t.get(key, translationParams);
|
||||
if (translation && translation !== key) {
|
||||
translatedMessage = translation;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
errors[fieldPath] = translatedMessage || issue.message;
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
function buildTranslationKeys(fieldName: string, issue: z.core.$ZodIssue, entityName?: string): string[] {
|
||||
const keys: string[] = [];
|
||||
|
||||
const errorSuffix = issue.code;
|
||||
|
||||
if (entityName) {
|
||||
// 1. Try entity-specific key first: validations.course.title.too_small
|
||||
keys.push(`validations.${entityName}.${fieldName}.${errorSuffix}`);
|
||||
}
|
||||
|
||||
// 2. Try generic field key: validations.title.too_small
|
||||
keys.push(`validations.${fieldName}.${errorSuffix}`);
|
||||
|
||||
// 3. Try generic translation based on error type
|
||||
keys.push(`validations.generic.${errorSuffix}`);
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function buildTranslationParams(
|
||||
issue: z.core.$ZodIssue,
|
||||
fieldName: string,
|
||||
entityName?: string
|
||||
): Record<string, unknown> {
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
if (fieldName) params.field = fieldName;
|
||||
if (entityName) params.entity = entityName;
|
||||
if (issue.code) params.code = issue.code;
|
||||
if (issue.message) params.message = issue.message;
|
||||
|
||||
const details = issue as unknown as Record<string, unknown>;
|
||||
|
||||
switch (issue.code) {
|
||||
case 'invalid_type':
|
||||
if ('expected' in details) params.expected = details.expected;
|
||||
if ('received' in details) params.received = details.received;
|
||||
if (!details.received) params.received = 'undefined';
|
||||
break;
|
||||
case 'too_small':
|
||||
if ('minimum' in details) params.minimum = details.minimum;
|
||||
if ('inclusive' in details) params.inclusive = details.inclusive;
|
||||
if ('type' in details) params.type = details.type;
|
||||
if ('exact' in details) params.exact = details.exact;
|
||||
break;
|
||||
case 'too_big':
|
||||
if ('maximum' in details) params.maximum = details.maximum;
|
||||
if ('inclusive' in details) params.inclusive = details.inclusive;
|
||||
if ('type' in details) params.type = details.type;
|
||||
if ('exact' in details) params.exact = details.exact;
|
||||
break;
|
||||
case 'invalid_format':
|
||||
if ('format' in details) params.validation = details.format;
|
||||
if ('regex' in details) params.regex = details.regex;
|
||||
break;
|
||||
case 'not_multiple_of':
|
||||
if ('multipleOf' in details) params.multipleOf = details.multipleOf;
|
||||
break;
|
||||
case 'unrecognized_keys':
|
||||
if ('keys' in details) {
|
||||
params.keys = Array.isArray(details.keys) ? (details.keys as unknown[]).join(', ') : details.keys;
|
||||
}
|
||||
break;
|
||||
case 'invalid_union':
|
||||
if ('unionErrors' in details) {
|
||||
params.unionErrors = Array.isArray(details.unionErrors)
|
||||
? (details.unionErrors as unknown[]).length
|
||||
: details.unionErrors;
|
||||
}
|
||||
break;
|
||||
case 'invalid_key':
|
||||
if ('key' in details) params.key = details.key;
|
||||
break;
|
||||
case 'invalid_element':
|
||||
if ('index' in details) params.index = details.index;
|
||||
if ('element' in details) params.element = details.element;
|
||||
break;
|
||||
case 'invalid_value':
|
||||
if ('expected' in details) params.expected = details.expected;
|
||||
if ('received' in details) params.received = details.received;
|
||||
break;
|
||||
case 'custom':
|
||||
if ('params' in details) {
|
||||
params.params =
|
||||
typeof details.params === 'object' && details.params !== null
|
||||
? JSON.stringify(details.params)
|
||||
: details.params;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to check if a Zod validation passed
|
||||
*/
|
||||
export function isValidationError(result: {
|
||||
success: boolean;
|
||||
error?: ZodError;
|
||||
}): result is { success: false; error: ZodError } {
|
||||
return Boolean(result && result.success === false && result.error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates data and returns either errors or valid data
|
||||
*
|
||||
* @example
|
||||
* // Without entity prefix
|
||||
* const result = validateWithTranslation(ZUserSchema, formData);
|
||||
*
|
||||
* // With entity prefix for entity-specific error messages
|
||||
* const result = validateWithTranslation(ZCourseSchema, formData, 'course');
|
||||
*
|
||||
* if (result.errors) {
|
||||
* // Show errors to user
|
||||
* setErrors(result.errors);
|
||||
* } else {
|
||||
* // Use validated data
|
||||
* await saveUser(result.data);
|
||||
* }
|
||||
*/
|
||||
export function validateWithTranslation<T>(
|
||||
schema: { safeParse: (data: unknown) => { success: boolean; error?: ZodError; data?: T } },
|
||||
data: unknown,
|
||||
entityName?: string
|
||||
): { errors: Record<string, string> } | { data: T } {
|
||||
const result = schema.safeParse(data);
|
||||
|
||||
if (!result.success && result.error) {
|
||||
return { errors: mapZodErrorsToTranslations(result.error, entityName) };
|
||||
}
|
||||
|
||||
return { data: result.data as T };
|
||||
}
|
||||
Reference in New Issue
Block a user