From c39f36a0a9f8c8ef664b007e6aa88193f8d34321 Mon Sep 17 00:00:00 2001 From: marcuscastelo Date: Tue, 24 Mar 2026 17:57:05 -0300 Subject: [PATCH 1/4] refactor: encapsulate local infrastructure services --- src/di/container.tsx | 6 +- .../application/usecases/dayUseCases.ts | 14 +- .../infrastructure/dayDietRepository.ts | 2 - .../infrastructure/supabase/realtime.ts | 77 ++++---- .../food/application/usecases/foodCrud.ts | 19 +- .../infrastructure/api/application/apiFood.ts | 186 ++++++++++-------- .../application/usecases/macroProfileState.ts | 11 +- .../infrastructure/supabase/realtime.ts | 77 ++++---- .../observability/application/telemetry.ts | 6 +- .../infrastructure/sentry/sentry.ts | 110 ++++++----- .../weight/usecases/weightUseCases.ts | 13 +- .../weight/supabase/realtime.ts | 75 +++---- 12 files changed, 333 insertions(+), 263 deletions(-) diff --git a/src/di/container.tsx b/src/di/container.tsx index fab4e6d31..eca5cee2f 100644 --- a/src/di/container.tsx +++ b/src/di/container.tsx @@ -65,7 +65,7 @@ import { createTelemetry, type TelemetryModule, } from '~/modules/observability/application/telemetry' -import { initializeSentry } from '~/modules/observability/infrastructure/sentry/sentry' +import { createSentryService } from '~/modules/observability/infrastructure/sentry/sentry' import { createProfile, type ProfileModule, @@ -170,7 +170,7 @@ export function createContainer( const telemetryUseCases = overrides.telemetryUseCases ?? createTelemetry({ - initializeSentry, + sentryService: createSentryService(), }) let guestUseCasesRef: GuestUseCases | null = overrides.guestUseCases ?? null @@ -371,7 +371,7 @@ let bootstrapTelemetry: TelemetryModule | null = null function getBootstrapTelemetry() { if (bootstrapTelemetry === null) { bootstrapTelemetry = createTelemetry({ - initializeSentry, + sentryService: createSentryService(), }) } diff --git a/src/modules/diet/day-diet/application/usecases/dayUseCases.ts b/src/modules/diet/day-diet/application/usecases/dayUseCases.ts index ce87aec4e..b8d4430f2 100644 --- a/src/modules/diet/day-diet/application/usecases/dayUseCases.ts +++ b/src/modules/diet/day-diet/application/usecases/dayUseCases.ts @@ -16,11 +16,9 @@ import { type DayDiet, type NewDayDiet, } from '~/modules/diet/day-diet/domain/dayDiet' -import { - createDayDietRepository, - type DayRepository, -} from '~/modules/diet/day-diet/infrastructure/dayDietRepository' -import { initializeDayDietRealtime } from '~/modules/diet/day-diet/infrastructure/supabase/realtime' +import { type DayRepository } from '~/modules/diet/day-diet/domain/dayDietRepository' +import { createDayDietRepository } from '~/modules/diet/day-diet/infrastructure/dayDietRepository' +import { createDayDietRealtimeService } from '~/modules/diet/day-diet/infrastructure/supabase/realtime' import { showPromise } from '~/modules/toast/application/toastManager' import { type User } from '~/modules/user/domain/user' import { getTodayYYYYMMDD } from '~/shared/utils/date/dateUtils' @@ -36,12 +34,16 @@ import { logging } from '~/shared/utils/logging' export function createDayUseCases(deps: { authUseCases: () => AuthUseCases dayRepository?: DayRepository + createDayDietRealtimeService?: typeof createDayDietRealtimeService }) { return createRoot(() => { const authUseCases = () => deps.authUseCases() const dayChangeStore = createDayChangeStore() const dayStateStore = createDayStateStore() const dayCacheStore = createDayCacheStore() + const realtimeService = ( + deps.createDayDietRealtimeService ?? createDayDietRealtimeService + )() const dayRepository = deps.dayRepository ?? createDayDietRepository() @@ -55,7 +57,7 @@ export function createDayUseCases(deps: { dayStateStore.setTargetDay(today) } - initializeDayDietRealtime({ + realtimeService.initializeDayDietRealtime({ onInsert(newDayDiet) { dayCacheStore.upsertToCache(newDayDiet) }, diff --git a/src/modules/diet/day-diet/infrastructure/dayDietRepository.ts b/src/modules/diet/day-diet/infrastructure/dayDietRepository.ts index 715c5d9f2..cac259c73 100644 --- a/src/modules/diet/day-diet/infrastructure/dayDietRepository.ts +++ b/src/modules/diet/day-diet/infrastructure/dayDietRepository.ts @@ -49,5 +49,3 @@ export function createDayDietRepository(deps?: { await getGateway().deleteDayDietById(dayId), } } - -export type { DayRepository } diff --git a/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts b/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts index 59c9a3699..b2eeda268 100644 --- a/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts +++ b/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts @@ -6,46 +6,57 @@ import { SUPABASE_TABLE_DAYS } from '~/modules/diet/day-diet/infrastructure/supa import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' -let initialized = false - -export function initializeDayDietRealtime(callbacks: { +type DayDietRealtimeCallbacks = { onInsert: (newDayDiet: DayDiet) => void onUpdate: (newDayDiet: DayDiet) => void onDelete: (oldDayDiet: DayDiet) => void -}): void { - if (initialized) { - return - } - logging.debug(`Day diet realtime initialized!`) - initialized = true - registerSubapabaseRealtimeCallback( - SUPABASE_TABLE_DAYS, - dayDietSchema, - (event) => { - logging.debug(`Event:`, event) - - switch (event.eventType) { - case 'INSERT': { - if (event.new !== undefined) { - callbacks.onInsert(event.new) +} + +export function createDayDietRealtimeService() { + let initialized = false + + function initializeDayDietRealtime( + callbacks: DayDietRealtimeCallbacks, + ): void { + if (initialized) { + return + } + + logging.debug(`Day diet realtime initialized!`) + initialized = true + registerSubapabaseRealtimeCallback( + SUPABASE_TABLE_DAYS, + dayDietSchema, + (event) => { + logging.debug(`Event:`, event) + + switch (event.eventType) { + case 'INSERT': { + if (event.new !== undefined) { + callbacks.onInsert(event.new) + } + break } - break - } - case 'UPDATE': { - if (event.new) { - callbacks.onUpdate(event.new) + case 'UPDATE': { + if (event.new) { + callbacks.onUpdate(event.new) + } + break } - break - } - case 'DELETE': { - if (event.old) { - callbacks.onDelete(event.old) + case 'DELETE': { + if (event.old) { + callbacks.onDelete(event.old) + } + break } - break } - } - }, - ) + }, + ) + } + + return { + initializeDayDietRealtime, + } } diff --git a/src/modules/diet/food/application/usecases/foodCrud.ts b/src/modules/diet/food/application/usecases/foodCrud.ts index 89ed9a547..61bec3d8b 100644 --- a/src/modules/diet/food/application/usecases/foodCrud.ts +++ b/src/modules/diet/food/application/usecases/foodCrud.ts @@ -1,10 +1,7 @@ import { type Food } from '~/modules/diet/food/domain/food' import { type FoodSearchParams } from '~/modules/diet/food/domain/foodRepository' import { type FoodRepository } from '~/modules/diet/food/domain/foodRepository' -import { - importFoodFromApiByEan, - importFoodsFromApiByName, -} from '~/modules/diet/food/infrastructure/api/application/apiFood' +import { createApiFoodImportService } from '~/modules/diet/food/infrastructure/api/application/apiFood' import { createCachedSearchCrud } from '~/modules/search/application/usecases/cachedSearchCrud' import { showPromise } from '~/modules/toast/application/toastManager' import { setBackendOutage } from '~/shared/error/backendOutageSignal' @@ -16,9 +13,17 @@ import { logging } from '~/shared/utils/logging' * Factory that returns food-related use-cases with injected dependencies. * Allows replacing the repository implementation (e.g. for guest mode or tests). */ -export function createFoodCrud(deps: { repository: () => FoodRepository }) { +export function createFoodCrud(deps: { + repository: () => FoodRepository + createApiFoodImportService?: typeof createApiFoodImportService +}) { const foodRepository = deps.repository() + const localCreateApiFoodImportService = + deps.createApiFoodImportService ?? createApiFoodImportService const cachedSearchCrud = createCachedSearchCrud() + const apiFoodImportService = localCreateApiFoodImportService({ + foodRepository, + }) return { async fetchFoods(params: FoodSearchParams = {}): Promise { @@ -40,7 +45,7 @@ export function createFoodCrud(deps: { repository: () => FoodRepository }) { if (!isCached) { await showPromise( - importFoodsFromApiByName(name), + apiFoodImportService.importFoodsFromApiByName(name), { loading: 'Importando alimentos...', success: 'Alimentos importados com sucesso', @@ -75,7 +80,7 @@ export function createFoodCrud(deps: { repository: () => FoodRepository }) { ): Promise { try { await showPromise( - importFoodFromApiByEan(ean), + apiFoodImportService.importFoodFromApiByEan(ean), { loading: 'Importando alimento...', success: 'Alimento importado com sucesso', diff --git a/src/modules/diet/food/infrastructure/api/application/apiFood.ts b/src/modules/diet/food/infrastructure/api/application/apiFood.ts index a53c7f352..c5d027e02 100644 --- a/src/modules/diet/food/infrastructure/api/application/apiFood.ts +++ b/src/modules/diet/food/infrastructure/api/application/apiFood.ts @@ -1,6 +1,7 @@ import axios from 'axios' import { type Food } from '~/modules/diet/food/domain/food' +import { type FoodRepository } from '~/modules/diet/food/domain/foodRepository' import { type ApiFood } from '~/modules/diet/food/infrastructure/api/domain/apiFoodSchema' import { createSupabaseFoodRepository } from '~/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodRepository' import { createCachedSearchCrud } from '~/modules/search/application/usecases/cachedSearchCrud' @@ -9,112 +10,127 @@ import { convertApi2Food } from '~/shared/utils/convertApi2Food' import { ORIGINAL_ERROR_SYMBOL } from '~/shared/utils/errorUtils' import { logging } from '~/shared/utils/logging' -const foodRepository = createSupabaseFoodRepository() -const cachedSearchCrud = createCachedSearchCrud() +export function createApiFoodImportService(deps?: { + foodRepository?: FoodRepository + cachedSearchCrud?: ReturnType + showError?: typeof showError +}) { + const foodRepository = deps?.foodRepository ?? createSupabaseFoodRepository() + const cachedSearchCrud = deps?.cachedSearchCrud ?? createCachedSearchCrud() + const localShowError = deps?.showError ?? showError + + async function importFoodFromApiByEan( + ean: Food['ean'], + ): Promise { + if (ean === null) { + logging.error('EAN is required to import food from API:', { ean }) + return null + } -export async function importFoodFromApiByEan( - ean: Food['ean'], -): Promise { - if (ean === null) { - logging.error('EAN is required to import food from API:', { ean }) - return null - } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const apiFood = (await axios.get(`/api/food/ean/${ean}`)).data - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const apiFood = (await axios.get(`/api/food/ean/${ean}`)).data + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (apiFood.id === 0) { + logging.error(`Food with ean ${ean} not found on external api:`, { ean }) + return null + } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (apiFood.id === 0) { - logging.error(`Food with ean ${ean} not found on external api:`, { ean }) - return null + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const food = convertApi2Food(apiFood) + const upsertedFood = await foodRepository.upsertFood(food) + return upsertedFood } - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - const food = convertApi2Food(apiFood) - const upsertedFood = await foodRepository.upsertFood(food) - return upsertedFood -} - -export async function importFoodsFromApiByName(name: string): Promise { - logging.debug(`Importing foods with name "${name}"`) - - const apiFoods = (await axios.get(`/api/food/name/${name}`)).data + async function importFoodsFromApiByName(name: string): Promise { + logging.debug(`Importing foods with name "${name}"`) - if (apiFoods.length === 0) { - showError(`Nenhum alimento encontrado para "${name}"`) - return [] - } - - logging.debug(`Found ${apiFoods.length} foods`) + const apiFoods = (await axios.get(`/api/food/name/${name}`)).data - const foodsToupsert = apiFoods.map(convertApi2Food) + if (apiFoods.length === 0) { + localShowError(`Nenhum alimento encontrado para "${name}"`) + return [] + } - const upsertPromises = foodsToupsert.map(foodRepository.upsertFood) + logging.debug(`Found ${apiFoods.length} foods`) - const upsertionResults = await Promise.allSettled(upsertPromises) - logging.debug( - `upserted ${upsertionResults.length} foods. ${ - upsertionResults.filter((result) => result.status === 'fulfilled').length - } succeeded, ${ - upsertionResults.filter((result) => result.status === 'rejected').length - } failed`, - ) + const foodsToupsert = apiFoods.map(convertApi2Food) - if (upsertionResults.some((result) => result.status === 'rejected')) { - logging.debug(`Erros de upsert: `, { upsertionResults }) - const allRejected = upsertionResults.filter( - (result) => result.status === 'rejected', - ) + const upsertPromises = foodsToupsert.map(foodRepository.upsertFood) - const reasons = allRejected.map((result) => { - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const reason: Error = result.reason as unknown as Error - return reason - }) - const errors = reasons.map( - // eslint-disable-next-line - (reason) => (reason as any)[ORIGINAL_ERROR_SYMBOL].code as string, + const upsertionResults = await Promise.allSettled(upsertPromises) + logging.debug( + `upserted ${upsertionResults.length} foods. ${ + upsertionResults.filter((result) => result.status === 'fulfilled') + .length + } succeeded, ${ + upsertionResults.filter((result) => result.status === 'rejected').length + } failed`, ) - logging.debug(`Readable errors:`, { errors }) - - const ignoredErrors = [ - '23505', // Unique violation: food already exists, ignore - ] - const relevantErrors = errors.filter( - (error) => !ignoredErrors.includes(error), - ) + if (upsertionResults.some((result) => result.status === 'rejected')) { + logging.debug(`Erros de upsert: `, { upsertionResults }) + const allRejected = upsertionResults.filter( + (result) => result.status === 'rejected', + ) - if (relevantErrors.length > 0) { - logging.debug(`Relevant errors:`, { relevantErrors }) - logging.error(`Failed to upsert ${relevantErrors.length} foods:`, { - operation: 'searchAndUpsertFoodsByNameFromApi', - name, - relevantErrors, - errorCount: relevantErrors.length, + const reasons = allRejected.map((result) => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const reason: Error = result.reason as unknown as Error + return reason }) + const errors = reasons.map( + // eslint-disable-next-line + (reason) => (reason as any)[ORIGINAL_ERROR_SYMBOL].code as string, + ) + logging.debug(`Readable errors:`, { errors }) + + const ignoredErrors = [ + '23505', // Unique violation: food already exists, ignore + ] - showError( - `Erro ao importar alguns alimentos: ${relevantErrors.length} falhas. Verifique o console para mais detalhes.`, - { context: 'user-action' }, + const relevantErrors = errors.filter( + (error) => !ignoredErrors.includes(error), ) + + if (relevantErrors.length > 0) { + logging.debug(`Relevant errors:`, { relevantErrors }) + logging.error(`Failed to upsert ${relevantErrors.length} foods:`, { + operation: 'searchAndUpsertFoodsByNameFromApi', + name, + relevantErrors, + errorCount: relevantErrors.length, + }) + + localShowError( + `Erro ao importar alguns alimentos: ${relevantErrors.length} falhas. Verifique o console para mais detalhes.`, + { context: 'user-action' }, + ) + } else { + logging.debug('No RELEVANT failed upsertions, marking search as cached') + await cachedSearchCrud.markSearchAsCached(name) + } } else { - logging.debug('No RELEVANT failed upsertions, marking search as cached') + logging.debug('No failed upsertions, marking search as cached') await cachedSearchCrud.markSearchAsCached(name) } - } else { - logging.debug('No failed upsertions, marking search as cached') - await cachedSearchCrud.markSearchAsCached(name) - } - const upsertedFoods: ReadonlyArray = upsertionResults - .filter( - (result): result is PromiseFulfilledResult => - result.status === 'fulfilled', - ) - .map((result) => result.value) + const upsertedFoods: ReadonlyArray = upsertionResults + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) + .map((result) => result.value) + + logging.debug(` Returning ${upsertedFoods.length}/${apiFoods.length} foods`) - logging.debug(` Returning ${upsertedFoods.length}/${apiFoods.length} foods`) + return upsertedFoods.filter((food): food is Food => food !== null) + } - return upsertedFoods.filter((food): food is Food => food !== null) + return { + importFoodFromApiByEan, + importFoodsFromApiByName, + } } + +export type ApiFoodImportService = ReturnType diff --git a/src/modules/diet/macro-profile/application/usecases/macroProfileState.ts b/src/modules/diet/macro-profile/application/usecases/macroProfileState.ts index 572b937a0..8e831a7ff 100644 --- a/src/modules/diet/macro-profile/application/usecases/macroProfileState.ts +++ b/src/modules/diet/macro-profile/application/usecases/macroProfileState.ts @@ -6,7 +6,7 @@ import { createDefaultMacroProfile, getLatestMacroProfile, } from '~/modules/diet/macro-profile/domain/macroProfileOperations' -import { initializeMacroProfileRealtime } from '~/modules/diet/macro-profile/infrastructure/supabase/realtime' +import { createMacroProfileRealtimeService } from '~/modules/diet/macro-profile/infrastructure/supabase/realtime' import { type User } from '~/modules/user/domain/user' import { GUEST_USER_ID } from '~/shared/guest/guestConstants' import { logging } from '~/shared/utils/logging' @@ -19,18 +19,19 @@ export function createMacroProfileState(deps: { userId: User['uuid'], ) => Promise cache?: MacroProfileCache - initializeMacroProfileRealtime?: typeof initializeMacroProfileRealtime + createMacroProfileRealtimeService?: typeof createMacroProfileRealtimeService }) { const localCache = deps.cache ?? createMacroProfileCacheStore() - const localInitializeRealtime = - deps.initializeMacroProfileRealtime ?? initializeMacroProfileRealtime + const localCreateMacroProfileRealtimeService = + deps.createMacroProfileRealtimeService ?? createMacroProfileRealtimeService return createRoot(() => { + const realtimeService = localCreateMacroProfileRealtimeService() const [selectedUserId, setSelectedUserId] = createSignal< User['uuid'] | null >(null) - localInitializeRealtime({ + realtimeService.initializeMacroProfileRealtime({ onInsert: (profile: MacroProfile) => { localCache.upsertToCache(profile) }, diff --git a/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts b/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts index 3539eb5e6..24f489691 100644 --- a/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts +++ b/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts @@ -6,46 +6,57 @@ import { SUPABASE_TABLE_MACRO_PROFILES } from '~/modules/diet/macro-profile/infr import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' -let initialized = false - -export function initializeMacroProfileRealtime(callbacks: { +type MacroProfileRealtimeCallbacks = { onInsert: (profile: MacroProfile) => void onUpdate: (profile: MacroProfile) => void onDelete: (profile: MacroProfile) => void -}): void { - if (initialized) { - return - } - logging.debug(`Macro profile realtime initialized!`) - initialized = true - registerSubapabaseRealtimeCallback( - SUPABASE_TABLE_MACRO_PROFILES, - macroProfileSchema, - (event) => { - logging.debug(`Event:`, event) - - switch (event.eventType) { - case 'INSERT': { - if (event.new) { - callbacks.onInsert(event.new) +} + +export function createMacroProfileRealtimeService() { + let initialized = false + + function initializeMacroProfileRealtime( + callbacks: MacroProfileRealtimeCallbacks, + ): void { + if (initialized) { + return + } + + logging.debug(`Macro profile realtime initialized!`) + initialized = true + registerSubapabaseRealtimeCallback( + SUPABASE_TABLE_MACRO_PROFILES, + macroProfileSchema, + (event) => { + logging.debug(`Event:`, event) + + switch (event.eventType) { + case 'INSERT': { + if (event.new) { + callbacks.onInsert(event.new) + } + break } - break - } - case 'UPDATE': { - if (event.new) { - callbacks.onUpdate(event.new) + case 'UPDATE': { + if (event.new) { + callbacks.onUpdate(event.new) + } + break } - break - } - case 'DELETE': { - if (event.old) { - callbacks.onDelete(event.old) + case 'DELETE': { + if (event.old) { + callbacks.onDelete(event.old) + } + break } - break } - } - }, - ) + }, + ) + } + + return { + initializeMacroProfileRealtime, + } } diff --git a/src/modules/observability/application/telemetry.ts b/src/modules/observability/application/telemetry.ts index daaf2403a..160d6d169 100644 --- a/src/modules/observability/application/telemetry.ts +++ b/src/modules/observability/application/telemetry.ts @@ -1,12 +1,12 @@ -import { type initializeSentry as defaultInitializeSentry } from '~/modules/observability/infrastructure/sentry/sentry' +import { type createSentryService } from '~/modules/observability/infrastructure/sentry/sentry' export type TelemetryDeps = { - initializeSentry: typeof defaultInitializeSentry + sentryService: ReturnType } export function createTelemetry(deps: TelemetryDeps) { function initializeTelemetry(type: 'server' | 'client') { - void deps.initializeSentry(type) + void deps.sentryService.initializeSentry(type) } return { diff --git a/src/modules/observability/infrastructure/sentry/sentry.ts b/src/modules/observability/infrastructure/sentry/sentry.ts index abdc46b19..730c01a1e 100644 --- a/src/modules/observability/infrastructure/sentry/sentry.ts +++ b/src/modules/observability/infrastructure/sentry/sentry.ts @@ -3,66 +3,82 @@ import * as Sentry from '@sentry/solidstart' import { createClientIntegrations } from '~/modules/observability/infrastructure/sentry/clientIntegrations' import { createSentryConfig } from '~/modules/observability/infrastructure/sentry/config' import { setupSentryOTelIntegration } from '~/modules/observability/infrastructure/sentry/otelIntegration' -let isInitialized = false -export async function initializeSentry(type: 'server' | 'client') { - if (isInitialized) { - console.warn('Sentry already initialized') - return - } - - try { - const config = createSentryConfig() +export function createSentryService(deps?: { + createSentryConfig?: typeof createSentryConfig + createClientIntegrations?: typeof createClientIntegrations + setupSentryOTelIntegration?: typeof setupSentryOTelIntegration +}) { + const localCreateSentryConfig = deps?.createSentryConfig ?? createSentryConfig + const localCreateClientIntegrations = + deps?.createClientIntegrations ?? createClientIntegrations + const localSetupSentryOTelIntegration = + deps?.setupSentryOTelIntegration ?? setupSentryOTelIntegration + let isInitialized = false - // Only initialize if DSN is provided - if (config.dsn === undefined || config.dsn === '') { - console.warn('❌ Sentry DSN not provided - skipping initialization', { - VITE_SENTRY_DSN: String(import.meta.env.VITE_SENTRY_DSN), - }) + async function initializeSentry(type: 'server' | 'client') { + if (isInitialized) { + console.warn('Sentry already initialized') return } - console.log( - '🚀 Initializing Sentry with DSN:', - config.dsn.substring(0, 20) + '...', - ) + try { + const config = localCreateSentryConfig() + + // Only initialize if DSN is provided + if (config.dsn === undefined || config.dsn === '') { + console.warn('❌ Sentry DSN not provided - skipping initialization', { + VITE_SENTRY_DSN: String(import.meta.env.VITE_SENTRY_DSN), + }) + return + } - Sentry.init({ - dsn: config.dsn, - release: config.release, - tracesSampleRate: 1.0, + console.log( + '🚀 Initializing Sentry with DSN:', + config.dsn.substring(0, 20) + '...', + ) - // SolidStart specific configuration - sendDefaultPii: true, - tracePropagationTargets: [ - 'localhost', - /^https:\/\/.*\.supabase\.co/, - /^https:\/\/.*\.macroflows\.app/, - /^https:\/\/.*\.macroflows.*\.app/, - ], + Sentry.init({ + dsn: config.dsn, + release: config.release, + tracesSampleRate: 1.0, - integrations: - type === 'client' - ? await createClientIntegrations() - : [Sentry.consoleLoggingIntegration()], + // SolidStart specific configuration + sendDefaultPii: true, + tracePropagationTargets: [ + 'localhost', + /^https:\/\/.*\.supabase\.co/, + /^https:\/\/.*\.macroflows\.app/, + /^https:\/\/.*\.macroflows.*\.app/, + ], - // Session Replay configuration - replaysSessionSampleRate: 1.0, - replaysOnErrorSampleRate: 1.0, + integrations: + type === 'client' + ? await localCreateClientIntegrations() + : [Sentry.consoleLoggingIntegration()], - // Set sample rate for profiling - profilesSampleRate: 1.0, + // Session Replay configuration + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 1.0, - enableLogs: true, - }) + // Set sample rate for profiling + profilesSampleRate: 1.0, - if (config.useOTel) { - await setupSentryOTelIntegration(type) + enableLogs: true, + }) + + if (config.useOTel) { + await localSetupSentryOTelIntegration(type) + } + + isInitialized = true + } catch (error) { + console.error('Failed to initialize Sentry:', error) + // Don't throw - Sentry should not break the application } + } - isInitialized = true - } catch (error) { - console.error('Failed to initialize Sentry:', error) - // Don't throw - Sentry should not break the application + return { + initializeSentry, } } diff --git a/src/modules/weight/application/weight/usecases/weightUseCases.ts b/src/modules/weight/application/weight/usecases/weightUseCases.ts index b1b59fea5..bd6952135 100644 --- a/src/modules/weight/application/weight/usecases/weightUseCases.ts +++ b/src/modules/weight/application/weight/usecases/weightUseCases.ts @@ -10,7 +10,7 @@ import { } from '~/modules/weight/domain/weight/weight' import { WeightsExt } from '~/modules/weight/domain/weight/weightsExt' import { createLocalStorageWeightCacheRepository } from '~/modules/weight/infrastructure/weight/localStorage/localStorageWeightCacheRepository' -import { initializeWeightRealtime } from '~/modules/weight/infrastructure/weight/supabase/realtime' +import { createWeightRealtimeService } from '~/modules/weight/infrastructure/weight/supabase/realtime' import { createWeightRepository } from '~/modules/weight/infrastructure/weight/supabase/supabaseWeightRepository' import { logging } from '~/shared/utils/logging' import { parseWithStack } from '~/shared/utils/parseWithStack' @@ -39,7 +39,7 @@ export function createWeightUseCases(deps: { createLocalStorageWeightCacheRepository?: typeof createLocalStorageWeightCacheRepository createWeightRepository?: typeof createWeightRepository createWeightCacheStore?: typeof createWeightCacheStore - initializeWeightRealtime?: typeof initializeWeightRealtime + createWeightRealtimeService?: typeof createWeightRealtimeService createWeightCrudService?: typeof createWeightCrudService parseWithStack?: typeof parseWithStack }) { @@ -48,7 +48,7 @@ export function createWeightUseCases(deps: { createLocalStorageWeightCacheRepository: injectedCreateLocalStorage, createWeightRepository: injectedCreateWeightRepository, createWeightCacheStore: injectedCreateWeightCacheStore, - initializeWeightRealtime: injectedInitializeRealtime, + createWeightRealtimeService: injectedCreateWeightRealtimeService, createWeightCrudService: injectedCreateWeightCrudService, parseWithStack: injectedParseWithStack, } = deps @@ -59,8 +59,8 @@ export function createWeightUseCases(deps: { injectedCreateWeightRepository ?? createWeightRepository const localCreateWeightCacheStore = injectedCreateWeightCacheStore ?? createWeightCacheStore - const localInitializeRealtime = - injectedInitializeRealtime ?? initializeWeightRealtime + const localCreateWeightRealtimeService = + injectedCreateWeightRealtimeService ?? createWeightRealtimeService const localCreateWeightCrudService = injectedCreateWeightCrudService ?? createWeightCrudService const localParseWithStack = injectedParseWithStack ?? parseWithStack @@ -71,6 +71,7 @@ export function createWeightUseCases(deps: { const weightRepository = localCreateWeightRepository({ isGuestMode: authDeps.isGuestMode, }) + const realtimeService = localCreateWeightRealtimeService() let realtimeInitialized = false function initializeRealtime() { @@ -79,7 +80,7 @@ export function createWeightUseCases(deps: { } realtimeInitialized = true - localInitializeRealtime({ + realtimeService.initializeWeightRealtime({ onInsert: (weight: Weight) => { cache.upsertToCache(weight) }, diff --git a/src/modules/weight/infrastructure/weight/supabase/realtime.ts b/src/modules/weight/infrastructure/weight/supabase/realtime.ts index 93d19ce92..9d358ebbe 100644 --- a/src/modules/weight/infrastructure/weight/supabase/realtime.ts +++ b/src/modules/weight/infrastructure/weight/supabase/realtime.ts @@ -6,46 +6,55 @@ import { SUPABASE_TABLE_WEIGHTS } from '~/modules/weight/infrastructure/weight/s import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' -let initialized = false - -export function initializeWeightRealtime(callbacks: { +type WeightRealtimeCallbacks = { onInsert: (weight: Weight) => void onUpdate: (weight: Weight) => void onDelete: (weight: Weight) => void -}): void { - if (initialized) { - return - } - logging.debug(`Weight realtime initialized!`) - initialized = true - registerSubapabaseRealtimeCallback( - SUPABASE_TABLE_WEIGHTS, - weightSchema, - (event) => { - logging.debug(`Weight realtime event ${event.eventType}:`, event) - - switch (event.eventType) { - case 'INSERT': { - if (event.new !== undefined) { - callbacks.onInsert(event.new) +} + +export function createWeightRealtimeService() { + let initialized = false + + function initializeWeightRealtime(callbacks: WeightRealtimeCallbacks): void { + if (initialized) { + return + } + + logging.debug(`Weight realtime initialized!`) + initialized = true + registerSubapabaseRealtimeCallback( + SUPABASE_TABLE_WEIGHTS, + weightSchema, + (event) => { + logging.debug(`Weight realtime event ${event.eventType}:`, event) + + switch (event.eventType) { + case 'INSERT': { + if (event.new !== undefined) { + callbacks.onInsert(event.new) + } + break } - break - } - case 'UPDATE': { - if (event.new) { - callbacks.onUpdate(event.new) + case 'UPDATE': { + if (event.new) { + callbacks.onUpdate(event.new) + } + break } - break - } - case 'DELETE': { - if (event.old) { - callbacks.onDelete(event.old) + case 'DELETE': { + if (event.old) { + callbacks.onDelete(event.old) + } + break } - break } - } - }, - ) + }, + ) + } + + return { + initializeWeightRealtime, + } } From 698e7c43e6bceb4e9c1cfc0f3fa951f698f57a03 Mon Sep 17 00:00:00 2001 From: marcuscastelo Date: Tue, 24 Mar 2026 18:47:48 -0300 Subject: [PATCH 2/4] refactor(infrastructure): inline data mappers, constants, and caching logic --- .../supabase/supabaseAuthGateway.ts | 54 +++- .../supabase/supabaseAuthMapper.ts | 65 ---- .../infrastructure/supabase/constants.ts | 1 - .../infrastructure/supabase/realtime.ts | 3 +- .../supabase/supabaseDayGateway.ts | 297 ++++++++++++++++- .../supabase/supabaseDayMapper.ts | 37 --- .../apiFoodSchema.ts => domain/apiFood.ts} | 0 .../api => }/domain/apiFoodRepository.ts | 2 +- .../infrastructure/api/application/apiFood.ts | 4 +- .../infrastructure/api/apiFoodRepository.ts | 7 +- .../supabase/supabaseFoodMapper.ts | 45 --- .../supabase/supabaseFoodRepository.ts | 79 ++++- .../food/infrastructure/supabase/constants.ts | 1 - .../supabase/supabaseItemMapper.ts | 189 ----------- .../supabase/supabaseMacroNutrientsMapper.ts | 44 --- .../supabaseMacroNutrientsMapper.test.ts | 34 -- .../infrastructure/supabase/constants.ts | 1 - .../infrastructure/supabase/realtime.ts | 3 +- .../supabase/supabaseMacroProfileGateway.ts | 42 ++- .../supabase/supabaseMacroProfileMapper.ts | 39 --- .../supabase/supabaseMealMapper.ts | 47 --- .../infrastructure/recentFoodRepository.ts | 23 +- .../infrastructure/supabase/constants.ts | 1 - .../infrastructure/supabase/realtime.ts | 83 ++--- .../supabase/supabaseRecentFoodGateway.ts | 276 +++++++++++++--- .../supabase/supabaseRecentFoodMapper.ts | 49 --- .../recipe/infrastructure/recipeRepository.ts | 49 ++- .../signals/recipeCacheStore.ts | 35 -- .../infrastructure/supabase/constants.ts | 1 - .../infrastructure/supabase/realtime.ts | 30 +- .../supabase/supabaseRecipeGateway.ts | 302 +++++++++++++++--- .../supabase/supabaseRecipeMapper.ts | 47 --- .../supabase/supabaseBodyMeasureGateway.ts | 42 ++- .../supabase/supabaseMeasureMapper.ts | 48 --- .../measureInfrastructureExports.test.ts | 7 - .../observability/application/telemetry.ts | 6 +- .../sentry/clientIntegrations.ts | 20 -- .../infrastructure/sentry/config.ts | 20 -- .../infrastructure/sentry/otelIntegration.ts | 37 --- .../infrastructure/sentry/sentry.ts | 82 ++++- .../infrastructure/cachedSearchRepository.ts | 38 +-- .../signals/cachedSearchCacheStore.ts | 50 --- .../signals/cachedSearchEffects.ts | 24 -- .../infrastructure/supabase/constants.ts | 1 - .../infrastructure/supabase/realtime.ts | 67 ---- .../supabase/supabaseCachedSearchGateway.ts | 20 +- .../supabase/supabaseCachedSearchMapper.ts | 35 -- .../tests/supabaseCachedSearchMapper.test.ts | 28 -- .../user/infrastructure/supabase/constants.ts | 1 - .../supabase/supabaseUserMapper.ts | 50 --- .../supabase/supabaseUserRepository.ts | 58 +++- .../weight/supabase/constants.ts | 1 - .../weight/supabase/realtime.ts | 3 +- .../weight/supabase/supabaseWeightGateway.ts | 43 ++- .../weight/supabase/supabaseWeightMapper.ts | 39 --- src/shared/utils/convertApi2Food.ts | 2 +- .../utils/tests/convertApi2Food.test.ts | 2 +- 57 files changed, 1278 insertions(+), 1336 deletions(-) delete mode 100644 src/modules/auth/infrastructure/supabase/supabaseAuthMapper.ts delete mode 100644 src/modules/diet/day-diet/infrastructure/supabase/constants.ts delete mode 100644 src/modules/diet/day-diet/infrastructure/supabase/supabaseDayMapper.ts rename src/modules/diet/food/{infrastructure/api/domain/apiFoodSchema.ts => domain/apiFood.ts} (100%) rename src/modules/diet/food/{infrastructure/api => }/domain/apiFoodRepository.ts (73%) delete mode 100644 src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodMapper.ts delete mode 100644 src/modules/diet/food/infrastructure/supabase/constants.ts delete mode 100644 src/modules/diet/item/infrastructure/supabase/supabaseItemMapper.ts delete mode 100644 src/modules/diet/macro-nutrients/infrastructure/supabase/supabaseMacroNutrientsMapper.ts delete mode 100644 src/modules/diet/macro-nutrients/tests/supabaseMacroNutrientsMapper.test.ts delete mode 100644 src/modules/diet/macro-profile/infrastructure/supabase/constants.ts delete mode 100644 src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileMapper.ts delete mode 100644 src/modules/diet/meal/infrastructure/supabase/supabaseMealMapper.ts delete mode 100644 src/modules/diet/recent-food/infrastructure/supabase/constants.ts delete mode 100644 src/modules/diet/recent-food/infrastructure/supabase/supabaseRecentFoodMapper.ts delete mode 100644 src/modules/diet/recipe/infrastructure/signals/recipeCacheStore.ts delete mode 100644 src/modules/diet/recipe/infrastructure/supabase/constants.ts delete mode 100644 src/modules/diet/recipe/infrastructure/supabase/supabaseRecipeMapper.ts delete mode 100644 src/modules/measure/infrastructure/supabase/supabaseMeasureMapper.ts delete mode 100644 src/modules/observability/infrastructure/sentry/clientIntegrations.ts delete mode 100644 src/modules/observability/infrastructure/sentry/config.ts delete mode 100644 src/modules/observability/infrastructure/sentry/otelIntegration.ts delete mode 100644 src/modules/search/infrastructure/signals/cachedSearchCacheStore.ts delete mode 100644 src/modules/search/infrastructure/signals/cachedSearchEffects.ts delete mode 100644 src/modules/search/infrastructure/supabase/constants.ts delete mode 100644 src/modules/search/infrastructure/supabase/realtime.ts delete mode 100644 src/modules/search/infrastructure/supabase/supabaseCachedSearchMapper.ts delete mode 100644 src/modules/search/infrastructure/supabase/tests/supabaseCachedSearchMapper.test.ts delete mode 100644 src/modules/user/infrastructure/supabase/constants.ts delete mode 100644 src/modules/user/infrastructure/supabase/supabaseUserMapper.ts delete mode 100644 src/modules/weight/infrastructure/weight/supabase/constants.ts delete mode 100644 src/modules/weight/infrastructure/weight/supabase/supabaseWeightMapper.ts diff --git a/src/modules/auth/infrastructure/supabase/supabaseAuthGateway.ts b/src/modules/auth/infrastructure/supabase/supabaseAuthGateway.ts index 34391f8a8..3a8a75bc3 100644 --- a/src/modules/auth/infrastructure/supabase/supabaseAuthGateway.ts +++ b/src/modules/auth/infrastructure/supabase/supabaseAuthGateway.ts @@ -1,4 +1,4 @@ -import type { AuthChangeEvent, Session } from '@supabase/supabase-js' +import type { AuthChangeEvent, Session, User } from '@supabase/supabase-js' import type { AuthSession, @@ -10,7 +10,51 @@ import type { AuthGateway } from '~/modules/auth/domain/authGateway' import { supabase } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' -import { supabaseAuthMapper } from './supabaseAuthMapper' +function mapUserToDomain(user: User | null): AuthUser | null { + if (user === null) return null + + return { + id: user.id, + email: user.email ?? 'unknown@example.com', + emailConfirmedAt: user.email_confirmed_at ?? undefined, + lastSignInAt: user.last_sign_in_at ?? undefined, + createdAt: + user.created_at !== '' ? user.created_at : new Date().toISOString(), + updatedAt: + user.updated_at !== undefined && user.updated_at !== '' + ? user.updated_at + : new Date().toISOString(), + userMetadata: user.user_metadata, + appMetadata: user.app_metadata, + } +} + +function mapSessionToDomain(session: Session | null): AuthSession | null { + if (session === null) return null + + return { + access_token: session.access_token, + refresh_token: session.refresh_token, + expires_at: session.expires_at ?? 0, + token_type: session.token_type, + user: { + id: session.user.id, + email: session.user.email ?? '', + email_confirmed_at: session.user.email_confirmed_at ?? undefined, + last_sign_in_at: session.user.last_sign_in_at ?? undefined, + created_at: + session.user.created_at !== '' + ? session.user.created_at + : new Date().toISOString(), + updated_at: + session.user.updated_at !== undefined && session.user.updated_at !== '' + ? session.user.updated_at + : new Date().toISOString(), + user_metadata: session.user.user_metadata, + app_metadata: session.user.app_metadata, + }, + } +} export function createSupabaseAuthGateway(): AuthGateway { return { @@ -23,7 +67,7 @@ export function createSupabaseAuthGateway(): AuthGateway { throw new Error('Failed to get session', { cause: error }) } - return supabaseAuthMapper.mapSessionToDomain(data.session) + return mapSessionToDomain(data.session) } catch (error) { logging.error('SupabaseAuthRepository getSession error:', error) throw error @@ -38,7 +82,7 @@ export function createSupabaseAuthGateway(): AuthGateway { throw new Error('Failed to get user', { cause: error }) } - return supabaseAuthMapper.mapUserToDomain(data.user) + return mapUserToDomain(data.user) } catch (error) { logging.error('SupabaseAuthRepository getUser error:', error) throw error @@ -101,7 +145,7 @@ export function createSupabaseAuthGateway(): AuthGateway { data: { subscription }, } = supabase.auth.onAuthStateChange( (event: AuthChangeEvent, session: Session | null) => { - callback(event, supabaseAuthMapper.mapSessionToDomain(session)) + callback(event, mapSessionToDomain(session)) }, ) diff --git a/src/modules/auth/infrastructure/supabase/supabaseAuthMapper.ts b/src/modules/auth/infrastructure/supabase/supabaseAuthMapper.ts deleted file mode 100644 index 48212f8a2..000000000 --- a/src/modules/auth/infrastructure/supabase/supabaseAuthMapper.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { Session, User } from '@supabase/supabase-js' - -import type { AuthSession, AuthUser } from '~/modules/auth/domain/auth' - -/** - * Maps Supabase User to domain AuthUser - */ -function mapSupabaseUserToAuthUser(user: User | null): AuthUser | null { - if (!user) return null - - return { - id: user.id, - email: user.email ?? 'unknown@example.com', - emailConfirmedAt: user.email_confirmed_at ?? undefined, - lastSignInAt: user.last_sign_in_at ?? undefined, - createdAt: - user.created_at !== '' ? user.created_at : new Date().toISOString(), - updatedAt: - user.updated_at !== undefined && user.updated_at !== '' - ? user.updated_at - : new Date().toISOString(), - userMetadata: user.user_metadata, - appMetadata: user.app_metadata, - } -} - -/** - * Maps Supabase Session to domain AuthSession - */ -function mapSupabaseSessionToAuthSession( - session: Session | null, -): AuthSession | null { - if (session === null) return null - - return { - access_token: session.access_token, - refresh_token: session.refresh_token, - expires_at: session.expires_at ?? 0, - token_type: session.token_type, - user: { - id: session.user.id, - email: session.user.email ?? '', - email_confirmed_at: session.user.email_confirmed_at ?? undefined, - last_sign_in_at: session.user.last_sign_in_at ?? undefined, - created_at: - session.user.created_at !== '' - ? session.user.created_at - : new Date().toISOString(), - updated_at: - session.user.updated_at !== undefined && session.user.updated_at !== '' - ? session.user.updated_at - : new Date().toISOString(), - user_metadata: session.user.user_metadata, - app_metadata: session.user.app_metadata, - }, - } -} - -/** - * Centralized Supabase Auth mapper functions - */ -export const supabaseAuthMapper = { - mapUserToDomain: mapSupabaseUserToAuthUser, - mapSessionToDomain: mapSupabaseSessionToAuthSession, -} as const diff --git a/src/modules/diet/day-diet/infrastructure/supabase/constants.ts b/src/modules/diet/day-diet/infrastructure/supabase/constants.ts deleted file mode 100644 index d511d706f..000000000 --- a/src/modules/diet/day-diet/infrastructure/supabase/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const SUPABASE_TABLE_DAYS = 'days' diff --git a/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts b/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts index b2eeda268..ee8a1d909 100644 --- a/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts +++ b/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts @@ -2,10 +2,11 @@ import { type DayDiet, dayDietSchema, } from '~/modules/diet/day-diet/domain/dayDiet' -import { SUPABASE_TABLE_DAYS } from '~/modules/diet/day-diet/infrastructure/supabase/constants' import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' +const SUPABASE_TABLE_DAYS = 'days' + type DayDietRealtimeCallbacks = { onInsert: (newDayDiet: DayDiet) => void onUpdate: (newDayDiet: DayDiet) => void diff --git a/src/modules/diet/day-diet/infrastructure/supabase/supabaseDayGateway.ts b/src/modules/diet/day-diet/infrastructure/supabase/supabaseDayGateway.ts index bea2991c8..994d9c430 100644 --- a/src/modules/diet/day-diet/infrastructure/supabase/supabaseDayGateway.ts +++ b/src/modules/diet/day-diet/infrastructure/supabase/supabaseDayGateway.ts @@ -1,14 +1,290 @@ import { type DayDiet, + dayDietSchema, type NewDayDiet, } from '~/modules/diet/day-diet/domain/dayDiet' import { type DayGateway } from '~/modules/diet/day-diet/domain/dayDietGateway' -import { SUPABASE_TABLE_DAYS } from '~/modules/diet/day-diet/infrastructure/supabase/constants' -import { supabaseDayMapper } from '~/modules/diet/day-diet/infrastructure/supabase/supabaseDayMapper' +import { + isFoodItem, + type Item, + itemSchema, +} from '~/modules/diet/item/schema/itemSchema' +import { + createMacroNutrients, + type MacroNutrients, +} from '~/modules/diet/macro-nutrients/domain/macroNutrients' +import { + createNewMeal, + type Meal, + promoteMeal, +} from '~/modules/diet/meal/domain/meal' import { type User } from '~/modules/user/domain/user' +import { type Database, type Json } from '~/shared/supabase/database.types' import { supabase } from '~/shared/supabase/supabase' import { wrapErrorWithStack } from '~/shared/utils/errorUtils' import { logging } from '~/shared/utils/logging' +import { parseWithStack } from '~/shared/utils/parseWithStack' + +const SUPABASE_TABLE_DAYS = 'days' + +function macrosToDomain(macrosDTO: Json) { + if ( + macrosDTO === null || + !(typeof macrosDTO === 'object') || + !('carbs' in macrosDTO) || + !('protein' in macrosDTO) || + !('fat' in macrosDTO) || + typeof macrosDTO.carbs !== 'number' || + typeof macrosDTO.protein !== 'number' || + typeof macrosDTO.fat !== 'number' + ) { + throw new Error( + 'macrosDTO is missing macros field: ' + JSON.stringify(macrosDTO), + ) + } + + return createMacroNutrients({ + carbsInMg: macrosDTO.carbs * 1000, + proteinInMg: macrosDTO.protein * 1000, + fatInMg: macrosDTO.fat * 1000, + }) +} + +function macrosToDTO(macroNutrients: MacroNutrients): Json { + return { + carbs: macroNutrients.carbsInMg / 1000, + protein: macroNutrients.proteinInMg / 1000, + fat: macroNutrients.fatInMg / 1000, + } +} + +function itemToDomain(itemDTO: Json): Item { + if ( + itemDTO === null || + !(typeof itemDTO === 'object') || + !('id' in itemDTO) || + !('name' in itemDTO) || + !('quantity' in itemDTO) || + !('reference' in itemDTO) || + typeof itemDTO.id !== 'number' || + typeof itemDTO.name !== 'string' || + typeof itemDTO.quantity !== 'number' || + typeof itemDTO.reference !== 'object' || + itemDTO.reference === null || + Array.isArray(itemDTO.reference) || + !('type' in itemDTO.reference) + ) { + throw new Error( + 'Item DTO is missing required fields: ' + JSON.stringify(itemDTO), + ) + } + + const referenceType = itemDTO.reference.type + if ( + referenceType === 'food' && + (!('id' in itemDTO.reference) || + typeof itemDTO.reference.id !== 'number' || + !('macros' in itemDTO.reference) || + typeof itemDTO.reference.macros !== 'object' || + itemDTO.reference.macros === null) + ) { + throw new Error( + 'Food Item DTO reference is missing required fields: ' + + JSON.stringify(itemDTO.reference), + ) + } else if ( + referenceType === 'recipe' && + (!('id' in itemDTO.reference) || + typeof itemDTO.reference.id !== 'number' || + !('children' in itemDTO.reference) || + !Array.isArray(itemDTO.reference.children)) + ) { + throw new Error( + 'Recipe Item DTO reference is missing required fields: ' + + JSON.stringify(itemDTO.reference), + ) + } else if ( + referenceType === 'group' && + (!('children' in itemDTO.reference) || + !Array.isArray(itemDTO.reference.children)) + ) { + throw new Error( + 'Group Item DTO reference is missing required fields: ' + + JSON.stringify(itemDTO.reference), + ) + } + + if ( + referenceType !== 'food' && + referenceType !== 'recipe' && + referenceType !== 'group' + ) { + throw new Error( + 'Item DTO reference has invalid type: ' + + JSON.stringify(itemDTO.reference), + ) + } + + if (referenceType === 'food') { + if ( + itemDTO.reference.macros === null || + typeof itemDTO.reference.macros !== 'object' + ) { + throw new Error( + 'Food Item DTO reference macros is invalid: ' + + JSON.stringify(itemDTO.reference.macros), + ) + } + + return parseWithStack(itemSchema, { + id: itemDTO.id, + name: itemDTO.name, + quantity: itemDTO.quantity, + reference: { + type: 'food', + id: itemDTO.reference.id, + macros: macrosToDomain(itemDTO.reference.macros), + }, + __type: 'UnifiedItem', + }) + } + + if ( + itemDTO.reference.children === null || + !Array.isArray(itemDTO.reference.children) + ) { + throw new Error( + 'Item DTO reference children is invalid: ' + + JSON.stringify(itemDTO.reference.children), + ) + } + + const children: Item[] = itemDTO.reference.children.map((childDTO: Json) => + itemToDomain(childDTO), + ) + + if (referenceType === 'recipe') { + return parseWithStack(itemSchema, { + id: itemDTO.id, + name: itemDTO.name, + quantity: itemDTO.quantity, + reference: { + type: 'recipe', + id: itemDTO.reference.id, + children, + }, + __type: 'UnifiedItem' as const, + }) + } + + return parseWithStack(itemSchema, { + id: itemDTO.id, + name: itemDTO.name, + quantity: itemDTO.quantity, + reference: { + type: 'group', + children, + }, + __type: 'UnifiedItem' as const, + }) +} + +function itemToDTO(item: Item): Json { + if (isFoodItem(item)) { + return { + id: item.id, + name: item.name, + quantity: item.quantity, + reference: { + type: 'food', + id: item.reference.id, + macros: macrosToDTO(item.reference.macros), + }, + } + } + + if (item.reference.type === 'recipe') { + return { + id: item.id, + name: item.name, + quantity: item.quantity, + reference: { + type: 'recipe', + id: item.reference.id, + children: item.reference.children.map((child) => itemToDTO(child)), + }, + } + } + + return { + id: item.id, + name: item.name, + quantity: item.quantity, + reference: { + type: 'group', + children: item.reference.children.map((child) => itemToDTO(child)), + }, + } +} + +function mealToDomain(mealDTO: Json): Meal { + if ( + mealDTO === null || + !(typeof mealDTO === 'object') || + !('id' in mealDTO) || + !('name' in mealDTO) || + !('items' in mealDTO) || + typeof mealDTO.id !== 'number' || + typeof mealDTO.name !== 'string' || + !Array.isArray(mealDTO.items) + ) { + throw new Error( + 'Food DTO is missing meal field: ' + JSON.stringify(mealDTO), + ) + } + + return promoteMeal( + createNewMeal({ + name: mealDTO.name, + items: mealDTO.items.map((item: Json) => itemToDomain(item)), + }), + { id: mealDTO.id }, + ) +} + +function mealToDTO(meal: Meal): Json { + return { + ...meal, + items: meal.items.map((item) => itemToDTO(item)), + } +} + +function dayToInsertDTO(newDayDiet: NewDayDiet) { + return { + target_day: newDayDiet.target_day, + user_id: newDayDiet.user_id, + meals: newDayDiet.meals.map((meal) => mealToDTO(meal)), + } +} + +type DayDietRow = Database['public']['Tables']['days']['Row'] + +function dayToDomain(dto: DayDietRow): DayDiet { + if (!Array.isArray(dto.meals)) { + throw new Error('DayDiet DTO meals field is not an array') + } + + if (dto.user_id === null) { + throw new Error('DayDiet DTO user_id cannot be null') + } + + return parseWithStack(dayDietSchema, { + id: dto.id, + target_day: dto.target_day, + user_id: dto.user_id, + meals: dto.meals.map((meal) => mealToDomain(meal)), + }) +} export function createSupabaseDayGateway(): DayGateway { return { @@ -34,7 +310,7 @@ async function fetchDayDietById(dayId: DayDiet['id']): Promise { throw error } - return supabaseDayMapper.toDomain(data) + return dayToDomain(data) } catch (err) { logging.error('DayDiet fetch error:', err) throw err @@ -58,16 +334,16 @@ async function fetchDayDietByUserIdAndTargetDay( if (error !== null) { if (error.code === 'PGRST116') { - // No rows returned - day doesn't exist logging.debug(`[supabaseDayRepository] No day found for ${targetDay}`) return null } + logging.error('DayDiet fetch error:', error) throw error } logging.debug(`[supabaseDayRepository] Successfully fetched day ${targetDay}`) - return supabaseDayMapper.toDomain(data) + return dayToDomain(data) } async function fetchDayDietsByUserIdBeforeDate( @@ -92,29 +368,30 @@ async function fetchDayDietsByUserIdBeforeDate( throw error } - return dayDTOs.map((dto) => supabaseDayMapper.toDomain(dto)) + return dayDTOs.map((dto) => dayToDomain(dto)) } async function insertDayDiet(newDay: NewDayDiet): Promise { - const newDayDTO = supabaseDayMapper.toInsertDTO(newDay) + const newDayDTO = dayToInsertDTO(newDay) const { data: dayDTO, error } = await supabase .from(SUPABASE_TABLE_DAYS) .insert(newDayDTO) .select() .single() + if (error !== null) { throw wrapErrorWithStack(error) } - return supabaseDayMapper.toDomain(dayDTO) + return dayToDomain(dayDTO) } async function updateDayDietById( id: DayDiet['id'], newDay: NewDayDiet, ): Promise { - const updateDTO = supabaseDayMapper.toInsertDTO(newDay) + const updateDTO = dayToInsertDTO(newDay) const { data: dayDTO, error } = await supabase .from(SUPABASE_TABLE_DAYS) @@ -128,7 +405,7 @@ async function updateDayDietById( throw error } - return supabaseDayMapper.toDomain(dayDTO) + return dayToDomain(dayDTO) } const deleteDayDietById = async (id: DayDiet['id']): Promise => { diff --git a/src/modules/diet/day-diet/infrastructure/supabase/supabaseDayMapper.ts b/src/modules/diet/day-diet/infrastructure/supabase/supabaseDayMapper.ts deleted file mode 100644 index d81490b46..000000000 --- a/src/modules/diet/day-diet/infrastructure/supabase/supabaseDayMapper.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { - type DayDiet, - dayDietSchema, - type NewDayDiet, -} from '~/modules/diet/day-diet/domain/dayDiet' -import { supabaseMealMapper } from '~/modules/diet/meal/infrastructure/supabase/supabaseMealMapper' -import { type Database } from '~/shared/supabase/database.types' -import { parseWithStack } from '~/shared/utils/parseWithStack' - -export type DayDietDTO = Database['public']['Tables']['days']['Row'] -export type InsertDayDietDTO = Database['public']['Tables']['days']['Insert'] - -function toInsertDTO(newDayDiet: NewDayDiet): InsertDayDietDTO { - return { - target_day: newDayDiet.target_day, - user_id: newDayDiet.user_id, - meals: newDayDiet.meals.map((meal) => supabaseMealMapper.toInsertDTO(meal)), - } -} - -function toDomain(dto: DayDietDTO): DayDiet { - if (!Array.isArray(dto.meals)) { - throw new Error('DayDiet DTO meals field is not an array') - } - - return parseWithStack(dayDietSchema, { - id: dto.id, - target_day: dto.target_day, - user_id: dto.user_id, - meals: dto.meals.map((meal) => supabaseMealMapper.toDomain(meal)), - }) -} - -export const supabaseDayMapper = { - toInsertDTO, - toDomain, -} diff --git a/src/modules/diet/food/infrastructure/api/domain/apiFoodSchema.ts b/src/modules/diet/food/domain/apiFood.ts similarity index 100% rename from src/modules/diet/food/infrastructure/api/domain/apiFoodSchema.ts rename to src/modules/diet/food/domain/apiFood.ts diff --git a/src/modules/diet/food/infrastructure/api/domain/apiFoodRepository.ts b/src/modules/diet/food/domain/apiFoodRepository.ts similarity index 73% rename from src/modules/diet/food/infrastructure/api/domain/apiFoodRepository.ts rename to src/modules/diet/food/domain/apiFoodRepository.ts index a17b79c5b..571540309 100644 --- a/src/modules/diet/food/infrastructure/api/domain/apiFoodRepository.ts +++ b/src/modules/diet/food/domain/apiFoodRepository.ts @@ -1,4 +1,4 @@ -import { type ApiFood } from '~/modules/diet/food/infrastructure/api/domain/apiFoodSchema' +import { type ApiFood } from '~/modules/diet/food/domain/apiFood' export type ApiFoodRepository = { fetchApiFoods: () => Promise diff --git a/src/modules/diet/food/infrastructure/api/application/apiFood.ts b/src/modules/diet/food/infrastructure/api/application/apiFood.ts index c5d027e02..31b6d9e8a 100644 --- a/src/modules/diet/food/infrastructure/api/application/apiFood.ts +++ b/src/modules/diet/food/infrastructure/api/application/apiFood.ts @@ -1,8 +1,8 @@ import axios from 'axios' +import { type ApiFood } from '~/modules/diet/food/domain/apiFood' import { type Food } from '~/modules/diet/food/domain/food' import { type FoodRepository } from '~/modules/diet/food/domain/foodRepository' -import { type ApiFood } from '~/modules/diet/food/infrastructure/api/domain/apiFoodSchema' import { createSupabaseFoodRepository } from '~/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodRepository' import { createCachedSearchCrud } from '~/modules/search/application/usecases/cachedSearchCrud' import { showError } from '~/modules/toast/application/toastManager' @@ -132,5 +132,3 @@ export function createApiFoodImportService(deps?: { importFoodsFromApiByName, } } - -export type ApiFoodImportService = ReturnType diff --git a/src/modules/diet/food/infrastructure/api/infrastructure/api/apiFoodRepository.ts b/src/modules/diet/food/infrastructure/api/infrastructure/api/apiFoodRepository.ts index 10bcb22c4..57c12eb0e 100644 --- a/src/modules/diet/food/infrastructure/api/infrastructure/api/apiFoodRepository.ts +++ b/src/modules/diet/food/infrastructure/api/infrastructure/api/apiFoodRepository.ts @@ -10,11 +10,8 @@ import { EXTERNAL_API_HOST, EXTERNAL_API_REFERER, } from '~/modules/diet/api/constants/apiSecrets' -import { type ApiFoodRepository } from '~/modules/diet/food/infrastructure/api/domain/apiFoodRepository' -import { - type ApiFood, - apiFoodSchema, -} from '~/modules/diet/food/infrastructure/api/domain/apiFoodSchema' +import { type ApiFood, apiFoodSchema } from '~/modules/diet/food/domain/apiFood' +import { type ApiFoodRepository } from '~/modules/diet/food/domain/apiFoodRepository' import { wrapErrorWithStack } from '~/shared/utils/errorUtils' import { jsonParseWithStack } from '~/shared/utils/jsonParseWithStack' import { logging } from '~/shared/utils/logging' diff --git a/src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodMapper.ts b/src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodMapper.ts deleted file mode 100644 index fafd74e88..000000000 --- a/src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodMapper.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - type Food, - foodSchema, - type NewFood, -} from '~/modules/diet/food/domain/food' -import { supabaseMacroNutrientsMapper } from '~/modules/diet/macro-nutrients/infrastructure/supabase/supabaseMacroNutrientsMapper' -import { type Database } from '~/shared/supabase/database.types' -import { parseWithStack } from '~/shared/utils/parseWithStack' - -export type FoodDTO = Database['public']['Tables']['foods']['Row'] -export type InsertFoodDTO = Database['public']['Tables']['foods']['Insert'] -export type UpdateFoodDTO = Database['public']['Tables']['foods']['Update'] - -function toDomain(dto: FoodDTO): Food { - return parseWithStack(foodSchema, { - ...dto, - ean: dto.ean ?? null, - source: dto.source ?? undefined, - macros: supabaseMacroNutrientsMapper.toDomain(dto.macros), - }) -} - -function toInsertDTO(newFood: NewFood): InsertFoodDTO { - return { - name: newFood.name, - ean: newFood.ean ?? null, - macros: supabaseMacroNutrientsMapper.toInsertDTO(newFood.macros), - source: newFood.source ?? null, - } -} - -function toUpdateDTO(food: Food): UpdateFoodDTO { - return { - name: food.name, - ean: food.ean ?? null, - macros: supabaseMacroNutrientsMapper.toUpdateDTO(food.macros), - source: food.source ?? null, - } -} - -export const supabaseFoodMapper = { - toDomain, - toInsertDTO, - toUpdateDTO, -} diff --git a/src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodRepository.ts b/src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodRepository.ts index 1d772948a..7f81ad6a9 100644 --- a/src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodRepository.ts +++ b/src/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodRepository.ts @@ -1,14 +1,73 @@ -import { type Food, type NewFood } from '~/modules/diet/food/domain/food' +import { + type Food, + foodSchema, + type NewFood, +} from '~/modules/diet/food/domain/food' import { type FoodRepository, type FoodSearchParams, } from '~/modules/diet/food/domain/foodRepository' -import { supabaseFoodMapper } from '~/modules/diet/food/infrastructure/api/infrastructure/supabase/supabaseFoodMapper' -import { SUPABASE_TABLE_FOODS } from '~/modules/diet/food/infrastructure/supabase/constants' +import { createMacroNutrients } from '~/modules/diet/macro-nutrients/domain/macroNutrients' +import { type Database, type Json } from '~/shared/supabase/database.types' import { supabase } from '~/shared/supabase/supabase' import { isSupabaseDuplicateEanError } from '~/shared/supabase/supabaseErrorUtils' import { wrapErrorWithStack } from '~/shared/utils/errorUtils' import { logging } from '~/shared/utils/logging' +import { parseWithStack } from '~/shared/utils/parseWithStack' + +const SUPABASE_TABLE_FOODS = 'foods' + +type FoodDTO = Database['public']['Tables']['foods']['Row'] +type InsertFoodDTO = Database['public']['Tables']['foods']['Insert'] + +function macrosToDomain(macrosDTO: Json) { + if ( + macrosDTO === null || + !(typeof macrosDTO === 'object') || + !('carbs' in macrosDTO) || + !('protein' in macrosDTO) || + !('fat' in macrosDTO) || + typeof macrosDTO.carbs !== 'number' || + typeof macrosDTO.protein !== 'number' || + typeof macrosDTO.fat !== 'number' + ) { + throw new Error( + 'macrosDTO is missing macros field: ' + JSON.stringify(macrosDTO), + ) + } + + return createMacroNutrients({ + carbsInMg: macrosDTO.carbs * 1000, + proteinInMg: macrosDTO.protein * 1000, + fatInMg: macrosDTO.fat * 1000, + }) +} + +function macrosToDTO(macroNutrients: Food['macros']): Json { + return { + carbs: macroNutrients.carbsInMg / 1000, + protein: macroNutrients.proteinInMg / 1000, + fat: macroNutrients.fatInMg / 1000, + } +} + +function foodToDomain(dto: FoodDTO): Food { + return parseWithStack(foodSchema, { + ...dto, + ean: dto.ean ?? null, + source: dto.source ?? undefined, + macros: macrosToDomain(dto.macros), + }) +} + +function foodToInsertDTO(newFood: NewFood): InsertFoodDTO { + return { + name: newFood.name, + ean: newFood.ean ?? null, + macros: macrosToDTO(newFood.macros), + source: newFood.source ?? null, + } +} export function createSupabaseFoodRepository(): FoodRepository { return { @@ -86,7 +145,7 @@ async function fetchFoodByEan( * @throws Error if not created or on API/validation error */ async function insertFood(newFood: NewFood): Promise { - const insertDTO = supabaseFoodMapper.toInsertDTO(newFood) + const insertDTO = foodToInsertDTO(newFood) const { data, error } = await supabase .from(SUPABASE_TABLE_FOODS) .insert(insertDTO) @@ -101,7 +160,7 @@ async function insertFood(newFood: NewFood): Promise { throw wrapErrorWithStack(error) } - return supabaseFoodMapper.toDomain(data) + return foodToDomain(data) } /** @@ -112,7 +171,7 @@ async function insertFood(newFood: NewFood): Promise { * @throws Error if not created or on API/validation error */ async function upsertFood(newFood: NewFood): Promise { - const createDTO = supabaseFoodMapper.toInsertDTO(newFood) + const createDTO = foodToInsertDTO(newFood) const { data: food, error } = await supabase .from(SUPABASE_TABLE_FOODS) .upsert(createDTO) @@ -127,7 +186,7 @@ async function upsertFood(newFood: NewFood): Promise { throw wrapErrorWithStack(error) } - return supabaseFoodMapper.toDomain(food) + return foodToDomain(food) } async function fetchFoodsByName( @@ -165,7 +224,7 @@ async function fetchFoodsByName( : 'enhanced search' logging.debug(`Found ${resultsCount} foods using ${searchType}`) - return result.data.map(supabaseFoodMapper.toDomain) + return result.data.map(foodToDomain) } catch (err) { logging.error('Food search error:', err) throw err @@ -242,7 +301,7 @@ async function internalCachedSearchFoods( } logging.debug(`Found ${foods.length} foods`) - return foods.map(supabaseFoodMapper.toDomain) + return foods.map(foodToDomain) } /** @@ -262,5 +321,5 @@ async function fetchFoodsByIds(ids: Food['id'][]): Promise { throw wrapErrorWithStack(error) } - return foods.map(supabaseFoodMapper.toDomain) + return foods.map(foodToDomain) } diff --git a/src/modules/diet/food/infrastructure/supabase/constants.ts b/src/modules/diet/food/infrastructure/supabase/constants.ts deleted file mode 100644 index 9c93cce98..000000000 --- a/src/modules/diet/food/infrastructure/supabase/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const SUPABASE_TABLE_FOODS = 'foods' diff --git a/src/modules/diet/item/infrastructure/supabase/supabaseItemMapper.ts b/src/modules/diet/item/infrastructure/supabase/supabaseItemMapper.ts deleted file mode 100644 index 6580ffd51..000000000 --- a/src/modules/diet/item/infrastructure/supabase/supabaseItemMapper.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { - isFoodItem, - type Item, - itemSchema, -} from '~/modules/diet/item/schema/itemSchema' -import { supabaseMacroNutrientsMapper } from '~/modules/diet/macro-nutrients/infrastructure/supabase/supabaseMacroNutrientsMapper' -import { type Json } from '~/shared/supabase/database.types' -import { parseWithStack } from '~/shared/utils/parseWithStack' - -function toDomain(itemDTO: Json): Item { - if ( - itemDTO === null || - !(typeof itemDTO === 'object') || - !('id' in itemDTO) || - !('name' in itemDTO) || - !('quantity' in itemDTO) || - !('reference' in itemDTO) || - typeof itemDTO.id !== 'number' || - typeof itemDTO.name !== 'string' || - typeof itemDTO.quantity !== 'number' || - typeof itemDTO.reference !== 'object' || - itemDTO.reference === null || - Array.isArray(itemDTO.reference) || - !('type' in itemDTO.reference) - ) { - throw new Error( - 'Item DTO is missing required fields: ' + JSON.stringify(itemDTO), - ) - } - - const referenceType = itemDTO.reference.type - if ( - referenceType === 'food' && - (!('id' in itemDTO.reference) || - typeof itemDTO.reference.id !== 'number' || - !('macros' in itemDTO.reference) || - typeof itemDTO.reference.macros !== 'object' || - itemDTO.reference.macros === null) - ) { - throw new Error( - 'Food Item DTO reference is missing required fields: ' + - JSON.stringify(itemDTO.reference), - ) - } else if ( - referenceType === 'recipe' && - (!('id' in itemDTO.reference) || - typeof itemDTO.reference.id !== 'number' || - !('children' in itemDTO.reference) || - !Array.isArray(itemDTO.reference.children)) - ) { - throw new Error( - 'Recipe Item DTO reference is missing required fields: ' + - JSON.stringify(itemDTO.reference), - ) - } else if ( - referenceType === 'group' && - (!('children' in itemDTO.reference) || - !Array.isArray(itemDTO.reference.children)) - ) { - throw new Error( - 'Group Item DTO reference is missing required fields: ' + - JSON.stringify(itemDTO.reference), - ) - } - - if ( - referenceType !== 'food' && - referenceType !== 'recipe' && - referenceType !== 'group' - ) { - throw new Error( - 'Item DTO reference has invalid type: ' + - JSON.stringify(itemDTO.reference), - ) - } - - if (referenceType === 'food') { - if ( - itemDTO.reference.macros === null || - typeof itemDTO.reference.macros !== 'object' - ) { - throw new Error( - 'Food Item DTO reference macros is invalid: ' + - JSON.stringify(itemDTO.reference.macros), - ) - } - const macros = supabaseMacroNutrientsMapper.toDomain( - itemDTO.reference.macros, - ) - return parseWithStack(itemSchema, { - id: itemDTO.id, - name: itemDTO.name, - quantity: itemDTO.quantity, - reference: { - type: 'food', - id: itemDTO.reference.id, - macros, - }, - __type: 'UnifiedItem', - }) - } - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (referenceType === 'recipe' || referenceType === 'group') { - // Recursively parse children items - if ( - itemDTO.reference.children === null || - !Array.isArray(itemDTO.reference.children) - ) { - throw new Error( - 'Item DTO reference children is invalid: ' + - JSON.stringify(itemDTO.reference.children), - ) - } - const children = itemDTO.reference.children.map((childDTO: Json) => - toDomain(childDTO), - ) - return parseWithStack(itemSchema, { - id: itemDTO.id, - name: itemDTO.name, - quantity: itemDTO.quantity, - reference: - referenceType === 'recipe' - ? { - type: 'recipe', - id: itemDTO.reference.id, - children, - } - : { - type: 'group', - children, - }, - __type: 'UnifiedItem', - }) - } - - referenceType satisfies never // for exhaustiveness - throw new Error( - 'Item DTO has invalid reference type: ' + JSON.stringify(itemDTO.reference), - ) -} - -function toSupabaseDTO(item: Item): Json { - if (isFoodItem(item)) { - return { - id: item.id, - name: item.name, - quantity: item.quantity, - reference: { - type: 'food', - id: item.reference.id, - macros: supabaseMacroNutrientsMapper.toInsertDTO(item.reference.macros), - }, - } - } else if (item.reference.type === 'recipe') { - return { - id: item.id, - name: item.name, - quantity: item.quantity, - reference: { - type: 'recipe', - id: item.reference.id, - children: item.reference.children.map((child) => toSupabaseDTO(child)), - }, - } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - } else if (item.reference.type === 'group') { - return { - id: item.id, - name: item.name, - quantity: item.quantity, - reference: { - type: 'group', - children: item.reference.children.map((child) => toSupabaseDTO(child)), - }, - } - } - - item.reference satisfies never // for exhaustiveness - throw new Error( - 'Item has invalid reference type: ' + JSON.stringify(item.reference), - ) -} - -export const supabaseItemMapper = { - toDomain, - toInsertDTO: (item: Item): Json => toSupabaseDTO(item), - toUpdateDTO: (item: Item): Json => toSupabaseDTO(item), -} diff --git a/src/modules/diet/macro-nutrients/infrastructure/supabase/supabaseMacroNutrientsMapper.ts b/src/modules/diet/macro-nutrients/infrastructure/supabase/supabaseMacroNutrientsMapper.ts deleted file mode 100644 index 45674ac06..000000000 --- a/src/modules/diet/macro-nutrients/infrastructure/supabase/supabaseMacroNutrientsMapper.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - createMacroNutrients, - type MacroNutrients, -} from '~/modules/diet/macro-nutrients/domain/macroNutrients' -import { type Json } from '~/shared/supabase/database.types' - -function toDomain(macrosDTO: Json): MacroNutrients { - if ( - macrosDTO === null || - !(typeof macrosDTO === 'object') || - !('carbs' in macrosDTO) || - !('protein' in macrosDTO) || - !('fat' in macrosDTO) || - typeof macrosDTO.carbs !== 'number' || - typeof macrosDTO.protein !== 'number' || - typeof macrosDTO.fat !== 'number' - ) { - throw new Error( - 'macrosDTO is missing macros field: ' + JSON.stringify(macrosDTO), - ) - } - - return createMacroNutrients({ - carbsInMg: macrosDTO.carbs * 1000, - proteinInMg: macrosDTO.protein * 1000, - fatInMg: macrosDTO.fat * 1000, - }) -} - -function toSupabaseDTO(macroNutrients: MacroNutrients): Json { - return { - carbs: macroNutrients.carbsInMg / 1000, - protein: macroNutrients.proteinInMg / 1000, - fat: macroNutrients.fatInMg / 1000, - } -} - -export const supabaseMacroNutrientsMapper = { - toDomain: (macrosDTO: Json): MacroNutrients => toDomain(macrosDTO), - toInsertDTO: (macroNutrients: MacroNutrients): Json => - toSupabaseDTO(macroNutrients), - toUpdateDTO: (macroNutrients: MacroNutrients): Json => - toSupabaseDTO(macroNutrients), -} diff --git a/src/modules/diet/macro-nutrients/tests/supabaseMacroNutrientsMapper.test.ts b/src/modules/diet/macro-nutrients/tests/supabaseMacroNutrientsMapper.test.ts deleted file mode 100644 index 4e8ad38d3..000000000 --- a/src/modules/diet/macro-nutrients/tests/supabaseMacroNutrientsMapper.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { supabaseMacroNutrientsMapper } from '~/modules/diet/macro-nutrients/infrastructure/supabase/supabaseMacroNutrientsMapper' -import { type Json } from '~/shared/supabase/database.types' - -describe('supabaseMacroNutrientsMapper', () => { - it('converts valid JSON to MacroNutrients and back', () => { - const carbs = 10.5 - const protein = 5 - const fat = 2.25 - - const dto: Json = { carbs, protein, fat } - - const domain = supabaseMacroNutrientsMapper.toDomain(dto) - - // Domain values are in milligrams - expect(domain.carbsInMg).toBe(carbs * 1000) - expect(domain.proteinInMg).toBe(protein * 1000) - expect(domain.fatInMg).toBe(fat * 1000) - - const supabaseDto = supabaseMacroNutrientsMapper.toInsertDTO(domain) - expect(supabaseDto).toEqual({ carbs, protein, fat }) - }) - - it('throws for invalid JSON shapes', () => { - const invalid1: Json = null - const invalid2: Json = {} - const invalid3: Json = { carbs: 'x' } - - expect(() => supabaseMacroNutrientsMapper.toDomain(invalid1)).toThrow() - expect(() => supabaseMacroNutrientsMapper.toDomain(invalid2)).toThrow() - expect(() => supabaseMacroNutrientsMapper.toDomain(invalid3)).toThrow() - }) -}) diff --git a/src/modules/diet/macro-profile/infrastructure/supabase/constants.ts b/src/modules/diet/macro-profile/infrastructure/supabase/constants.ts deleted file mode 100644 index de5f24648..000000000 --- a/src/modules/diet/macro-profile/infrastructure/supabase/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const SUPABASE_TABLE_MACRO_PROFILES = 'macro_profiles' diff --git a/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts b/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts index 24f489691..e7c552bf2 100644 --- a/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts +++ b/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts @@ -2,10 +2,11 @@ import { type MacroProfile, macroProfileSchema, } from '~/modules/diet/macro-profile/domain/macroProfile' -import { SUPABASE_TABLE_MACRO_PROFILES } from '~/modules/diet/macro-profile/infrastructure/supabase/constants' import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' +const SUPABASE_TABLE_MACRO_PROFILES = 'macro_profiles' + type MacroProfileRealtimeCallbacks = { onInsert: (profile: MacroProfile) => void onUpdate: (profile: MacroProfile) => void diff --git a/src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileGateway.ts b/src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileGateway.ts index 0d1a5b3e4..c9b8459a0 100644 --- a/src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileGateway.ts +++ b/src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileGateway.ts @@ -1,13 +1,41 @@ import { type MacroProfile, + macroProfileSchema, type NewMacroProfile, } from '~/modules/diet/macro-profile/domain/macroProfile' import { type MacroProfileGateway } from '~/modules/diet/macro-profile/domain/macroProfileGateway' -import { SUPABASE_TABLE_MACRO_PROFILES } from '~/modules/diet/macro-profile/infrastructure/supabase/constants' -import { supabaseMacroProfileMapper } from '~/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileMapper' import { type User } from '~/modules/user/domain/user' +import { type Database } from '~/shared/supabase/database.types' import { supabase } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' +import { parseWithStack } from '~/shared/utils/parseWithStack' + +const SUPABASE_TABLE_MACRO_PROFILES = 'macro_profiles' + +type InsertMacroProfileDTO = + Database['public']['Tables']['macro_profiles']['Insert'] +type MacroProfileDTO = Database['public']['Tables']['macro_profiles']['Row'] + +function toInsertDTO(newMacroProfile: NewMacroProfile): InsertMacroProfileDTO { + return { + user_id: newMacroProfile.user_id, + target_day: newMacroProfile.target_day.toISOString(), + gramsPerKgCarbs: newMacroProfile.gramsPerKgCarbs, + gramsPerKgProtein: newMacroProfile.gramsPerKgProtein, + gramsPerKgFat: newMacroProfile.gramsPerKgFat, + } +} + +function toDomain(dto: MacroProfileDTO): MacroProfile { + return parseWithStack(macroProfileSchema, { + id: dto.id, + user_id: dto.user_id, + target_day: new Date(dto.target_day ?? ''), + gramsPerKgCarbs: dto.gramsPerKgCarbs, + gramsPerKgProtein: dto.gramsPerKgProtein, + gramsPerKgFat: dto.gramsPerKgFat, + }) +} export function createSupabaseMacroProfileGateway(): MacroProfileGateway { return { @@ -32,13 +60,13 @@ async function fetchUserMacroProfiles( throw error } - return data.map(supabaseMacroProfileMapper.toDomain) + return data.map(toDomain) } async function insertMacroProfile( newMacroProfile: NewMacroProfile, ): Promise { - const createDTO = supabaseMacroProfileMapper.toInsertDTO(newMacroProfile) + const createDTO = toInsertDTO(newMacroProfile) const { data, error } = await supabase .from(SUPABASE_TABLE_MACRO_PROFILES) .insert(createDTO) @@ -50,14 +78,14 @@ async function insertMacroProfile( throw error } - return supabaseMacroProfileMapper.toDomain(data) + return toDomain(data) } async function updateMacroProfile( profileId: MacroProfile['id'], newMacroProfile: NewMacroProfile, ): Promise { - const updateDTO = supabaseMacroProfileMapper.toInsertDTO(newMacroProfile) + const updateDTO = toInsertDTO(newMacroProfile) const { data, error } = await supabase .from(SUPABASE_TABLE_MACRO_PROFILES) .update(updateDTO) @@ -70,7 +98,7 @@ async function updateMacroProfile( throw error } - return supabaseMacroProfileMapper.toDomain(data) + return toDomain(data) } async function deleteMacroProfile(id: MacroProfile['id']): Promise { diff --git a/src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileMapper.ts b/src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileMapper.ts deleted file mode 100644 index fbffba33f..000000000 --- a/src/modules/diet/macro-profile/infrastructure/supabase/supabaseMacroProfileMapper.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - type MacroProfile, - macroProfileSchema, - type NewMacroProfile, -} from '~/modules/diet/macro-profile/domain/macroProfile' -import { type Database } from '~/shared/supabase/database.types' -import { parseWithStack } from '~/shared/utils/parseWithStack' - -export type InsertMacroProfileDTO = - Database['public']['Tables']['macro_profiles']['Insert'] -export type MacroProfileDTO = - Database['public']['Tables']['macro_profiles']['Row'] - -// Conversion functions -function toInsertDTO(newMacroProfile: NewMacroProfile): InsertMacroProfileDTO { - return { - user_id: newMacroProfile.user_id, - target_day: newMacroProfile.target_day.toISOString(), - gramsPerKgCarbs: newMacroProfile.gramsPerKgCarbs, - gramsPerKgProtein: newMacroProfile.gramsPerKgProtein, - gramsPerKgFat: newMacroProfile.gramsPerKgFat, - } -} - -function toDomain(dto: MacroProfileDTO): MacroProfile { - return parseWithStack(macroProfileSchema, { - id: dto.id, - user_id: dto.user_id, - target_day: new Date(dto.target_day ?? ''), - gramsPerKgCarbs: dto.gramsPerKgCarbs, - gramsPerKgProtein: dto.gramsPerKgProtein, - gramsPerKgFat: dto.gramsPerKgFat, - }) -} - -export const supabaseMacroProfileMapper = { - toInsertDTO, - toDomain, -} diff --git a/src/modules/diet/meal/infrastructure/supabase/supabaseMealMapper.ts b/src/modules/diet/meal/infrastructure/supabase/supabaseMealMapper.ts deleted file mode 100644 index 1010742cb..000000000 --- a/src/modules/diet/meal/infrastructure/supabase/supabaseMealMapper.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { supabaseItemMapper } from '~/modules/diet/item/infrastructure/supabase/supabaseItemMapper' -import { - createNewMeal, - type Meal, - promoteMeal, -} from '~/modules/diet/meal/domain/meal' -import { type Json } from '~/shared/supabase/database.types' - -function toDomain(mealDTO: Json): Meal { - if ( - mealDTO === null || - !(typeof mealDTO === 'object') || - !('id' in mealDTO) || - !('name' in mealDTO) || - !('items' in mealDTO) || - typeof mealDTO.id !== 'number' || - typeof mealDTO.name !== 'string' || - !Array.isArray(mealDTO.items) - ) { - throw new Error( - 'Food DTO is missing meal field: ' + JSON.stringify(mealDTO), - ) - } - - return promoteMeal( - createNewMeal({ - name: mealDTO.name, - items: mealDTO.items.map((item: Json) => - supabaseItemMapper.toDomain(item), - ), - }), - { id: mealDTO.id }, - ) -} - -function toSupabaseDTO(meal: Meal): Json { - return { - ...meal, - items: meal.items.map((item) => supabaseItemMapper.toInsertDTO(item)), - } -} - -export const supabaseMealMapper = { - toDomain, - toInsertDTO: (meal: Meal): Json => toSupabaseDTO(meal), - toUpdateDTO: (meal: Meal): Json => toSupabaseDTO(meal), -} diff --git a/src/modules/diet/recent-food/infrastructure/recentFoodRepository.ts b/src/modules/diet/recent-food/infrastructure/recentFoodRepository.ts index fac06fec6..96ce5d08e 100644 --- a/src/modules/diet/recent-food/infrastructure/recentFoodRepository.ts +++ b/src/modules/diet/recent-food/infrastructure/recentFoodRepository.ts @@ -3,13 +3,32 @@ import { type RecentFood, } from '~/modules/diet/recent-food/domain/recentFood' import { type RecentFoodRepository } from '~/modules/diet/recent-food/domain/recentFoodRepository' -import { type RecentFoodGateway } from '~/modules/diet/recent-food/infrastructure/supabase/supabaseRecentFoodGateway' import { type Template } from '~/modules/diet/template/domain/template' import { type User } from '~/modules/user/domain/user' import { logging } from '~/shared/utils/logging' +type RecentFoodGatewayLike = { + fetchByUserTypeAndReferenceId: ( + userId: User['uuid'], + type: RecentFood['type'], + referenceId: number, + ) => Promise + fetchUserRecentFoodsAsTemplates: ( + userId: User['uuid'], + search: string, + opts?: { limit?: number }, + ) => Promise + insert: (input: NewRecentFood) => Promise + update: (id: number, input: NewRecentFood) => Promise + deleteByReference: ( + userId: User['uuid'], + type: RecentFood['type'], + referenceId: number, + ) => Promise +} + export function createRecentFoodRepository( - gateway: RecentFoodGateway, + gateway: RecentFoodGatewayLike, ): RecentFoodRepository { return { async fetchByUserTypeAndReferenceId( diff --git a/src/modules/diet/recent-food/infrastructure/supabase/constants.ts b/src/modules/diet/recent-food/infrastructure/supabase/constants.ts deleted file mode 100644 index 613e073d0..000000000 --- a/src/modules/diet/recent-food/infrastructure/supabase/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const SUPABASE_TABLE_RECENT_FOODS = 'recent_foods' diff --git a/src/modules/diet/recent-food/infrastructure/supabase/realtime.ts b/src/modules/diet/recent-food/infrastructure/supabase/realtime.ts index bd5cb42e2..4298d2182 100644 --- a/src/modules/diet/recent-food/infrastructure/supabase/realtime.ts +++ b/src/modules/diet/recent-food/infrastructure/supabase/realtime.ts @@ -2,50 +2,59 @@ import { type RecentFood, recentFoodSchema, } from '~/modules/diet/recent-food/domain/recentFood' -import { SUPABASE_TABLE_RECENT_FOODS } from '~/modules/diet/recent-food/infrastructure/supabase/constants' import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' -let initialized = false -export function initializeRecentFoodRealtime(callbacks: { - onInsert: (data: RecentFood) => void - onUpdate: (data: RecentFood) => void - onDelete: (data: RecentFood) => void -}) { - if (initialized) { - return - } - logging.debug(`Recent food realtime initialized!`) - initialized = true - - registerSubapabaseRealtimeCallback( - SUPABASE_TABLE_RECENT_FOODS, - recentFoodSchema, - (event) => { - logging.debug(`Recent food realtime event ${event.eventType}:`, event) - - switch (event.eventType) { - case 'INSERT': { - if (event.new !== undefined) { - callbacks.onInsert(event.new) +const SUPABASE_TABLE_RECENT_FOODS = 'recent_foods' + +export function createRecentFoodRealtimeService() { + let initialized = false + + function initializeRecentFoodRealtime(callbacks: { + onInsert: (data: RecentFood) => void + onUpdate: (data: RecentFood) => void + onDelete: (data: RecentFood) => void + }) { + if (initialized) { + return + } + + logging.debug(`Recent food realtime initialized!`) + initialized = true + + registerSubapabaseRealtimeCallback( + SUPABASE_TABLE_RECENT_FOODS, + recentFoodSchema, + (event) => { + logging.debug(`Recent food realtime event ${event.eventType}:`, event) + + switch (event.eventType) { + case 'INSERT': { + if (event.new !== undefined) { + callbacks.onInsert(event.new) + } + break } - break - } - case 'UPDATE': { - if (event.new) { - callbacks.onUpdate(event.new) + case 'UPDATE': { + if (event.new) { + callbacks.onUpdate(event.new) + } + break } - break - } - case 'DELETE': { - if (event.old) { - callbacks.onDelete(event.old) + case 'DELETE': { + if (event.old) { + callbacks.onDelete(event.old) + } + break } - break } - } - }, - ) + }, + ) + } + + return { + initializeRecentFoodRealtime, + } } diff --git a/src/modules/diet/recent-food/infrastructure/supabase/supabaseRecentFoodGateway.ts b/src/modules/diet/recent-food/infrastructure/supabase/supabaseRecentFoodGateway.ts index baf745e67..ae1e91acd 100644 --- a/src/modules/diet/recent-food/infrastructure/supabase/supabaseRecentFoodGateway.ts +++ b/src/modules/diet/recent-food/infrastructure/supabase/supabaseRecentFoodGateway.ts @@ -1,23 +1,29 @@ import { z } from 'zod/v4' import { foodSchema } from '~/modules/diet/food/domain/food' -import { supabaseItemMapper } from '~/modules/diet/item/infrastructure/supabase/supabaseItemMapper' -import { supabaseMacroNutrientsMapper } from '~/modules/diet/macro-nutrients/infrastructure/supabase/supabaseMacroNutrientsMapper' +import { type Item, itemSchema } from '~/modules/diet/item/schema/itemSchema' +import { createMacroNutrients } from '~/modules/diet/macro-nutrients/domain/macroNutrients' import { type NewRecentFood, type RecentFood, + recentFoodSchema, } from '~/modules/diet/recent-food/domain/recentFood' -import { SUPABASE_TABLE_RECENT_FOODS } from '~/modules/diet/recent-food/infrastructure/supabase/constants' -import { supabaseRecentFoodMapper } from '~/modules/diet/recent-food/infrastructure/supabase/supabaseRecentFoodMapper' import { recipeSchema } from '~/modules/diet/recipe/domain/recipe' import { type Template } from '~/modules/diet/template/domain/template' import { type User } from '~/modules/user/domain/user' -import { type Json } from '~/shared/supabase/database.types' +import { type Database, type Json } from '~/shared/supabase/database.types' import { supabase } from '~/shared/supabase/supabase' import { parseWithStack } from '~/shared/utils/parseWithStack' import { removeDiacritics } from '~/shared/utils/removeDiacritics' -// Schema for the enhanced database function response +const SUPABASE_TABLE_RECENT_FOODS = 'recent_foods' + +type RecentFoodDTO = Database['public']['Tables']['recent_foods']['Row'] +type UpdateRecentFoodDTO = + Database['public']['Tables']['recent_foods']['Update'] +type InsertRecentFoodDTO = + Database['public']['Tables']['recent_foods']['Insert'] + const enhancedRecentFoodRowSchema = z .object({ recent_food_id: z.number(), @@ -37,7 +43,160 @@ const enhancedRecentFoodRowSchema = z }) .strip() -// Helper function to safely get recipe fields +function macrosToDomain(macrosDTO: Json) { + if ( + macrosDTO === null || + !(typeof macrosDTO === 'object') || + !('carbs' in macrosDTO) || + !('protein' in macrosDTO) || + !('fat' in macrosDTO) || + typeof macrosDTO.carbs !== 'number' || + typeof macrosDTO.protein !== 'number' || + typeof macrosDTO.fat !== 'number' + ) { + throw new Error( + 'macrosDTO is missing macros field: ' + JSON.stringify(macrosDTO), + ) + } + + return createMacroNutrients({ + carbsInMg: macrosDTO.carbs * 1000, + proteinInMg: macrosDTO.protein * 1000, + fatInMg: macrosDTO.fat * 1000, + }) +} + +function itemToDomain(itemDTO: Json): Item { + if ( + itemDTO === null || + !(typeof itemDTO === 'object') || + !('id' in itemDTO) || + !('name' in itemDTO) || + !('quantity' in itemDTO) || + !('reference' in itemDTO) || + typeof itemDTO.id !== 'number' || + typeof itemDTO.name !== 'string' || + typeof itemDTO.quantity !== 'number' || + typeof itemDTO.reference !== 'object' || + itemDTO.reference === null || + Array.isArray(itemDTO.reference) || + !('type' in itemDTO.reference) + ) { + throw new Error( + 'Item DTO is missing required fields: ' + JSON.stringify(itemDTO), + ) + } + + const referenceType = itemDTO.reference.type + if ( + referenceType === 'food' && + (!('id' in itemDTO.reference) || + typeof itemDTO.reference.id !== 'number' || + !('macros' in itemDTO.reference) || + typeof itemDTO.reference.macros !== 'object' || + itemDTO.reference.macros === null) + ) { + throw new Error( + 'Food Item DTO reference is missing required fields: ' + + JSON.stringify(itemDTO.reference), + ) + } else if ( + referenceType === 'recipe' && + (!('id' in itemDTO.reference) || + typeof itemDTO.reference.id !== 'number' || + !('children' in itemDTO.reference) || + !Array.isArray(itemDTO.reference.children)) + ) { + throw new Error( + 'Recipe Item DTO reference is missing required fields: ' + + JSON.stringify(itemDTO.reference), + ) + } else if ( + referenceType === 'group' && + (!('children' in itemDTO.reference) || + !Array.isArray(itemDTO.reference.children)) + ) { + throw new Error( + 'Group Item DTO reference is missing required fields: ' + + JSON.stringify(itemDTO.reference), + ) + } + + if ( + referenceType !== 'food' && + referenceType !== 'recipe' && + referenceType !== 'group' + ) { + throw new Error( + 'Item DTO reference has invalid type: ' + + JSON.stringify(itemDTO.reference), + ) + } + + if (referenceType === 'food') { + if ( + itemDTO.reference.macros === null || + typeof itemDTO.reference.macros !== 'object' + ) { + throw new Error( + 'Food Item DTO reference macros is invalid: ' + + JSON.stringify(itemDTO.reference.macros), + ) + } + + return parseWithStack(itemSchema, { + id: itemDTO.id, + name: itemDTO.name, + quantity: itemDTO.quantity, + reference: { + type: 'food', + id: itemDTO.reference.id, + macros: macrosToDomain(itemDTO.reference.macros), + }, + __type: 'UnifiedItem', + }) + } + + if ( + itemDTO.reference.children === null || + !Array.isArray(itemDTO.reference.children) + ) { + throw new Error( + 'Item DTO reference children is invalid: ' + + JSON.stringify(itemDTO.reference.children), + ) + } + + const children: Item[] = itemDTO.reference.children.map((childDTO: Json) => + itemToDomain(childDTO), + ) + + if (referenceType === 'recipe') { + return parseWithStack(itemSchema, { + id: itemDTO.id, + name: itemDTO.name, + quantity: itemDTO.quantity, + reference: { + type: 'recipe', + id: itemDTO.reference.id, + children, + }, + __type: 'UnifiedItem' as const, + }) + } + + return parseWithStack(itemSchema, { + id: itemDTO.id, + name: itemDTO.name, + quantity: itemDTO.quantity, + reference: { + type: 'group', + children, + }, + __type: 'UnifiedItem' as const, + }) +} + function getRecipeFields(row: z.infer) { if (row.type !== 'recipe') { throw new Error('Expected recipe type but got food') @@ -53,7 +212,6 @@ function getRecipeFields(row: z.infer) { return { user_id, preparedMultiplier } } -// Helper function to transform raw database data to Template objects function transformRowToTemplate(row: unknown): Template { const validatedRow = parseWithStack(enhancedRecentFoodRowSchema, row) @@ -68,36 +226,66 @@ function transformRowToTemplate(row: unknown): Template { name: validatedRow.template_name, ean: validatedRow.template_ean, source: validatedRow.template_source, - macros: supabaseMacroNutrientsMapper.toDomain(macrosDTO), + macros: macrosToDomain(macrosDTO), __type: 'Food', }) - } else { - const { user_id: user_id, preparedMultiplier } = - getRecipeFields(validatedRow) - const items = Array.isArray(validatedRow.template_items) - ? (() => { - const itemsDTO = parseWithStack( - z.array(z.any()), - validatedRow.template_items, - ) - return itemsDTO.map((item: Json) => supabaseItemMapper.toDomain(item)) - })() - : (() => { - throw new Error( - 'Recent food recipe template items is not an array: ' + - JSON.stringify(validatedRow.template_items), - ) - })() - - return parseWithStack(recipeSchema, { - id: validatedRow.template_id, - name: validatedRow.template_name, - user_id, - items, - prepared_multiplier: preparedMultiplier, - __type: 'Recipe', - }) } + + const { user_id, preparedMultiplier } = getRecipeFields(validatedRow) + const items = Array.isArray(validatedRow.template_items) + ? (() => { + const itemsDTO = parseWithStack( + z.array(z.any()), + validatedRow.template_items, + ) + return itemsDTO.map((item: Json) => itemToDomain(item)) + })() + : (() => { + throw new Error( + 'Recent food recipe template items is not an array: ' + + JSON.stringify(validatedRow.template_items), + ) + })() + + return parseWithStack(recipeSchema, { + id: validatedRow.template_id, + name: validatedRow.template_name, + user_id, + items, + prepared_multiplier: preparedMultiplier, + __type: 'Recipe', + }) +} + +function toUpdateDTO(recentFood: NewRecentFood): UpdateRecentFoodDTO { + return { + last_used: recentFood.last_used.toISOString(), + reference_id: recentFood.reference_id, + times_used: recentFood.times_used, + user_id: recentFood.user_id, + type: recentFood.type, + } +} + +function toInsertDTO(recentFood: NewRecentFood): InsertRecentFoodDTO { + return { + last_used: recentFood.last_used.toISOString(), + reference_id: recentFood.reference_id, + times_used: recentFood.times_used, + user_id: recentFood.user_id, + type: recentFood.type, + } +} + +function toDomain(recentFoodDTO: RecentFoodDTO): RecentFood { + return parseWithStack(recentFoodSchema, { + id: recentFoodDTO.id, + reference_id: recentFoodDTO.reference_id, + times_used: recentFoodDTO.times_used, + last_used: new Date(recentFoodDTO.last_used), + user_id: recentFoodDTO.user_id, + type: recentFoodDTO.type, + }) } export function createSupabaseRecentFoodGateway() { @@ -110,10 +298,6 @@ export function createSupabaseRecentFoodGateway() { } } -export type RecentFoodGateway = ReturnType< - typeof createSupabaseRecentFoodGateway -> - async function fetchByUserTypeAndReferenceId( userId: User['uuid'], type: RecentFood['type'], @@ -129,7 +313,7 @@ async function fetchByUserTypeAndReferenceId( if (error !== null) throw error - return supabaseRecentFoodMapper.toDomain(data) + return toDomain(data) } async function fetchUserRecentFoodsAsTemplates( @@ -146,6 +330,7 @@ async function fetchUserRecentFoodsAsTemplates( p_search_term: normalizedSearch ?? undefined, p_limit: limit, }) + if (response.error !== null) throw response.error const validatedData = parseWithStack( @@ -157,29 +342,31 @@ async function fetchUserRecentFoodsAsTemplates( } async function insert(input: NewRecentFood): Promise { - const insertData = supabaseRecentFoodMapper.toInsertDTO(input) + const insertData = toInsertDTO(input) const { data, error } = await supabase .from(SUPABASE_TABLE_RECENT_FOODS) .insert(insertData) .select() .single() + if (error !== null) throw error - return supabaseRecentFoodMapper.toDomain(data) + return toDomain(data) } async function update( id: number, input: NewRecentFood, ): Promise { - const updateData = supabaseRecentFoodMapper.toUpdateDTO(input) + const updateData = toUpdateDTO(input) const { data, error } = await supabase .from(SUPABASE_TABLE_RECENT_FOODS) .update(updateData) .eq('id', id) .select() .single() + if (error !== null) throw error - return supabaseRecentFoodMapper.toDomain(data) + return toDomain(data) } async function deleteByReference( @@ -193,6 +380,7 @@ async function deleteByReference( .eq('user_id', userId) .eq('type', type) .eq('reference_id', referenceId) + if (error !== null) throw error return true } diff --git a/src/modules/diet/recent-food/infrastructure/supabase/supabaseRecentFoodMapper.ts b/src/modules/diet/recent-food/infrastructure/supabase/supabaseRecentFoodMapper.ts deleted file mode 100644 index 903e199f5..000000000 --- a/src/modules/diet/recent-food/infrastructure/supabase/supabaseRecentFoodMapper.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - type NewRecentFood, - type RecentFood, - recentFoodSchema, -} from '~/modules/diet/recent-food/domain/recentFood' -import { type Database } from '~/shared/supabase/database.types' -import { parseWithStack } from '~/shared/utils/parseWithStack' - -export type RecentFoodDTO = Database['public']['Tables']['recent_foods']['Row'] -export type UpdateRecentFoodDTO = - Database['public']['Tables']['recent_foods']['Update'] -export type InsertRecentFoodDTO = - Database['public']['Tables']['recent_foods']['Insert'] - -function toUpdateDTO(recentFood: NewRecentFood): UpdateRecentFoodDTO { - return { - last_used: recentFood.last_used.toISOString(), - reference_id: recentFood.reference_id, - times_used: recentFood.times_used, - user_id: recentFood.user_id, - type: recentFood.type, - } -} - -function toInsertDTO(recentFood: NewRecentFood): InsertRecentFoodDTO { - return { - last_used: recentFood.last_used.toISOString(), - reference_id: recentFood.reference_id, - times_used: recentFood.times_used, - user_id: recentFood.user_id, - type: recentFood.type, - } -} - -function toDomain(recentFoodDTO: RecentFoodDTO): RecentFood { - return parseWithStack(recentFoodSchema, { - id: recentFoodDTO.id, - reference_id: recentFoodDTO.reference_id, - times_used: recentFoodDTO.times_used, - last_used: new Date(recentFoodDTO.last_used), - user_id: recentFoodDTO.user_id, - type: recentFoodDTO.type, - }) -} -export const supabaseRecentFoodMapper = { - toUpdateDTO, - toInsertDTO, - toDomain, -} diff --git a/src/modules/diet/recipe/infrastructure/recipeRepository.ts b/src/modules/diet/recipe/infrastructure/recipeRepository.ts index 332167ddb..6cf340ade 100644 --- a/src/modules/diet/recipe/infrastructure/recipeRepository.ts +++ b/src/modules/diet/recipe/infrastructure/recipeRepository.ts @@ -1,14 +1,43 @@ +import { createSignal } from 'solid-js' + import { type NewRecipe, type Recipe, } from '~/modules/diet/recipe/domain/recipe' import { type RecipeRepository } from '~/modules/diet/recipe/domain/recipeRepository' -import { recipeCacheStore } from '~/modules/diet/recipe/infrastructure/signals/recipeCacheStore' import { createSupabaseRecipeGateway } from '~/modules/diet/recipe/infrastructure/supabase/supabaseRecipeGateway' import { type User } from '~/modules/user/domain/user' import { logging } from '~/shared/utils/logging' const supabaseGateway = createSupabaseRecipeGateway() +const [recipes, setRecipes] = createSignal([]) + +function upsertToCache(recipe: Recipe) { + setRecipes((current) => { + const existingIndex = current.findIndex((currentRecipe) => { + return currentRecipe.id === recipe.id + }) + + if (existingIndex >= 0) { + const updated = [...current] + updated[existingIndex] = recipe + return updated + } + + return [...current, recipe] + }) +} + +function removeFromCache(criteria: { by: 'id'; value: Recipe['id'] }) { + setRecipes((current) => + current.filter((recipe) => recipe.id !== criteria.value), + ) +} + +function findInCache(criteria: { by: 'id'; value: Recipe['id'] }) { + const current = recipes() + return current.find((recipe) => recipe.id === criteria.value) ?? null +} export function createRecipeRepository(): RecipeRepository { return { @@ -27,7 +56,7 @@ async function fetchUserRecipes( try { const recipes = await supabaseGateway.fetchUserRecipes(userId) for (const recipe of recipes) { - recipeCacheStore.upsertToCache(recipe) + upsertToCache(recipe) } return recipes } catch (error) { @@ -39,21 +68,21 @@ async function fetchUserRecipes( async function fetchRecipeById(recipeId: Recipe['id']): Promise { try { // Check cache first - const cached = recipeCacheStore.findInCache({ by: 'id', value: recipeId }) + const cached = findInCache({ by: 'id', value: recipeId }) if (cached !== null) { return cached } const recipe = await supabaseGateway.fetchRecipeById(recipeId) if (recipe === null) { - recipeCacheStore.removeFromCache({ by: 'id', value: recipeId }) + removeFromCache({ by: 'id', value: recipeId }) return null } - recipeCacheStore.upsertToCache(recipe) + upsertToCache(recipe) return recipe } catch (error) { logging.error('Recipe error:', error) - recipeCacheStore.removeFromCache({ by: 'id', value: recipeId }) + removeFromCache({ by: 'id', value: recipeId }) return null } } @@ -65,7 +94,7 @@ async function fetchUserRecipeByName( try { const recipes = await supabaseGateway.fetchUserRecipeByName(userId, name) for (const recipe of recipes) { - recipeCacheStore.upsertToCache(recipe) + upsertToCache(recipe) } return recipes } catch (error) { @@ -78,7 +107,7 @@ async function insertRecipe(newRecipe: NewRecipe): Promise { try { const insertedRecipe = await supabaseGateway.insertRecipe(newRecipe) if (insertedRecipe !== null) { - recipeCacheStore.upsertToCache(insertedRecipe) + upsertToCache(insertedRecipe) } return insertedRecipe } catch (error) { @@ -97,7 +126,7 @@ async function updateRecipe( newRecipe, ) if (updatedRecipe !== null) { - recipeCacheStore.upsertToCache(updatedRecipe) + upsertToCache(updatedRecipe) } return updatedRecipe } catch (error) { @@ -109,7 +138,7 @@ async function updateRecipe( async function deleteRecipe(recipeId: Recipe['id']): Promise { try { await supabaseGateway.deleteRecipe(recipeId) - recipeCacheStore.removeFromCache({ by: 'id', value: recipeId }) + removeFromCache({ by: 'id', value: recipeId }) } catch (error) { logging.error('Recipe error:', error) } diff --git a/src/modules/diet/recipe/infrastructure/signals/recipeCacheStore.ts b/src/modules/diet/recipe/infrastructure/signals/recipeCacheStore.ts deleted file mode 100644 index 8336ed14a..000000000 --- a/src/modules/diet/recipe/infrastructure/signals/recipeCacheStore.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { createSignal } from 'solid-js' - -import { type Recipe } from '~/modules/diet/recipe/domain/recipe' - -const [recipes, setRecipes] = createSignal([]) - -export const recipeCacheStore = { - getRecipes: recipes, - setRecipes, - - upsertToCache: (recipe: Recipe) => { - setRecipes((current) => { - const existingIndex = current.findIndex((r) => r.id === recipe.id) - if (existingIndex >= 0) { - const updated = [...current] - updated[existingIndex] = recipe - return updated - } - return [...current, recipe] - }) - }, - - removeFromCache: (criteria: { by: 'id'; value: Recipe['id'] }) => { - setRecipes((current) => current.filter((r) => r.id !== criteria.value)) - }, - - clearCache: () => { - setRecipes([]) - }, - - findInCache: (criteria: { by: 'id'; value: Recipe['id'] }) => { - const current = recipes() - return current.find((r) => r.id === criteria.value) ?? null - }, -} diff --git a/src/modules/diet/recipe/infrastructure/supabase/constants.ts b/src/modules/diet/recipe/infrastructure/supabase/constants.ts deleted file mode 100644 index ceae05b29..000000000 --- a/src/modules/diet/recipe/infrastructure/supabase/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const SUPABASE_TABLE_RECIPES = 'recipes' diff --git a/src/modules/diet/recipe/infrastructure/supabase/realtime.ts b/src/modules/diet/recipe/infrastructure/supabase/realtime.ts index 8f4dd09e2..93349ac5a 100644 --- a/src/modules/diet/recipe/infrastructure/supabase/realtime.ts +++ b/src/modules/diet/recipe/infrastructure/supabase/realtime.ts @@ -1,9 +1,9 @@ import { type Recipe, recipeSchema } from '~/modules/diet/recipe/domain/recipe' -import { recipeCacheStore } from '~/modules/diet/recipe/infrastructure/signals/recipeCacheStore' -import { SUPABASE_TABLE_RECIPES } from '~/modules/diet/recipe/infrastructure/supabase/constants' import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' +const SUPABASE_TABLE_RECIPES = 'recipes' + export function createRecipeRealtimeService() { let initialized = false @@ -36,32 +36,6 @@ export function createRecipeRealtimeService() { recipeSchema, (event) => { logging.debug(`Event:`, event) - - switch (event.eventType) { - case 'INSERT': { - if (event.new !== undefined) { - recipeCacheStore.upsertToCache(event.new) - } - break - } - - case 'UPDATE': { - if (event.new) { - recipeCacheStore.upsertToCache(event.new) - } - break - } - - case 'DELETE': { - if (event.old) { - recipeCacheStore.removeFromCache({ - by: 'id', - value: event.old.id, - }) - } - break - } - } }, ) } diff --git a/src/modules/diet/recipe/infrastructure/supabase/supabaseRecipeGateway.ts b/src/modules/diet/recipe/infrastructure/supabase/supabaseRecipeGateway.ts index f3a2923b5..3ffbd1917 100644 --- a/src/modules/diet/recipe/infrastructure/supabase/supabaseRecipeGateway.ts +++ b/src/modules/diet/recipe/infrastructure/supabase/supabaseRecipeGateway.ts @@ -1,15 +1,262 @@ +import { + isFoodItem, + type Item, + itemSchema, +} from '~/modules/diet/item/schema/itemSchema' +import { + createMacroNutrients, + type MacroNutrients, +} from '~/modules/diet/macro-nutrients/domain/macroNutrients' import { type NewRecipe, type Recipe, + recipeSchema, } from '~/modules/diet/recipe/domain/recipe' import { type RecipeGateway } from '~/modules/diet/recipe/domain/recipeGateway' -import { SUPABASE_TABLE_RECIPES } from '~/modules/diet/recipe/infrastructure/supabase/constants' -import { supabaseRecipeMapper } from '~/modules/diet/recipe/infrastructure/supabase/supabaseRecipeMapper' import { type User } from '~/modules/user/domain/user' +import { type Database, type Json } from '~/shared/supabase/database.types' import { supabase } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' +import { parseWithStack } from '~/shared/utils/parseWithStack' import { removeDiacritics } from '~/shared/utils/removeDiacritics' +const SUPABASE_TABLE_RECIPES = 'recipes' + +function macrosToDomain(macrosDTO: Json) { + if ( + macrosDTO === null || + !(typeof macrosDTO === 'object') || + !('carbs' in macrosDTO) || + !('protein' in macrosDTO) || + !('fat' in macrosDTO) || + typeof macrosDTO.carbs !== 'number' || + typeof macrosDTO.protein !== 'number' || + typeof macrosDTO.fat !== 'number' + ) { + throw new Error( + 'macrosDTO is missing macros field: ' + JSON.stringify(macrosDTO), + ) + } + + return createMacroNutrients({ + carbsInMg: macrosDTO.carbs * 1000, + proteinInMg: macrosDTO.protein * 1000, + fatInMg: macrosDTO.fat * 1000, + }) +} + +function macrosToDTO(macroNutrients: MacroNutrients): Json { + return { + carbs: macroNutrients.carbsInMg / 1000, + protein: macroNutrients.proteinInMg / 1000, + fat: macroNutrients.fatInMg / 1000, + } +} + +function itemToDomain(itemDTO: Json): Item { + if ( + itemDTO === null || + !(typeof itemDTO === 'object') || + !('id' in itemDTO) || + !('name' in itemDTO) || + !('quantity' in itemDTO) || + !('reference' in itemDTO) || + typeof itemDTO.id !== 'number' || + typeof itemDTO.name !== 'string' || + typeof itemDTO.quantity !== 'number' || + typeof itemDTO.reference !== 'object' || + itemDTO.reference === null || + Array.isArray(itemDTO.reference) || + !('type' in itemDTO.reference) + ) { + throw new Error( + 'Item DTO is missing required fields: ' + JSON.stringify(itemDTO), + ) + } + + const referenceType = itemDTO.reference.type + if ( + referenceType === 'food' && + (!('id' in itemDTO.reference) || + typeof itemDTO.reference.id !== 'number' || + !('macros' in itemDTO.reference) || + typeof itemDTO.reference.macros !== 'object' || + itemDTO.reference.macros === null) + ) { + throw new Error( + 'Food Item DTO reference is missing required fields: ' + + JSON.stringify(itemDTO.reference), + ) + } else if ( + referenceType === 'recipe' && + (!('id' in itemDTO.reference) || + typeof itemDTO.reference.id !== 'number' || + !('children' in itemDTO.reference) || + !Array.isArray(itemDTO.reference.children)) + ) { + throw new Error( + 'Recipe Item DTO reference is missing required fields: ' + + JSON.stringify(itemDTO.reference), + ) + } else if ( + referenceType === 'group' && + (!('children' in itemDTO.reference) || + !Array.isArray(itemDTO.reference.children)) + ) { + throw new Error( + 'Group Item DTO reference is missing required fields: ' + + JSON.stringify(itemDTO.reference), + ) + } + + if ( + referenceType !== 'food' && + referenceType !== 'recipe' && + referenceType !== 'group' + ) { + throw new Error( + 'Item DTO reference has invalid type: ' + + JSON.stringify(itemDTO.reference), + ) + } + + if (referenceType === 'food') { + if ( + itemDTO.reference.macros === null || + typeof itemDTO.reference.macros !== 'object' + ) { + throw new Error( + 'Food Item DTO reference macros is invalid: ' + + JSON.stringify(itemDTO.reference.macros), + ) + } + + return parseWithStack(itemSchema, { + id: itemDTO.id, + name: itemDTO.name, + quantity: itemDTO.quantity, + reference: { + type: 'food', + id: itemDTO.reference.id, + macros: macrosToDomain(itemDTO.reference.macros), + }, + __type: 'UnifiedItem', + }) + } + + if ( + itemDTO.reference.children === null || + !Array.isArray(itemDTO.reference.children) + ) { + throw new Error( + 'Item DTO reference children is invalid: ' + + JSON.stringify(itemDTO.reference.children), + ) + } + + const children: Item[] = itemDTO.reference.children.map((childDTO: Json) => + itemToDomain(childDTO), + ) + + if (referenceType === 'recipe') { + return parseWithStack(itemSchema, { + id: itemDTO.id, + name: itemDTO.name, + quantity: itemDTO.quantity, + reference: { + type: 'recipe', + id: itemDTO.reference.id, + children, + }, + __type: 'UnifiedItem' as const, + }) + } + + return parseWithStack(itemSchema, { + id: itemDTO.id, + name: itemDTO.name, + quantity: itemDTO.quantity, + reference: { + type: 'group', + children, + }, + __type: 'UnifiedItem' as const, + }) +} + +function itemToDTO(item: Item): Json { + if (isFoodItem(item)) { + return { + id: item.id, + name: item.name, + quantity: item.quantity, + reference: { + type: 'food', + id: item.reference.id, + macros: macrosToDTO(item.reference.macros), + }, + } + } + + if (item.reference.type === 'recipe') { + return { + id: item.id, + name: item.name, + quantity: item.quantity, + reference: { + type: 'recipe', + id: item.reference.id, + children: item.reference.children.map((child) => itemToDTO(child)), + }, + } + } + + return { + id: item.id, + name: item.name, + quantity: item.quantity, + reference: { + type: 'group', + children: item.reference.children.map((child) => itemToDTO(child)), + }, + } +} + +function recipeToInsertDTO(recipe: NewRecipe) { + return { + name: recipe.name, + user_id: recipe.user_id, + items: recipe.items.map((item) => itemToDTO(item)), + prepared_multiplier: recipe.prepared_multiplier, + } +} + +function recipeToUpdateDTO(recipe: Recipe) { + return { + name: recipe.name, + user_id: recipe.user_id, + items: recipe.items.map((item) => itemToDTO(item)), + prepared_multiplier: recipe.prepared_multiplier, + } +} + +type RecipeRow = Database['public']['Tables']['recipes']['Row'] + +function recipeToDomain(dto: RecipeRow): Recipe { + if (!Array.isArray(dto.items)) { + throw new Error('Recipe DTO items field is not an array') + } + + if (dto.user_id === null) { + throw new Error('Recipe DTO user_id cannot be null') + } + + return parseWithStack(recipeSchema, { + ...dto, + items: dto.items.map((itemDTO) => itemToDomain(itemDTO)), + }) +} + export function createSupabaseRecipeGateway(): RecipeGateway { return { fetchUserRecipes, @@ -21,11 +268,6 @@ export function createSupabaseRecipeGateway(): RecipeGateway { } } -/** - * Fetches all recipes for a user. - * @param userId - The user ID - * @returns Array of recipes or empty array on error - */ const fetchUserRecipes = async ( userId: User['uuid'], ): Promise => { @@ -34,22 +276,19 @@ const fetchUserRecipes = async ( .from(SUPABASE_TABLE_RECIPES) .select() .eq('user_id', userId) + if (error !== null) { logging.error('Recipe fetch error:', error) return [] } - return data.map(supabaseRecipeMapper.toDomain) + + return data.map(recipeToDomain) } catch (err) { logging.error('Recipe fetch error:', err) return [] } } -/** - * Fetches a recipe by its ID. - * @param id - The recipe ID - * @returns The recipe or null if not found/error - */ const fetchRecipeById = async (id: Recipe['id']): Promise => { try { const { data, error } = await supabase @@ -63,51 +302,40 @@ const fetchRecipeById = async (id: Recipe['id']): Promise => { return null } - return supabaseRecipeMapper.toDomain(data) + return recipeToDomain(data) } catch (err) { logging.error('Recipe fetch error:', err) return null } } -/** - * Fetches a user's recipe by name (partial, case-insensitive, diacritic-insensitive). - * @param userId - The user ID - * @param name - The recipe name (partial or full) - * @returns Array of recipes or empty array on error - */ const fetchUserRecipeByName = async ( userId: User['uuid'], name: Recipe['name'], ): Promise => { try { - // Normalize diacritics for search const normalizedName = removeDiacritics(name) const { data, error } = await supabase .from(SUPABASE_TABLE_RECIPES) .select() .eq('user_id', userId) .ilike('name', `%${normalizedName}%`) + if (error !== null) { logging.error('Recipe fetch error:', error) return [] } - return data.map(supabaseRecipeMapper.toDomain) + return data.map(recipeToDomain) } catch (err) { logging.error('Recipe fetch error:', err) return [] } } -/** - * Inserts a new recipe. - * @param newRecipe - The new recipe - * @returns The created recipe or null on error - */ const insertRecipe = async (newRecipe: NewRecipe): Promise => { try { - const createDTO = supabaseRecipeMapper.toInsertDTO(newRecipe) + const createDTO = recipeToInsertDTO(newRecipe) const { data, error } = await supabase .from(SUPABASE_TABLE_RECIPES) .insert(createDTO) @@ -119,25 +347,19 @@ const insertRecipe = async (newRecipe: NewRecipe): Promise => { return null } - return supabaseRecipeMapper.toDomain(data) + return recipeToDomain(data) } catch (err) { logging.error('Recipe fetch error:', err) return null } } -/** - * Updates a recipe. - * @param recipeId - The recipe ID - * @param newRecipe - The new recipe data - * @returns The updated recipe or null on error - */ const updateRecipe = async ( recipeId: Recipe['id'], newRecipe: Recipe, ): Promise => { try { - const updateDTO = supabaseRecipeMapper.toUpdateDTO(newRecipe) + const updateDTO = recipeToUpdateDTO(newRecipe) const { data, error } = await supabase .from(SUPABASE_TABLE_RECIPES) @@ -145,28 +367,26 @@ const updateRecipe = async ( .eq('id', recipeId) .select() .single() + if (error !== null) { logging.error('Recipe fetch error:', error) return null } - return supabaseRecipeMapper.toDomain(data) + return recipeToDomain(data) } catch (err) { logging.error('Recipe fetch error:', err) return null } } -/** - * Deletes a recipe by ID. - * @param id - The recipe ID - */ const deleteRecipe = async (id: Recipe['id']): Promise => { try { const { error } = await supabase .from(SUPABASE_TABLE_RECIPES) .delete() .eq('id', id) + if (error !== null) { logging.error('Recipe fetch error:', error) } diff --git a/src/modules/diet/recipe/infrastructure/supabase/supabaseRecipeMapper.ts b/src/modules/diet/recipe/infrastructure/supabase/supabaseRecipeMapper.ts deleted file mode 100644 index 51438cde4..000000000 --- a/src/modules/diet/recipe/infrastructure/supabase/supabaseRecipeMapper.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { supabaseItemMapper } from '~/modules/diet/item/infrastructure/supabase/supabaseItemMapper' -import { - type NewRecipe, - type Recipe, - recipeSchema, -} from '~/modules/diet/recipe/domain/recipe' -import { type Database } from '~/shared/supabase/database.types' -import { parseWithStack } from '~/shared/utils/parseWithStack' - -// Types -type RecipeDTO = Database['public']['Tables']['recipes']['Row'] -type InsertRecipeDTO = Database['public']['Tables']['recipes']['Insert'] -type UpdateRecipeDTO = Database['public']['Tables']['recipes']['Update'] - -function toInsertDTO(recipe: NewRecipe): InsertRecipeDTO { - return { - name: recipe.name, - user_id: recipe.user_id, - items: recipe.items.map((item) => supabaseItemMapper.toInsertDTO(item)), - prepared_multiplier: recipe.prepared_multiplier, - } -} - -function toUpdateDTO(recipe: Recipe): UpdateRecipeDTO { - return { - name: recipe.name, - user_id: recipe.user_id, - items: recipe.items.map((item) => supabaseItemMapper.toUpdateDTO(item)), - prepared_multiplier: recipe.prepared_multiplier, - } -} - -function toDomain(dto: RecipeDTO): Recipe { - if (!Array.isArray(dto.items)) { - throw new Error('Recipe DTO items field is not an array') - } - return parseWithStack(recipeSchema, { - ...dto, - items: dto.items.map((itemDTO) => supabaseItemMapper.toDomain(itemDTO)), - }) -} - -export const supabaseRecipeMapper = { - toDomain, - toInsertDTO, - toUpdateDTO, -} diff --git a/src/modules/measure/infrastructure/supabase/supabaseBodyMeasureGateway.ts b/src/modules/measure/infrastructure/supabase/supabaseBodyMeasureGateway.ts index 22493a3c7..2441e64ed 100644 --- a/src/modules/measure/infrastructure/supabase/supabaseBodyMeasureGateway.ts +++ b/src/modules/measure/infrastructure/supabase/supabaseBodyMeasureGateway.ts @@ -1,16 +1,44 @@ import { type BodyMeasure, + bodyMeasureSchema, type NewBodyMeasure, } from '~/modules/measure/domain/measure' import { type BodyMeasureGateway } from '~/modules/measure/domain/measureGateway' -import { createSupabaseBodyMeasureMapper } from '~/modules/measure/infrastructure/supabase/supabaseMeasureMapper' import { type User } from '~/modules/user/domain/user' +import { type Database } from '~/shared/supabase/database.types' import { supabase } from '~/shared/supabase/supabase' import { wrapErrorWithStack } from '~/shared/utils/errorUtils' import { logging } from '~/shared/utils/logging' +import { parseWithStack } from '~/shared/utils/parseWithStack' const SUPABASE_TABLE_BODY_MEASURES = 'body_measures' -const supabaseBodyMeasureMapper = createSupabaseBodyMeasureMapper() + +type InsertBodyMeasureDTO = + Database['public']['Tables']['body_measures']['Insert'] +type BodyMeasureDTO = Database['public']['Tables']['body_measures']['Row'] + +function toInsertDTO(newBodyMeasure: NewBodyMeasure): InsertBodyMeasureDTO { + return { + height: newBodyMeasure.height, + waist: newBodyMeasure.waist, + hip: newBodyMeasure.hip, + neck: newBodyMeasure.neck, + user_id: newBodyMeasure.user_id, + target_timestamp: newBodyMeasure.target_timestamp.toISOString(), + } +} + +function toDomain(dto: BodyMeasureDTO): BodyMeasure { + return parseWithStack(bodyMeasureSchema, { + id: dto.id, + height: dto.height, + waist: dto.waist, + hip: dto.hip === null ? undefined : dto.hip, + neck: dto.neck, + user_id: dto.user_id, + target_timestamp: new Date(dto.target_timestamp), + }) +} /** * Creates the Supabase gateway used to persist body measures. @@ -38,13 +66,13 @@ async function fetchUserBodyMeasures(userId: User['uuid']) { throw wrapErrorWithStack(error) } - return data.map(supabaseBodyMeasureMapper.toDomain) + return data.map(toDomain) } async function insertBodyMeasure( newBodyMeasure: NewBodyMeasure, ): Promise { - const createDTO = supabaseBodyMeasureMapper.toInsertDTO(newBodyMeasure) + const createDTO = toInsertDTO(newBodyMeasure) const { data, error } = await supabase .from(SUPABASE_TABLE_BODY_MEASURES) .insert(createDTO) @@ -56,14 +84,14 @@ async function insertBodyMeasure( throw wrapErrorWithStack(error) } - return supabaseBodyMeasureMapper.toDomain(data) + return toDomain(data) } async function updateBodyMeasure( bodyMeasureId: BodyMeasure['id'], newBodyMeasure: NewBodyMeasure, ): Promise { - const updateDTO = supabaseBodyMeasureMapper.toInsertDTO(newBodyMeasure) + const updateDTO = toInsertDTO(newBodyMeasure) const { data, error } = await supabase .from(SUPABASE_TABLE_BODY_MEASURES) .update(updateDTO) @@ -76,7 +104,7 @@ async function updateBodyMeasure( throw wrapErrorWithStack(error) } - return supabaseBodyMeasureMapper.toDomain(data) + return toDomain(data) } async function deleteBodyMeasure(id: BodyMeasure['id']) { diff --git a/src/modules/measure/infrastructure/supabase/supabaseMeasureMapper.ts b/src/modules/measure/infrastructure/supabase/supabaseMeasureMapper.ts deleted file mode 100644 index 819cf6230..000000000 --- a/src/modules/measure/infrastructure/supabase/supabaseMeasureMapper.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - type BodyMeasure, - bodyMeasureSchema, - type NewBodyMeasure, -} from '~/modules/measure/domain/measure' -import { type Database } from '~/shared/supabase/database.types' -import { parseWithStack } from '~/shared/utils/parseWithStack' - -type InsertBodyMeasureDTO = - Database['public']['Tables']['body_measures']['Insert'] -type BodyMeasureDTO = Database['public']['Tables']['body_measures']['Row'] - -// Conversion functions -function toInsertDTO(newBodyMeasure: NewBodyMeasure): InsertBodyMeasureDTO { - return { - height: newBodyMeasure.height, - waist: newBodyMeasure.waist, - hip: newBodyMeasure.hip, - neck: newBodyMeasure.neck, - user_id: newBodyMeasure.user_id, - target_timestamp: newBodyMeasure.target_timestamp.toISOString(), - } -} - -function toDomain(dto: BodyMeasureDTO): BodyMeasure { - return parseWithStack(bodyMeasureSchema, { - id: dto.id, - height: dto.height, - waist: dto.waist, - hip: dto.hip === null ? undefined : dto.hip, - neck: dto.neck, - user_id: dto.user_id, - target_timestamp: new Date(dto.target_timestamp), - }) -} - -/** - * Creates the mapper responsible for converting body measure data - * between Supabase DTOs and domain entities. - * - * @returns Mapper functions for reading and writing body measure records. - */ -export function createSupabaseBodyMeasureMapper() { - return { - toDomain, - toInsertDTO, - } -} diff --git a/src/modules/measure/infrastructure/tests/measureInfrastructureExports.test.ts b/src/modules/measure/infrastructure/tests/measureInfrastructureExports.test.ts index 4c7094a7e..6ce581c88 100644 --- a/src/modules/measure/infrastructure/tests/measureInfrastructureExports.test.ts +++ b/src/modules/measure/infrastructure/tests/measureInfrastructureExports.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from 'vitest' import * as measureRepositoryModule from '~/modules/measure/infrastructure/measureRepository' import * as realtimeModule from '~/modules/measure/infrastructure/supabase/realtime' import * as gatewayModule from '~/modules/measure/infrastructure/supabase/supabaseBodyMeasureGateway' -import * as mapperModule from '~/modules/measure/infrastructure/supabase/supabaseMeasureMapper' describe('measure infrastructure module exports', () => { it('exports only the measure repository factory', () => { @@ -18,12 +17,6 @@ describe('measure infrastructure module exports', () => { ]) }) - it('exports only the Supabase mapper factory', () => { - expect(Object.keys(mapperModule)).toEqual([ - 'createSupabaseBodyMeasureMapper', - ]) - }) - it('keeps realtime exports limited to the public factory', () => { expect(Object.keys(realtimeModule)).toEqual([ 'createMeasureRealtimeService', diff --git a/src/modules/observability/application/telemetry.ts b/src/modules/observability/application/telemetry.ts index 160d6d169..c49eef34c 100644 --- a/src/modules/observability/application/telemetry.ts +++ b/src/modules/observability/application/telemetry.ts @@ -1,7 +1,7 @@ -import { type createSentryService } from '~/modules/observability/infrastructure/sentry/sentry' - export type TelemetryDeps = { - sentryService: ReturnType + sentryService: { + initializeSentry: (type: 'server' | 'client') => Promise | void + } } export function createTelemetry(deps: TelemetryDeps) { diff --git a/src/modules/observability/infrastructure/sentry/clientIntegrations.ts b/src/modules/observability/infrastructure/sentry/clientIntegrations.ts deleted file mode 100644 index 327f29820..000000000 --- a/src/modules/observability/infrastructure/sentry/clientIntegrations.ts +++ /dev/null @@ -1,20 +0,0 @@ -import * as Sentry from '@sentry/solidstart' - -export async function createClientIntegrations() { - const { solidRouterBrowserTracingIntegration } = - await import('@sentry/solidstart/solidrouter') - - return [ - solidRouterBrowserTracingIntegration(), - Sentry.browserTracingIntegration({ - traceFetch: true, - traceXHR: true, - }), - Sentry.browserProfilingIntegration(), - Sentry.replayIntegration({ - maskAllText: false, - maskAllInputs: false, - }), - Sentry.consoleLoggingIntegration(), - ] -} diff --git a/src/modules/observability/infrastructure/sentry/config.ts b/src/modules/observability/infrastructure/sentry/config.ts deleted file mode 100644 index ce2196ed6..000000000 --- a/src/modules/observability/infrastructure/sentry/config.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { APP_VERSION } from '~/app-version' - -export type SentryConfig = { - dsn?: string - release: string - useOTel: boolean -} - -export function createSentryConfig(): SentryConfig { - const release = `macroflows@${APP_VERSION}` - - return { - dsn: - typeof import.meta.env.VITE_SENTRY_DSN === 'string' - ? import.meta.env.VITE_SENTRY_DSN - : undefined, - release, - useOTel: false, - } -} diff --git a/src/modules/observability/infrastructure/sentry/otelIntegration.ts b/src/modules/observability/infrastructure/sentry/otelIntegration.ts deleted file mode 100644 index 2a2f75ff9..000000000 --- a/src/modules/observability/infrastructure/sentry/otelIntegration.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { context, propagation, trace } from '@opentelemetry/api' -import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base' -import * as SentryOTel from '@sentry/opentelemetry' -import * as Sentry from '@sentry/solidstart' - -export async function setupSentryOTelIntegration(type: 'server' | 'client') { - const client = Sentry.getClient() - if (client === undefined) { - console.warn('Sentry OTel Fatal Error: Sentry client is undefined') - return - } - SentryOTel.setupEventContextTrace(client) - - const provider = new BasicTracerProvider({ - sampler: new SentryOTel.SentrySampler(client), - spanProcessors: [new SentryOTel.SentrySpanProcessor()], - }) - - const SentryContextManager = await wrapContextManagerClass(type) - - trace.setGlobalTracerProvider(provider) - propagation.setGlobalPropagator(new SentryOTel.SentryPropagator()) - context.setGlobalContextManager(new SentryContextManager()) - - SentryOTel.setOpenTelemetryContextAsyncContextStrategy() -} - -async function wrapContextManagerClass(type: 'server' | 'client') { - if (type === 'client') { - const { ZoneContextManager } = await import('@opentelemetry/context-zone') - return SentryOTel.wrapContextManagerClass(ZoneContextManager) - } else { - const { AsyncLocalStorageContextManager } = - await import('@opentelemetry/context-async-hooks') - return SentryOTel.wrapContextManagerClass(AsyncLocalStorageContextManager) - } -} diff --git a/src/modules/observability/infrastructure/sentry/sentry.ts b/src/modules/observability/infrastructure/sentry/sentry.ts index 730c01a1e..7a558e56a 100644 --- a/src/modules/observability/infrastructure/sentry/sentry.ts +++ b/src/modules/observability/infrastructure/sentry/sentry.ts @@ -1,8 +1,84 @@ import * as Sentry from '@sentry/solidstart' -import { createClientIntegrations } from '~/modules/observability/infrastructure/sentry/clientIntegrations' -import { createSentryConfig } from '~/modules/observability/infrastructure/sentry/config' -import { setupSentryOTelIntegration } from '~/modules/observability/infrastructure/sentry/otelIntegration' +import { APP_VERSION } from '~/app-version' + +type SentryConfig = { + dsn?: string + release: string + useOTel: boolean +} + +function createSentryConfig(): SentryConfig { + const release = `macroflows@${APP_VERSION}` + + return { + dsn: + typeof import.meta.env.VITE_SENTRY_DSN === 'string' + ? import.meta.env.VITE_SENTRY_DSN + : undefined, + release, + useOTel: false, + } +} + +async function createClientIntegrations() { + const { solidRouterBrowserTracingIntegration } = + await import('@sentry/solidstart/solidrouter') + + return [ + solidRouterBrowserTracingIntegration(), + Sentry.browserTracingIntegration({ + traceFetch: true, + traceXHR: true, + }), + Sentry.browserProfilingIntegration(), + Sentry.replayIntegration({ + maskAllText: false, + maskAllInputs: false, + }), + Sentry.consoleLoggingIntegration(), + ] +} + +async function setupSentryOTelIntegration(type: 'server' | 'client') { + const client = Sentry.getClient() + if (client === undefined) { + console.warn('Sentry OTel Fatal Error: Sentry client is undefined') + return + } + + const { context, propagation, trace } = await import('@opentelemetry/api') + const { BasicTracerProvider } = await import('@opentelemetry/sdk-trace-base') + const SentryOTel = await import('@sentry/opentelemetry') + + SentryOTel.setupEventContextTrace(client) + + const provider = new BasicTracerProvider({ + sampler: new SentryOTel.SentrySampler(client), + spanProcessors: [new SentryOTel.SentrySpanProcessor()], + }) + + const SentryContextManager = await wrapContextManagerClass(type) + + trace.setGlobalTracerProvider(provider) + propagation.setGlobalPropagator(new SentryOTel.SentryPropagator()) + context.setGlobalContextManager(new SentryContextManager()) + + SentryOTel.setOpenTelemetryContextAsyncContextStrategy() +} + +async function wrapContextManagerClass(type: 'server' | 'client') { + const SentryOTel = await import('@sentry/opentelemetry') + + if (type === 'client') { + const { ZoneContextManager } = await import('@opentelemetry/context-zone') + return SentryOTel.wrapContextManagerClass(ZoneContextManager) + } + + const { AsyncLocalStorageContextManager } = + await import('@opentelemetry/context-async-hooks') + return SentryOTel.wrapContextManagerClass(AsyncLocalStorageContextManager) +} export function createSentryService(deps?: { createSentryConfig?: typeof createSentryConfig diff --git a/src/modules/search/infrastructure/cachedSearchRepository.ts b/src/modules/search/infrastructure/cachedSearchRepository.ts index 41248d141..10b6867e8 100644 --- a/src/modules/search/infrastructure/cachedSearchRepository.ts +++ b/src/modules/search/infrastructure/cachedSearchRepository.ts @@ -1,41 +1,15 @@ -import { cachedSearchSchema } from '~/modules/search/domain/cachedSearch' import { type CachedSearchRepository } from '~/modules/search/domain/searchRepository' -import { cachedSearchCacheStore } from '~/modules/search/infrastructure/signals/cachedSearchCacheStore' import { createSupabaseCachedSearchGateway } from '~/modules/search/infrastructure/supabase/supabaseCachedSearchGateway' -import { parseWithStack } from '~/shared/utils/parseWithStack' export function createCachedSearchRepository(): CachedSearchRepository { const gateway = createSupabaseCachedSearchGateway() return { - isSearchCached: async (searchQuery) => { - const result = await gateway.isSearchCached(searchQuery) - if (result) { - cachedSearchCacheStore.upsertToCache( - parseWithStack(cachedSearchSchema, { search: searchQuery }), - ) - } else { - cachedSearchCacheStore.removeFromCache({ - by: 'search', - value: searchQuery, - }) - } - return result - }, - markSearchAsCached: async (searchQuery) => { - await gateway.markSearchAsCached(searchQuery) - cachedSearchCacheStore.upsertToCache( - parseWithStack(cachedSearchSchema, { - search: searchQuery, - }), - ) - }, - unmarkSearchAsCached: async (searchQuery) => { - await gateway.unmarkSearchAsCached(searchQuery) - cachedSearchCacheStore.removeFromCache({ - by: 'search', - value: searchQuery, - }) - }, + isSearchCached: async (searchQuery) => + await gateway.isSearchCached(searchQuery), + markSearchAsCached: async (searchQuery) => + await gateway.markSearchAsCached(searchQuery), + unmarkSearchAsCached: async (searchQuery) => + await gateway.unmarkSearchAsCached(searchQuery), } } diff --git a/src/modules/search/infrastructure/signals/cachedSearchCacheStore.ts b/src/modules/search/infrastructure/signals/cachedSearchCacheStore.ts deleted file mode 100644 index cb6ee6862..000000000 --- a/src/modules/search/infrastructure/signals/cachedSearchCacheStore.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createSignal } from 'solid-js' - -import { type CachedSearch } from '~/modules/search/domain/cachedSearch' -import { logging } from '~/shared/utils/logging' - -const [cachedSearches, setCachedSearches] = createSignal< - readonly CachedSearch[] ->([]) - -export const cachedSearchCacheStore = { - // Upsert to cache (from realtime events) - upsertToCache: (cachedSearch: CachedSearch) => { - const current = cachedSearches() - const existingIndex = current.findIndex( - (search) => search.search === cachedSearch.search, - ) - - if (existingIndex >= 0) { - // Update existing - const updated = [...current] - updated[existingIndex] = cachedSearch - setCachedSearches(updated) - logging.debug('Updated cached search in cache:', { cachedSearch }) - } else { - // Add new - setCachedSearches([cachedSearch, ...current]) - logging.debug('Added new cached search to cache:', { cachedSearch }) - } - }, - - // Remove from cache (from realtime events) - removeFromCache: (selector: { - by: 'search' - value: CachedSearch['search'] - }) => { - const current = cachedSearches() - const searchToRemove = current.find( - (search) => search.search === selector.value, - ) - - if (searchToRemove) { - const updated = current.filter( - (search) => search.search !== selector.value, - ) - setCachedSearches(updated) - - logging.debug('Removed cached search from cache:', { searchToRemove }) - } - }, -} diff --git a/src/modules/search/infrastructure/signals/cachedSearchEffects.ts b/src/modules/search/infrastructure/signals/cachedSearchEffects.ts deleted file mode 100644 index cf20b8e3a..000000000 --- a/src/modules/search/infrastructure/signals/cachedSearchEffects.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { onCleanup } from 'solid-js' - -import { initializeCachedSearchRealtime } from '~/modules/search/infrastructure/supabase/realtime' -import { logging } from '~/shared/utils/logging' - -let effectsInitialized = false - -export function initializeCachedSearchEffects() { - if (effectsInitialized) { - logging.debug('Cached search effects already initialized') - return - } - - logging.debug('Initializing cached search effects') - effectsInitialized = true - - // Initialize realtime subscription - initializeCachedSearchRealtime() - - onCleanup(() => { - logging.debug('Cleaning up cached search effects') - effectsInitialized = false - }) -} diff --git a/src/modules/search/infrastructure/supabase/constants.ts b/src/modules/search/infrastructure/supabase/constants.ts deleted file mode 100644 index f1418cbfb..000000000 --- a/src/modules/search/infrastructure/supabase/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const SUPABASE_TABLE_CACHED_SEARCHES = 'cached_searches' diff --git a/src/modules/search/infrastructure/supabase/realtime.ts b/src/modules/search/infrastructure/supabase/realtime.ts deleted file mode 100644 index 3c32e0620..000000000 --- a/src/modules/search/infrastructure/supabase/realtime.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { - type CachedSearch, - cachedSearchSchema, -} from '~/modules/search/domain/cachedSearch' -import { cachedSearchCacheStore } from '~/modules/search/infrastructure/signals/cachedSearchCacheStore' -import { SUPABASE_TABLE_CACHED_SEARCHES } from '~/modules/search/infrastructure/supabase/constants' -import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase' -import { logging } from '~/shared/utils/logging' - -let initialized = false - -export type CachedSearchRealtimeEvent = { - eventType: 'INSERT' | 'UPDATE' | 'DELETE' - new?: CachedSearch - old?: CachedSearch -} - -export function setupCachedSearchRealtimeSubscription( - onEvent: (event: CachedSearchRealtimeEvent) => void, -): void { - registerSubapabaseRealtimeCallback( - SUPABASE_TABLE_CACHED_SEARCHES, - cachedSearchSchema, - onEvent, - ) -} - -export function initializeCachedSearchRealtime(): void { - if (initialized) { - return - } - logging.debug(`Cached search realtime initialized!`) - initialized = true - registerSubapabaseRealtimeCallback( - SUPABASE_TABLE_CACHED_SEARCHES, - cachedSearchSchema, - (event) => { - logging.debug(`Event:`, event) - - switch (event.eventType) { - case 'INSERT': { - if (event.new !== undefined) { - cachedSearchCacheStore.upsertToCache(event.new) - } - break - } - - case 'UPDATE': { - if (event.new) { - cachedSearchCacheStore.upsertToCache(event.new) - } - break - } - - case 'DELETE': { - if (event.old) { - cachedSearchCacheStore.removeFromCache({ - by: 'search', - value: event.old.search, - }) - } - break - } - } - }, - ) -} diff --git a/src/modules/search/infrastructure/supabase/supabaseCachedSearchGateway.ts b/src/modules/search/infrastructure/supabase/supabaseCachedSearchGateway.ts index d3c548a8b..06f40f7ee 100644 --- a/src/modules/search/infrastructure/supabase/supabaseCachedSearchGateway.ts +++ b/src/modules/search/infrastructure/supabase/supabaseCachedSearchGateway.ts @@ -3,14 +3,24 @@ import { createNormalizedSearch, } from '~/modules/search/domain/cachedSearch' import { type CachedSearchGateway } from '~/modules/search/domain/searchGateway' -import { SUPABASE_TABLE_CACHED_SEARCHES } from '~/modules/search/infrastructure/supabase/constants' -import { createSupabaseCachedSearchMapper } from '~/modules/search/infrastructure/supabase/supabaseCachedSearchMapper' +import { type Database } from '~/shared/supabase/database.types' import { supabase } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' -export function createSupabaseCachedSearchGateway(): CachedSearchGateway { - const mapper = createSupabaseCachedSearchMapper() +const SUPABASE_TABLE_CACHED_SEARCHES = 'cached_searches' + +type InsertCachedSearchDTO = + Database['public']['Tables']['cached_searches']['Insert'] +function toInsertDTO( + domainData: ReturnType, +): InsertCachedSearchDTO { + return { + search: domainData.search, + } +} + +export function createSupabaseCachedSearchGateway(): CachedSearchGateway { return { async isSearchCached(searchQuery: string): Promise { try { @@ -47,7 +57,7 @@ export function createSupabaseCachedSearchGateway(): CachedSearchGateway { return } - const insertData = mapper.toInsertDTO( + const insertData = toInsertDTO( createNewCachedSearch({ search: normalizedSearch, }), diff --git a/src/modules/search/infrastructure/supabase/supabaseCachedSearchMapper.ts b/src/modules/search/infrastructure/supabase/supabaseCachedSearchMapper.ts deleted file mode 100644 index 070ecefa3..000000000 --- a/src/modules/search/infrastructure/supabase/supabaseCachedSearchMapper.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { - type CachedSearch, - cachedSearchSchema, - type NewCachedSearch, -} from '~/modules/search/domain/cachedSearch' -import { type Database } from '~/shared/supabase/database.types' -import { parseWithStack } from '~/shared/utils/parseWithStack' - -type CachedSearchDTO = Database['public']['Tables']['cached_searches']['Row'] -type InsertCachedSearchDTO = - Database['public']['Tables']['cached_searches']['Insert'] - -function toDomain(supabaseData: CachedSearchDTO): CachedSearch { - return parseWithStack(cachedSearchSchema, { - search: supabaseData.search, - }) -} - -function toInsertDTO(domainData: NewCachedSearch): InsertCachedSearchDTO { - return { - search: domainData.search, - } -} - -/** - * Factory for the Supabase cached-search mapper. - * - * @returns Cached-search mapping helpers for Supabase DTOs. - */ -export function createSupabaseCachedSearchMapper() { - return { - toDomain, - toInsertDTO, - } -} diff --git a/src/modules/search/infrastructure/supabase/tests/supabaseCachedSearchMapper.test.ts b/src/modules/search/infrastructure/supabase/tests/supabaseCachedSearchMapper.test.ts deleted file mode 100644 index a8dc0af53..000000000 --- a/src/modules/search/infrastructure/supabase/tests/supabaseCachedSearchMapper.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { createNewCachedSearch } from '~/modules/search/domain/cachedSearch' -import { createSupabaseCachedSearchMapper } from '~/modules/search/infrastructure/supabase/supabaseCachedSearchMapper' -import { type Database } from '~/shared/supabase/database.types' - -describe('createSupabaseCachedSearchMapper', () => { - it('creates a mapper that converts cached searches to and from Supabase DTOs', () => { - const mapper = createSupabaseCachedSearchMapper() - const search = ' arroz integral ' - - const dto: Database['public']['Tables']['cached_searches']['Row'] = { - created_at: '2026-01-01T00:00:00.000Z', - search, - } - - expect(mapper.toDomain(dto).search).toBe(search) - expect( - mapper.toInsertDTO( - createNewCachedSearch({ - search, - }), - ), - ).toEqual({ - search, - }) - }) -}) diff --git a/src/modules/user/infrastructure/supabase/constants.ts b/src/modules/user/infrastructure/supabase/constants.ts deleted file mode 100644 index 35566893b..000000000 --- a/src/modules/user/infrastructure/supabase/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const SUPABASE_TABLE_USERS = 'users' diff --git a/src/modules/user/infrastructure/supabase/supabaseUserMapper.ts b/src/modules/user/infrastructure/supabase/supabaseUserMapper.ts deleted file mode 100644 index 2849180e9..000000000 --- a/src/modules/user/infrastructure/supabase/supabaseUserMapper.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { type NewUser, type User, userSchema } from '~/modules/user/domain/user' -import { type Database } from '~/shared/supabase/database.types' -import { parseWithStack } from '~/shared/utils/parseWithStack' - -export type InsertUserDTO = Database['public']['Tables']['users']['Insert'] -export type UpdateUserDTO = Database['public']['Tables']['users']['Update'] -export type UserDTO = Database['public']['Tables']['users']['Row'] - -function toInsertDTO(newUser: NewUser): InsertUserDTO { - return { - name: newUser.name, - favorite_foods: newUser.favorite_foods, - diet: newUser.diet, - birthdate: newUser.birthdate, - gender: newUser.gender, - desired_weight: newUser.desired_weight, - uuid: newUser.uuid, - } -} - -function toUpdateDTO(newUser: NewUser): UpdateUserDTO { - return { - name: newUser.name, - favorite_foods: newUser.favorite_foods, - diet: newUser.diet, - birthdate: newUser.birthdate, - gender: newUser.gender, - desired_weight: newUser.desired_weight, - uuid: newUser.uuid, - } -} - -function toDomain(dto: UserDTO): User { - return parseWithStack(userSchema, { - id: dto.id, - name: dto.name, - favorite_foods: dto.favorite_foods ?? [], - diet: dto.diet, - birthdate: dto.birthdate, - gender: dto.gender, - desired_weight: dto.desired_weight, - uuid: dto.uuid, - }) -} - -export const subapaseUserMapper = { - toDomain, - toInsertDTO, - toUpdateDTO, -} diff --git a/src/modules/user/infrastructure/supabase/supabaseUserRepository.ts b/src/modules/user/infrastructure/supabase/supabaseUserRepository.ts index f3cd3598b..d86ec0aa8 100644 --- a/src/modules/user/infrastructure/supabase/supabaseUserRepository.ts +++ b/src/modules/user/infrastructure/supabase/supabaseUserRepository.ts @@ -1,9 +1,53 @@ import { type NewUser, type User } from '~/modules/user/domain/user' +import { userSchema } from '~/modules/user/domain/user' import { type UserRepository } from '~/modules/user/domain/userRepository' -import { SUPABASE_TABLE_USERS } from '~/modules/user/infrastructure/supabase/constants' -import { subapaseUserMapper } from '~/modules/user/infrastructure/supabase/supabaseUserMapper' +import { type Database } from '~/shared/supabase/database.types' import { supabase } from '~/shared/supabase/supabase' import { wrapErrorWithStack } from '~/shared/utils/errorUtils' +import { parseWithStack } from '~/shared/utils/parseWithStack' + +const SUPABASE_TABLE_USERS = 'users' + +type InsertUserDTO = Database['public']['Tables']['users']['Insert'] +type UpdateUserDTO = Database['public']['Tables']['users']['Update'] +type UserDTO = Database['public']['Tables']['users']['Row'] + +function toInsertDTO(newUser: NewUser): InsertUserDTO { + return { + name: newUser.name, + favorite_foods: newUser.favorite_foods, + diet: newUser.diet, + birthdate: newUser.birthdate, + gender: newUser.gender, + desired_weight: newUser.desired_weight, + uuid: newUser.uuid, + } +} + +function toUpdateDTO(newUser: NewUser): UpdateUserDTO { + return { + name: newUser.name, + favorite_foods: newUser.favorite_foods, + diet: newUser.diet, + birthdate: newUser.birthdate, + gender: newUser.gender, + desired_weight: newUser.desired_weight, + uuid: newUser.uuid, + } +} + +function toDomain(dto: UserDTO): User { + return parseWithStack(userSchema, { + id: dto.id, + name: dto.name, + favorite_foods: dto.favorite_foods ?? [], + diet: dto.diet, + birthdate: dto.birthdate, + gender: dto.gender, + desired_weight: dto.desired_weight, + uuid: dto.uuid, + }) +} export function createSupabaseUserRepository(): UserRepository { return { @@ -24,13 +68,13 @@ const fetchUser = async (userId: User['uuid']): Promise => { throw wrapErrorWithStack(error) } - const users = data.map(subapaseUserMapper.toDomain) + const users = data.map(toDomain) return users[0] ?? null } const insertUser = async (newUser: NewUser): Promise => { - const createDTO = subapaseUserMapper.toInsertDTO(newUser) + const createDTO = toInsertDTO(newUser) const { data, error } = await supabase .from(SUPABASE_TABLE_USERS) @@ -41,7 +85,7 @@ const insertUser = async (newUser: NewUser): Promise => { throw wrapErrorWithStack(error) } - const users = data.map(subapaseUserMapper.toDomain) + const users = data.map(toDomain) return users[0] ?? null } @@ -50,7 +94,7 @@ const updateUser = async ( userId: User['uuid'], newUser: NewUser, ): Promise => { - const updateDTO = subapaseUserMapper.toUpdateDTO(newUser) + const updateDTO = toUpdateDTO(newUser) const { data, error } = await supabase .from(SUPABASE_TABLE_USERS) @@ -62,7 +106,7 @@ const updateUser = async ( throw wrapErrorWithStack(error) } - const users = data.map(subapaseUserMapper.toDomain) + const users = data.map(toDomain) return users[0] ?? null } diff --git a/src/modules/weight/infrastructure/weight/supabase/constants.ts b/src/modules/weight/infrastructure/weight/supabase/constants.ts deleted file mode 100644 index 1661ed21b..000000000 --- a/src/modules/weight/infrastructure/weight/supabase/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const SUPABASE_TABLE_WEIGHTS = 'weights' diff --git a/src/modules/weight/infrastructure/weight/supabase/realtime.ts b/src/modules/weight/infrastructure/weight/supabase/realtime.ts index 9d358ebbe..3bc455c1d 100644 --- a/src/modules/weight/infrastructure/weight/supabase/realtime.ts +++ b/src/modules/weight/infrastructure/weight/supabase/realtime.ts @@ -2,10 +2,11 @@ import { type Weight, weightSchema, } from '~/modules/weight/domain/weight/weight' -import { SUPABASE_TABLE_WEIGHTS } from '~/modules/weight/infrastructure/weight/supabase/constants' import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' +const SUPABASE_TABLE_WEIGHTS = 'weights' + type WeightRealtimeCallbacks = { onInsert: (weight: Weight) => void onUpdate: (weight: Weight) => void diff --git a/src/modules/weight/infrastructure/weight/supabase/supabaseWeightGateway.ts b/src/modules/weight/infrastructure/weight/supabase/supabaseWeightGateway.ts index d5d76f81e..b53f54a1a 100644 --- a/src/modules/weight/infrastructure/weight/supabase/supabaseWeightGateway.ts +++ b/src/modules/weight/infrastructure/weight/supabase/supabaseWeightGateway.ts @@ -2,11 +2,40 @@ import { type User } from '~/modules/user/domain/user' import { type NewWeight, type Weight, + weightSchema, } from '~/modules/weight/domain/weight/weight' import { type WeightGateway } from '~/modules/weight/domain/weight/weightGateway' -import { SUPABASE_TABLE_WEIGHTS } from '~/modules/weight/infrastructure/weight/supabase/constants' -import { supabaseWeightMapper } from '~/modules/weight/infrastructure/weight/supabase/supabaseWeightMapper' +import { type Database } from '~/shared/supabase/database.types' import { supabase } from '~/shared/supabase/supabase' +import { parseWithStack } from '~/shared/utils/parseWithStack' + +const SUPABASE_TABLE_WEIGHTS = 'weights' + +type WeightDTO = Database['public']['Tables']['weights']['Row'] +type InsertWeightDTO = Database['public']['Tables']['weights']['Insert'] +type UpdateWeightDTO = Database['public']['Tables']['weights']['Update'] + +function toDomain(dto: WeightDTO): Weight { + return parseWithStack(weightSchema, { + ...dto, + }) +} + +function toInsertDTO(weight: NewWeight): InsertWeightDTO { + return { + user_id: weight.user_id, + weight: weight.weight, + target_timestamp: weight.target_timestamp.toISOString(), + } +} + +function toUpdateDTO(weight: Weight): UpdateWeightDTO { + return { + user_id: weight.user_id, + weight: weight.weight, + target_timestamp: weight.target_timestamp.toISOString(), + } +} export function createSupabaseWeightGateway(): WeightGateway { return { @@ -28,11 +57,11 @@ async function fetchUserWeights(userId: User['uuid']) { throw error } - return weights.map(supabaseWeightMapper.toDomain) + return weights.map(toDomain) } async function insertWeight(newWeight: NewWeight) { - const weightDTO = supabaseWeightMapper.toInsertDTO(newWeight) + const weightDTO = toInsertDTO(newWeight) const { data: weight, error } = await supabase .from(SUPABASE_TABLE_WEIGHTS) .insert(weightDTO) @@ -43,11 +72,11 @@ async function insertWeight(newWeight: NewWeight) { throw error } - return supabaseWeightMapper.toDomain(weight) + return toDomain(weight) } async function updateWeight(weightId: Weight['id'], weightUpdate: Weight) { - const weightDTO = supabaseWeightMapper.toUpdateDTO(weightUpdate) + const weightDTO = toUpdateDTO(weightUpdate) const { data: weight, error } = await supabase .from(SUPABASE_TABLE_WEIGHTS) .update(weightDTO) @@ -59,7 +88,7 @@ async function updateWeight(weightId: Weight['id'], weightUpdate: Weight) { throw error } - return supabaseWeightMapper.toDomain(weight) + return toDomain(weight) } async function deleteWeight(id: Weight['id']) { diff --git a/src/modules/weight/infrastructure/weight/supabase/supabaseWeightMapper.ts b/src/modules/weight/infrastructure/weight/supabase/supabaseWeightMapper.ts deleted file mode 100644 index f47b3b408..000000000 --- a/src/modules/weight/infrastructure/weight/supabase/supabaseWeightMapper.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - type NewWeight, - type Weight, - weightSchema, -} from '~/modules/weight/domain/weight/weight' -import { type Database } from '~/shared/supabase/database.types' -import { parseWithStack } from '~/shared/utils/parseWithStack' - -export type WeightDTO = Database['public']['Tables']['weights']['Row'] -export type InsertWeightDTO = Database['public']['Tables']['weights']['Insert'] -export type UpdateWeightDTO = Database['public']['Tables']['weights']['Update'] - -function toDomain(dto: WeightDTO): Weight { - return parseWithStack(weightSchema, { - ...dto, - }) -} - -function toInsertDTO(weight: NewWeight): InsertWeightDTO { - return { - user_id: weight.user_id, - weight: weight.weight, - target_timestamp: weight.target_timestamp.toISOString(), - } -} - -function toUpdateDTO(weight: Weight): UpdateWeightDTO { - return { - user_id: weight.user_id, - weight: weight.weight, - target_timestamp: weight.target_timestamp.toISOString(), - } -} - -export const supabaseWeightMapper = { - toDomain, - toInsertDTO, - toUpdateDTO, -} diff --git a/src/shared/utils/convertApi2Food.ts b/src/shared/utils/convertApi2Food.ts index a9db694f4..b10752883 100644 --- a/src/shared/utils/convertApi2Food.ts +++ b/src/shared/utils/convertApi2Food.ts @@ -1,5 +1,5 @@ +import { type ApiFood } from '~/modules/diet/food/domain/apiFood' import { createNewFood, type NewFood } from '~/modules/diet/food/domain/food' -import { type ApiFood } from '~/modules/diet/food/infrastructure/api/domain/apiFoodSchema' import { createMacroNutrients } from '~/modules/diet/macro-nutrients/domain/macroNutrients' /** diff --git a/src/shared/utils/tests/convertApi2Food.test.ts b/src/shared/utils/tests/convertApi2Food.test.ts index fddc668d6..c299ca915 100644 --- a/src/shared/utils/tests/convertApi2Food.test.ts +++ b/src/shared/utils/tests/convertApi2Food.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { apiFoodSchema } from '~/modules/diet/food/infrastructure/api/domain/apiFoodSchema' +import { apiFoodSchema } from '~/modules/diet/food/domain/apiFood' import { convertApi2Food } from '~/shared/utils/convertApi2Food' import { parseWithStack } from '~/shared/utils/parseWithStack' From 2f6c5d852829b3bad74d6ebd75c6b461d5f89ab7 Mon Sep 17 00:00:00 2001 From: marcuscastelo Date: Tue, 24 Mar 2026 18:53:49 -0300 Subject: [PATCH 3/4] refactor(useCases): streamline imports and remove unused type exports --- src/di/useCases.ts | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/di/useCases.ts b/src/di/useCases.ts index d3e1525a8..9cb51eb94 100644 --- a/src/di/useCases.ts +++ b/src/di/useCases.ts @@ -6,20 +6,11 @@ import { createUserUseCases } from '~/modules/user/application/usecases/userUseC import { type UserRepository } from '~/modules/user/domain/userRepository' import { createGuestUserRepository } from '~/modules/user/infrastructure/guest/guestUserRepository' import { createSupabaseUserRepository } from '~/modules/user/infrastructure/supabase/supabaseUserRepository' -import { - createWeightChartUseCases, - type WeightChartUseCases, -} from '~/modules/weight/application/chart/weightChartUseCases' -import { - createWeightUseCases, - type WeightUseCases, -} from '~/modules/weight/application/weight/usecases/weightUseCases' +import { createWeightChartUseCases } from '~/modules/weight/application/chart/weightChartUseCases' +import { createWeightUseCases } from '~/modules/weight/application/weight/usecases/weightUseCases' import { createGuestUseCases } from '~/shared/guest/guestUseCases' -export type AppMode = 'guest' | 'normal' - -// Re-export use-case types for consumers -export type { WeightChartUseCases, WeightUseCases } +type AppMode = 'guest' | 'normal' // TODO: Refactor global DI so that we don't need to switch repositories like this // Issue URL: https://github.com/marcuscastelo/macroflows/issues/1440 From 35a464e7d878842f9e60d675e0938d5c66602901 Mon Sep 17 00:00:00 2001 From: marcuscastelo Date: Tue, 24 Mar 2026 19:44:08 -0300 Subject: [PATCH 4/4] chore(pr): apply PR #1471 suggestions --- src/di/container.tsx | 17 +- .../infrastructure/supabase/realtime.ts | 7 +- .../day-diet/tests/realtimeService.test.ts | 35 ++++ .../infrastructure/api/application/apiFood.ts | 9 + .../infrastructure/supabase/realtime.ts | 7 +- .../tests/realtimeService.test.ts | 39 +++++ .../infrastructure/sentry/sentry.ts | 115 +++++++------ .../sentry/tests/sentry.test.ts | 83 ++++++++++ .../supabase/supabaseCachedSearchGateway.ts | 156 +++++++++--------- .../tests/supabaseCachedSearchGateway.test.ts | 57 +++++++ .../weight/tests/realtimeService.test.ts | 35 ++++ .../weight/supabase/realtime.ts | 7 +- 12 files changed, 421 insertions(+), 146 deletions(-) create mode 100644 src/modules/diet/day-diet/tests/realtimeService.test.ts create mode 100644 src/modules/diet/macro-profile/tests/realtimeService.test.ts create mode 100644 src/modules/observability/infrastructure/sentry/tests/sentry.test.ts create mode 100644 src/modules/search/infrastructure/tests/supabaseCachedSearchGateway.test.ts create mode 100644 src/modules/weight/application/weight/tests/realtimeService.test.ts diff --git a/src/di/container.tsx b/src/di/container.tsx index eca5cee2f..55f8a79a0 100644 --- a/src/di/container.tsx +++ b/src/di/container.tsx @@ -163,15 +163,20 @@ function createModeAwareUserRepository(deps: { } } +const sharedSentryService = createSentryService() + +function createAppTelemetry(): TelemetryModule { + return createTelemetry({ + sentryService: sharedSentryService, + }) +} + export function createContainer( overrides: Partial = {}, ): Readonly { return createRoot((dispose) => { const telemetryUseCases = - overrides.telemetryUseCases ?? - createTelemetry({ - sentryService: createSentryService(), - }) + overrides.telemetryUseCases ?? createAppTelemetry() let guestUseCasesRef: GuestUseCases | null = overrides.guestUseCases ?? null @@ -370,9 +375,7 @@ let bootstrapTelemetry: TelemetryModule | null = null function getBootstrapTelemetry() { if (bootstrapTelemetry === null) { - bootstrapTelemetry = createTelemetry({ - sentryService: createSentryService(), - }) + bootstrapTelemetry = createAppTelemetry() } return bootstrapTelemetry diff --git a/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts b/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts index ee8a1d909..137e8b93e 100644 --- a/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts +++ b/src/modules/diet/day-diet/infrastructure/supabase/realtime.ts @@ -6,6 +6,7 @@ import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' const SUPABASE_TABLE_DAYS = 'days' +let dayDietRealtimeInitialized = false type DayDietRealtimeCallbacks = { onInsert: (newDayDiet: DayDiet) => void @@ -14,17 +15,15 @@ type DayDietRealtimeCallbacks = { } export function createDayDietRealtimeService() { - let initialized = false - function initializeDayDietRealtime( callbacks: DayDietRealtimeCallbacks, ): void { - if (initialized) { + if (dayDietRealtimeInitialized) { return } logging.debug(`Day diet realtime initialized!`) - initialized = true + dayDietRealtimeInitialized = true registerSubapabaseRealtimeCallback( SUPABASE_TABLE_DAYS, dayDietSchema, diff --git a/src/modules/diet/day-diet/tests/realtimeService.test.ts b/src/modules/diet/day-diet/tests/realtimeService.test.ts new file mode 100644 index 000000000..1e34c0632 --- /dev/null +++ b/src/modules/diet/day-diet/tests/realtimeService.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('createDayDietRealtimeService', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + }) + + it('registers the realtime callback only once across service instances', async () => { + const registerSubapabaseRealtimeCallback = vi.fn() + + vi.doMock('~/shared/supabase/supabase', () => ({ + registerSubapabaseRealtimeCallback, + })) + vi.doMock('~/shared/utils/logging', () => ({ + logging: { + debug: vi.fn(), + }, + })) + + const { createDayDietRealtimeService } = + await import('~/modules/diet/day-diet/infrastructure/supabase/realtime') + + const callbacks = { + onInsert: vi.fn(), + onUpdate: vi.fn(), + onDelete: vi.fn(), + } + + createDayDietRealtimeService().initializeDayDietRealtime(callbacks) + createDayDietRealtimeService().initializeDayDietRealtime(callbacks) + + expect(registerSubapabaseRealtimeCallback).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/modules/diet/food/infrastructure/api/application/apiFood.ts b/src/modules/diet/food/infrastructure/api/application/apiFood.ts index 31b6d9e8a..f4edf54f2 100644 --- a/src/modules/diet/food/infrastructure/api/application/apiFood.ts +++ b/src/modules/diet/food/infrastructure/api/application/apiFood.ts @@ -10,6 +10,15 @@ import { convertApi2Food } from '~/shared/utils/convertApi2Food' import { ORIGINAL_ERROR_SYMBOL } from '~/shared/utils/errorUtils' import { logging } from '~/shared/utils/logging' +/** + * Creates the API food import service used by food CRUD flows. + * + * Supports dependency injection for repository, cached-search updates, and + * user-facing error reporting so callers and tests can replace external IO. + * + * @param deps Optional dependency overrides for persistence and UI feedback. + * @returns An object with EAN and name-based import methods. + */ export function createApiFoodImportService(deps?: { foodRepository?: FoodRepository cachedSearchCrud?: ReturnType diff --git a/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts b/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts index e7c552bf2..22757007b 100644 --- a/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts +++ b/src/modules/diet/macro-profile/infrastructure/supabase/realtime.ts @@ -6,6 +6,7 @@ import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' const SUPABASE_TABLE_MACRO_PROFILES = 'macro_profiles' +let macroProfileRealtimeInitialized = false type MacroProfileRealtimeCallbacks = { onInsert: (profile: MacroProfile) => void @@ -14,17 +15,15 @@ type MacroProfileRealtimeCallbacks = { } export function createMacroProfileRealtimeService() { - let initialized = false - function initializeMacroProfileRealtime( callbacks: MacroProfileRealtimeCallbacks, ): void { - if (initialized) { + if (macroProfileRealtimeInitialized) { return } logging.debug(`Macro profile realtime initialized!`) - initialized = true + macroProfileRealtimeInitialized = true registerSubapabaseRealtimeCallback( SUPABASE_TABLE_MACRO_PROFILES, macroProfileSchema, diff --git a/src/modules/diet/macro-profile/tests/realtimeService.test.ts b/src/modules/diet/macro-profile/tests/realtimeService.test.ts new file mode 100644 index 000000000..5271567ca --- /dev/null +++ b/src/modules/diet/macro-profile/tests/realtimeService.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('createMacroProfileRealtimeService', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + }) + + it('registers the realtime callback only once across service instances', async () => { + const registerSubapabaseRealtimeCallback = vi.fn() + + vi.doMock('~/shared/supabase/supabase', () => ({ + registerSubapabaseRealtimeCallback, + })) + vi.doMock('~/shared/utils/logging', () => ({ + logging: { + debug: vi.fn(), + }, + })) + + const { createMacroProfileRealtimeService } = + await import('~/modules/diet/macro-profile/infrastructure/supabase/realtime') + + const callbacks = { + onInsert: vi.fn(), + onUpdate: vi.fn(), + onDelete: vi.fn(), + } + + createMacroProfileRealtimeService().initializeMacroProfileRealtime( + callbacks, + ) + createMacroProfileRealtimeService().initializeMacroProfileRealtime( + callbacks, + ) + + expect(registerSubapabaseRealtimeCallback).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/modules/observability/infrastructure/sentry/sentry.ts b/src/modules/observability/infrastructure/sentry/sentry.ts index 7a558e56a..384db419a 100644 --- a/src/modules/observability/infrastructure/sentry/sentry.ts +++ b/src/modules/observability/infrastructure/sentry/sentry.ts @@ -80,6 +80,9 @@ async function wrapContextManagerClass(type: 'server' | 'client') { return SentryOTel.wrapContextManagerClass(AsyncLocalStorageContextManager) } +let isSentryInitialized = false +let sentryInitializationPromise: Promise | null = null + export function createSentryService(deps?: { createSentryConfig?: typeof createSentryConfig createClientIntegrations?: typeof createClientIntegrations @@ -90,68 +93,78 @@ export function createSentryService(deps?: { deps?.createClientIntegrations ?? createClientIntegrations const localSetupSentryOTelIntegration = deps?.setupSentryOTelIntegration ?? setupSentryOTelIntegration - let isInitialized = false async function initializeSentry(type: 'server' | 'client') { - if (isInitialized) { + if (isSentryInitialized) { console.warn('Sentry already initialized') return } - try { - const config = localCreateSentryConfig() + if (sentryInitializationPromise !== null) { + await sentryInitializationPromise + return + } - // Only initialize if DSN is provided - if (config.dsn === undefined || config.dsn === '') { - console.warn('❌ Sentry DSN not provided - skipping initialization', { - VITE_SENTRY_DSN: String(import.meta.env.VITE_SENTRY_DSN), + sentryInitializationPromise = (async () => { + try { + const config = localCreateSentryConfig() + + // Only initialize if DSN is provided + if (config.dsn === undefined || config.dsn === '') { + console.warn('❌ Sentry DSN not provided - skipping initialization', { + VITE_SENTRY_DSN: String(import.meta.env.VITE_SENTRY_DSN), + }) + return + } + + console.log( + '🚀 Initializing Sentry with DSN:', + config.dsn.substring(0, 20) + '...', + ) + + Sentry.init({ + dsn: config.dsn, + release: config.release, + tracesSampleRate: 1.0, + + // SolidStart specific configuration + sendDefaultPii: true, + tracePropagationTargets: [ + 'localhost', + /^https:\/\/.*\.supabase\.co/, + /^https:\/\/.*\.macroflows\.app/, + /^https:\/\/.*\.macroflows.*\.app/, + ], + + integrations: + type === 'client' + ? await localCreateClientIntegrations() + : [Sentry.consoleLoggingIntegration()], + + // Session Replay configuration + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 1.0, + + // Set sample rate for profiling + profilesSampleRate: 1.0, + + enableLogs: true, }) - return - } - console.log( - '🚀 Initializing Sentry with DSN:', - config.dsn.substring(0, 20) + '...', - ) - - Sentry.init({ - dsn: config.dsn, - release: config.release, - tracesSampleRate: 1.0, - - // SolidStart specific configuration - sendDefaultPii: true, - tracePropagationTargets: [ - 'localhost', - /^https:\/\/.*\.supabase\.co/, - /^https:\/\/.*\.macroflows\.app/, - /^https:\/\/.*\.macroflows.*\.app/, - ], - - integrations: - type === 'client' - ? await localCreateClientIntegrations() - : [Sentry.consoleLoggingIntegration()], - - // Session Replay configuration - replaysSessionSampleRate: 1.0, - replaysOnErrorSampleRate: 1.0, - - // Set sample rate for profiling - profilesSampleRate: 1.0, - - enableLogs: true, - }) - - if (config.useOTel) { - await localSetupSentryOTelIntegration(type) + if (config.useOTel) { + await localSetupSentryOTelIntegration(type) + } + + isSentryInitialized = true + } catch (error) { + console.error('Failed to initialize Sentry:', error) + // Don't throw - Sentry should not break the application + } finally { + sentryInitializationPromise = null } + })() - isInitialized = true - } catch (error) { - console.error('Failed to initialize Sentry:', error) - // Don't throw - Sentry should not break the application - } + await sentryInitializationPromise } return { diff --git a/src/modules/observability/infrastructure/sentry/tests/sentry.test.ts b/src/modules/observability/infrastructure/sentry/tests/sentry.test.ts new file mode 100644 index 000000000..fc334ea9f --- /dev/null +++ b/src/modules/observability/infrastructure/sentry/tests/sentry.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +async function loadSentryModule() { + const init = vi.fn() + + vi.doMock('@sentry/solidstart', () => ({ + init, + getClient: vi.fn(() => ({ id: 'client' })), + browserTracingIntegration: vi.fn(() => ({ name: 'browserTracing' })), + browserProfilingIntegration: vi.fn(() => ({ name: 'browserProfiling' })), + replayIntegration: vi.fn(() => ({ name: 'replay' })), + consoleLoggingIntegration: vi.fn(() => ({ name: 'consoleLogging' })), + })) + + const module = + await import('~/modules/observability/infrastructure/sentry/sentry') + + return { + module, + init, + } +} + +describe('createSentryService', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + vi.spyOn(console, 'error').mockImplementation(() => undefined) + vi.spyOn(console, 'log').mockImplementation(() => undefined) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + + it('shares the same in-flight initialization across concurrent calls', async () => { + const { module, init } = await loadSentryModule() + + const service = module.createSentryService({ + createSentryConfig: () => ({ + dsn: 'https://public@example.ingest.sentry.io/1', + release: 'macroflows@test', + useOTel: false, + }), + createClientIntegrations: async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + return [] + }, + }) + + const firstInitialization = service.initializeSentry('client') + const secondInitialization = service.initializeSentry('client') + + expect(init).not.toHaveBeenCalled() + + await Promise.all([firstInitialization, secondInitialization]) + + expect(init).toHaveBeenCalledTimes(1) + }) + + it('keeps initialization idempotent across multiple service instances', async () => { + const { module, init } = await loadSentryModule() + + const createSentryConfig = () => ({ + dsn: 'https://public@example.ingest.sentry.io/1', + release: 'macroflows@test', + useOTel: false, + }) + + await module + .createSentryService({ + createSentryConfig, + createClientIntegrations: async () => [], + }) + .initializeSentry('client') + + await module + .createSentryService({ + createSentryConfig, + createClientIntegrations: async () => [], + }) + .initializeSentry('client') + + expect(init).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/modules/search/infrastructure/supabase/supabaseCachedSearchGateway.ts b/src/modules/search/infrastructure/supabase/supabaseCachedSearchGateway.ts index 06f40f7ee..ad6f48989 100644 --- a/src/modules/search/infrastructure/supabase/supabaseCachedSearchGateway.ts +++ b/src/modules/search/infrastructure/supabase/supabaseCachedSearchGateway.ts @@ -21,84 +21,88 @@ function toInsertDTO( } export function createSupabaseCachedSearchGateway(): CachedSearchGateway { - return { - async isSearchCached(searchQuery: string): Promise { - try { - const normalizedSearch = createNormalizedSearch(searchQuery) - - const { data, error } = await supabase - .from(SUPABASE_TABLE_CACHED_SEARCHES) - .select('search') - .eq('search', normalizedSearch) - .limit(1) - - if (error !== null) { - throw new Error('Failed to check if search is cached', { - cause: error, - }) - } - - return data.length > 0 - } catch (error) { - logging.error( - 'SupabaseSearchCacheRepository isSearchCached error:', - error, - ) - throw error + async function isSearchCached(searchQuery: string): Promise { + try { + const normalizedSearch = createNormalizedSearch(searchQuery) + + const { data, error } = await supabase + .from(SUPABASE_TABLE_CACHED_SEARCHES) + .select('search') + .eq('search', normalizedSearch) + .limit(1) + + if (error !== null) { + throw new Error('Failed to check if search is cached', { + cause: error, + }) } - }, - - async markSearchAsCached(searchQuery: string): Promise { - try { - const normalizedSearch = createNormalizedSearch(searchQuery) - - // Check if already cached to avoid unnecessary database operations - if (await this.isSearchCached(searchQuery)) { - return - } - - const insertData = toInsertDTO( - createNewCachedSearch({ - search: normalizedSearch, - }), - ) - - const { error } = await supabase - .from(SUPABASE_TABLE_CACHED_SEARCHES) - .upsert(insertData) - .select() - - if (error !== null) { - throw new Error('Failed to mark search as cached', { cause: error }) - } - } catch (error) { - logging.error( - 'SupabaseSearchCacheRepository markSearchAsCached error:', - error, - ) - throw error + + return data.length > 0 + } catch (error) { + logging.error( + 'SupabaseSearchCacheRepository isSearchCached error:', + error, + ) + throw error + } + } + + async function markSearchAsCached(searchQuery: string): Promise { + try { + const normalizedSearch = createNormalizedSearch(searchQuery) + + // Check if already cached to avoid unnecessary database operations + if (await isSearchCached(searchQuery)) { + return + } + + const insertData = toInsertDTO( + createNewCachedSearch({ + search: normalizedSearch, + }), + ) + + const { error } = await supabase + .from(SUPABASE_TABLE_CACHED_SEARCHES) + .upsert(insertData) + .select() + + if (error !== null) { + throw new Error('Failed to mark search as cached', { cause: error }) } - }, - - async unmarkSearchAsCached(searchQuery: string): Promise { - try { - const normalizedSearch = createNormalizedSearch(searchQuery) - - const { error } = await supabase - .from(SUPABASE_TABLE_CACHED_SEARCHES) - .delete() - .eq('search', normalizedSearch) - - if (error !== null) { - throw new Error('Failed to unmark search as cached', { cause: error }) - } - } catch (error) { - logging.error( - 'SupabaseSearchCacheRepository unmarkSearchAsCached error:', - error, - ) - throw error + } catch (error) { + logging.error( + 'SupabaseSearchCacheRepository markSearchAsCached error:', + error, + ) + throw error + } + } + + async function unmarkSearchAsCached(searchQuery: string): Promise { + try { + const normalizedSearch = createNormalizedSearch(searchQuery) + + const { error } = await supabase + .from(SUPABASE_TABLE_CACHED_SEARCHES) + .delete() + .eq('search', normalizedSearch) + + if (error !== null) { + throw new Error('Failed to unmark search as cached', { cause: error }) } - }, + } catch (error) { + logging.error( + 'SupabaseSearchCacheRepository unmarkSearchAsCached error:', + error, + ) + throw error + } + } + + return { + isSearchCached, + markSearchAsCached, + unmarkSearchAsCached, } } diff --git a/src/modules/search/infrastructure/tests/supabaseCachedSearchGateway.test.ts b/src/modules/search/infrastructure/tests/supabaseCachedSearchGateway.test.ts new file mode 100644 index 000000000..18047a46d --- /dev/null +++ b/src/modules/search/infrastructure/tests/supabaseCachedSearchGateway.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('createSupabaseCachedSearchGateway', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + }) + + it('allows markSearchAsCached to be called after destructuring', async () => { + const limit = vi.fn(async () => ({ + data: [], + error: null, + })) + const eq = vi.fn(() => ({ + limit, + })) + const selectSearch = vi.fn(() => ({ + eq, + })) + const selectAfterUpsert = vi.fn(async () => ({ + error: null, + })) + const upsert = vi.fn(() => ({ + select: selectAfterUpsert, + })) + const from = vi.fn(() => ({ + select: selectSearch, + upsert, + })) + + vi.doMock('~/shared/supabase/supabase', () => ({ + supabase: { + from, + }, + })) + vi.doMock('~/shared/utils/logging', () => ({ + logging: { + error: vi.fn(), + }, + })) + + const { createSupabaseCachedSearchGateway } = + await import('~/modules/search/infrastructure/supabase/supabaseCachedSearchGateway') + + const gateway = createSupabaseCachedSearchGateway() + + await expect( + gateway.markSearchAsCached.call(undefined, ' Search Term '), + ).resolves.toBeUndefined() + + expect(eq).toHaveBeenCalledWith('search', 'search term') + expect(upsert).toHaveBeenCalledWith({ + search: 'search term', + }) + expect(selectAfterUpsert).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/modules/weight/application/weight/tests/realtimeService.test.ts b/src/modules/weight/application/weight/tests/realtimeService.test.ts new file mode 100644 index 000000000..05efbb790 --- /dev/null +++ b/src/modules/weight/application/weight/tests/realtimeService.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +describe('createWeightRealtimeService', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + }) + + it('registers the realtime callback only once across service instances', async () => { + const registerSubapabaseRealtimeCallback = vi.fn() + + vi.doMock('~/shared/supabase/supabase', () => ({ + registerSubapabaseRealtimeCallback, + })) + vi.doMock('~/shared/utils/logging', () => ({ + logging: { + debug: vi.fn(), + }, + })) + + const { createWeightRealtimeService } = + await import('~/modules/weight/infrastructure/weight/supabase/realtime') + + const callbacks = { + onInsert: vi.fn(), + onUpdate: vi.fn(), + onDelete: vi.fn(), + } + + createWeightRealtimeService().initializeWeightRealtime(callbacks) + createWeightRealtimeService().initializeWeightRealtime(callbacks) + + expect(registerSubapabaseRealtimeCallback).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/modules/weight/infrastructure/weight/supabase/realtime.ts b/src/modules/weight/infrastructure/weight/supabase/realtime.ts index 3bc455c1d..bf10b3add 100644 --- a/src/modules/weight/infrastructure/weight/supabase/realtime.ts +++ b/src/modules/weight/infrastructure/weight/supabase/realtime.ts @@ -6,6 +6,7 @@ import { registerSubapabaseRealtimeCallback } from '~/shared/supabase/supabase' import { logging } from '~/shared/utils/logging' const SUPABASE_TABLE_WEIGHTS = 'weights' +let weightRealtimeInitialized = false type WeightRealtimeCallbacks = { onInsert: (weight: Weight) => void @@ -14,15 +15,13 @@ type WeightRealtimeCallbacks = { } export function createWeightRealtimeService() { - let initialized = false - function initializeWeightRealtime(callbacks: WeightRealtimeCallbacks): void { - if (initialized) { + if (weightRealtimeInitialized) { return } logging.debug(`Weight realtime initialized!`) - initialized = true + weightRealtimeInitialized = true registerSubapabaseRealtimeCallback( SUPABASE_TABLE_WEIGHTS, weightSchema,