diff --git a/packages/web/src/components/checklist/LocalChecklistView.jsx b/packages/web/src/components/checklist/LocalChecklistView.jsx index 78720cbd1..b419edaa4 100644 --- a/packages/web/src/components/checklist/LocalChecklistView.jsx +++ b/packages/web/src/components/checklist/LocalChecklistView.jsx @@ -15,6 +15,7 @@ import localChecklistsStore from '@/stores/localChecklistsStore'; import { getChecklistTypeFromState, scoreChecklistOfType } from '@/checklist-registry'; import { IoChevronBack } from 'solid-icons/io'; import ScoreTag from '@/components/checklist/ScoreTag.jsx'; +import { createLocalAdapterFactories } from '@/components/checklist/common/LocalTextAdapter.js'; export default function LocalChecklistView() { const params = useParams(); @@ -28,6 +29,29 @@ export default function LocalChecklistView() { const [loading, setLoading] = createSignal(true); const [error, setError] = createSignal(null); + // Debounced save function always saves the full current checklist state + // to avoid race conditions where rapid partial updates could overwrite each other. + // Ignores arguments and reads checklist() when it fires to get the latest merged state. + // eslint-disable-next-line solid/reactivity + const debouncedSave = debounce(async () => { + try { + const current = checklist(); + if (current) { + await updateChecklist(params.checklistId, current); + } + } catch (err) { + console.error('Error saving checklist:', err); + } + }, 500); + + // Create adapter factories for text fields + const { getRob2Text, getQuestionNote, getRobinsText, clearCache } = createLocalAdapterFactories( + // eslint-disable-next-line solid/reactivity + () => checklist(), + setChecklist, + debouncedSave, + ); + // Load the checklist and PDF on mount createEffect(() => { const checklistId = params.checklistId; @@ -55,6 +79,8 @@ export default function LocalChecklistView() { return; } + // Clear adapter cache when loading new data to prevent stale values + clearCache(); setChecklist(loaded); // Load saved PDF if exists @@ -75,30 +101,24 @@ export default function LocalChecklistView() { })(); }); - // Debounced save function - const debouncedSave = debounce(async (checklistId, updates) => { - try { - await updateChecklist(checklistId, updates); - } catch (err) { - console.error('Error saving checklist:', err); - } - }, 500); - // Cleanup on unmount onCleanup(() => { debouncedSave.clear(); + clearCache(); }); - // Handle updates from the AMSTAR2Checklist component + // Handle updates from checklist components (answers, judgements, etc.) const handleUpdate = updates => { - // Optimistically update local state setChecklist(prev => { if (!prev) return prev; return { ...prev, ...updates }; }); - // Debounce the save to IndexedDB - debouncedSave(params.checklistId, updates); + // Clear adapter cache to ensure text fields sync with new state + clearCache(); + + // Trigger debounced save - it will read checklist() when it fires + debouncedSave(); }; // Handle PDF change @@ -209,6 +229,9 @@ export default function LocalChecklistView() { onPdfChange={handlePdfChange} onPdfClear={handlePdfClear} allowDelete={true} + getQuestionNote={getQuestionNote} + getRob2Text={getRob2Text} + getRobinsText={getRobinsText} /> diff --git a/packages/web/src/components/checklist/common/LocalTextAdapter.js b/packages/web/src/components/checklist/common/LocalTextAdapter.js new file mode 100644 index 000000000..ba341490d --- /dev/null +++ b/packages/web/src/components/checklist/common/LocalTextAdapter.js @@ -0,0 +1,213 @@ +/** + * LocalTextAdapter - Y.Text-compatible adapter for local checklist comments + * + * Mimics Y.Text interface to enable NoteEditor to work with plain strings + * in local checklists without requiring full Yjs infrastructure. + */ + +/** + * Creates adapter factory functions for local checklist text fields. + * Encapsulates all the path resolution and caching logic. + * + * @param {Function} getChecklist - Getter for current checklist state + * @param {Function} updateState - Function to update checklist state: (updater: (prev) => next) => void + * @param {Function} save - Function to trigger debounced persistence (reads current state when it fires) + * @returns {Object} Factory functions and cache control + */ +export function createLocalAdapterFactories(getChecklist, updateState, save) { + const cache = new Map(); + + function getOrCreateAdapter(path, getValue, getUpdatedState) { + if (cache.has(path)) { + return cache.get(path); + } + + const currentValue = getValue(getChecklist()); + + const adapter = new LocalTextAdapter(currentValue, newValue => { + updateState(prev => { + if (!prev) return prev; + const updated = getUpdatedState(prev, newValue); + // Trigger save after state is updated - save() reads current state when it fires + save(); + return updated; + }); + }); + + cache.set(path, adapter); + return adapter; + } + + // ROB2: domain comments and preliminary text fields + function getRob2Text(sectionKey, fieldKey, questionKey) { + let path, getValue, getUpdatedState; + + if (sectionKey.startsWith('domain') && questionKey) { + path = `${sectionKey}.${questionKey}.comment`; + getValue = cl => cl?.[sectionKey]?.answers?.[questionKey]?.comment || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + [sectionKey]: { + ...prev[sectionKey], + answers: { + ...prev[sectionKey]?.answers, + [questionKey]: { + ...prev[sectionKey]?.answers?.[questionKey], + comment: newValue, + }, + }, + }, + }); + } else if (sectionKey === 'preliminary') { + path = `preliminary.${fieldKey}`; + getValue = cl => cl?.preliminary?.[fieldKey] || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + preliminary: { + ...prev.preliminary, + [fieldKey]: newValue, + }, + }); + } else { + return null; + } + + return getOrCreateAdapter(path, getValue, getUpdatedState); + } + + // AMSTAR2: question notes + function getQuestionNote(questionKey) { + const path = `notes.${questionKey}`; + const getValue = cl => cl?.notes?.[questionKey] || ''; + const getUpdatedState = (prev, newValue) => ({ + ...prev, + notes: { + ...prev.notes, + [questionKey]: newValue, + }, + }); + + return getOrCreateAdapter(path, getValue, getUpdatedState); + } + + // ROBINS-I: domain comments, sectionB comments, and section text fields + function getRobinsText(sectionKey, fieldKey, questionKey) { + let path, getValue, getUpdatedState; + + if (sectionKey.startsWith('domain') && questionKey) { + path = `${sectionKey}.${questionKey}.comment`; + getValue = cl => cl?.[sectionKey]?.answers?.[questionKey]?.comment || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + [sectionKey]: { + ...prev[sectionKey], + answers: { + ...prev[sectionKey]?.answers, + [questionKey]: { + ...prev[sectionKey]?.answers?.[questionKey], + comment: newValue, + }, + }, + }, + }); + } else if (sectionKey === 'sectionB' && questionKey) { + path = `sectionB.${questionKey}.comment`; + getValue = cl => cl?.sectionB?.[questionKey]?.comment || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + sectionB: { + ...prev.sectionB, + [questionKey]: { + ...prev.sectionB?.[questionKey], + comment: newValue, + }, + }, + }); + } else if (['sectionA', 'sectionC', 'sectionD', 'planning'].includes(sectionKey)) { + path = `${sectionKey}.${fieldKey}`; + getValue = cl => cl?.[sectionKey]?.[fieldKey] || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + [sectionKey]: { + ...prev[sectionKey], + [fieldKey]: newValue, + }, + }); + } else { + return null; + } + + return getOrCreateAdapter(path, getValue, getUpdatedState); + } + + function clearCache() { + cache.clear(); + } + + return { + getRob2Text, + getQuestionNote, + getRobinsText, + clearCache, + }; +} + +export class LocalTextAdapter { + constructor(initialValue = '', onUpdate) { + this._value = initialValue; + this._onUpdate = onUpdate; + this._observers = new Set(); + + // Mock doc object with transact method (executes immediately) + this.doc = { + transact: fn => { + fn(); + }, + }; + } + + toString() { + return this._value; + } + + get length() { + return this._value.length; + } + + observe(callback) { + this._observers.add(callback); + } + + unobserve(callback) { + this._observers.delete(callback); + } + + delete(index, length) { + const before = this._value.substring(0, index); + const after = this._value.substring(index + length); + this._value = before + after; + this._notifyUpdate(); + } + + insert(index, text) { + const before = this._value.substring(0, index); + const after = this._value.substring(index); + this._value = before + text + after; + this._notifyUpdate(); + } + + _notifyUpdate() { + if (this._onUpdate) { + this._onUpdate(this._value); + } + this._observers.forEach(callback => callback()); + } + + // Allow external sync of value (e.g., when checklist reloads) + _syncValue(newValue) { + if (this._value !== newValue) { + this._value = newValue; + this._observers.forEach(callback => callback()); + } + } +} diff --git a/packages/workers/src/auth/config.ts b/packages/workers/src/auth/config.ts index c18aa3f99..90a8fa7f8 100644 --- a/packages/workers/src/auth/config.ts +++ b/packages/workers/src/auth/config.ts @@ -1,13 +1,7 @@ import { betterAuth } from 'better-auth'; import { createAuthMiddleware } from 'better-auth/api'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; -import { - genericOAuth, - magicLink, - twoFactor, - admin, - organization, -} from 'better-auth/plugins'; +import { genericOAuth, magicLink, twoFactor, admin, organization } from 'better-auth/plugins'; import { oAuthRelay } from './oauth-relay'; import { stripe } from '@better-auth/stripe'; import Stripe from 'stripe'; diff --git a/packages/workers/src/auth/oauth-relay.ts b/packages/workers/src/auth/oauth-relay.ts index 52b5c2149..16af5d06e 100644 --- a/packages/workers/src/auth/oauth-relay.ts +++ b/packages/workers/src/auth/oauth-relay.ts @@ -101,7 +101,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { method: 'GET', query: relayQuerySchema, }, - async (ctx) => { + async ctx => { const { payload: encryptedPayload } = ctx.query; // Decrypt the payload @@ -113,9 +113,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { }); } catch (e) { ctx.context.logger.error('Failed to decrypt OAuth relay payload:', e); - throw ctx.redirect( - `${ctx.context.baseURL}/error?error=oauth_relay_decrypt_failed` - ); + throw ctx.redirect(`${ctx.context.baseURL}/error?error=oauth_relay_decrypt_failed`); } // Parse the payload @@ -124,20 +122,16 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { payload = JSON.parse(decrypted); } catch (e) { ctx.context.logger.error('Failed to parse OAuth relay payload:', e); - throw ctx.redirect( - `${ctx.context.baseURL}/error?error=oauth_relay_invalid_payload` - ); + throw ctx.redirect(`${ctx.context.baseURL}/error?error=oauth_relay_invalid_payload`); } // Check timestamp const age = (Date.now() - payload.timestamp) / 1000; if (age > maxAge || age < -10) { ctx.context.logger.error( - `OAuth relay payload expired (age: ${age}s, maxAge: ${maxAge}s)` - ); - throw ctx.redirect( - `${ctx.context.baseURL}/error?error=oauth_relay_expired` + `OAuth relay payload expired (age: ${age}s, maxAge: ${maxAge}s)`, ); + throw ctx.redirect(`${ctx.context.baseURL}/error?error=oauth_relay_expired`); } // Create user and session locally using Better Auth's internal handler @@ -156,7 +150,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { if (result.error) { ctx.context.logger.error('OAuth relay user creation failed:', result.error); throw ctx.redirect( - `${ctx.context.baseURL}/error?error=${encodeURIComponent(result.error)}` + `${ctx.context.baseURL}/error?error=${encodeURIComponent(result.error)}`, ); } @@ -167,7 +161,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { // Redirect to the original callback URL throw ctx.redirect(payload.callbackURL); - } + }, ), }, hooks: { @@ -182,7 +176,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { context.path?.startsWith('/sign-in/oauth2') ); }, - handler: createAuthMiddleware(async (ctx) => { + handler: createAuthMiddleware(async ctx => { const currentOrigin = getOrigin(ctx.context.baseURL); // Skip if we're on production (no relay needed) @@ -205,11 +199,10 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { */ matcher(context) { return !!( - context.path?.startsWith('/callback') || - context.path?.startsWith('/oauth2/callback') + context.path?.startsWith('/callback') || context.path?.startsWith('/oauth2/callback') ); }, - handler: createAuthMiddleware(async (ctx) => { + handler: createAuthMiddleware(async ctx => { const state = ctx.query?.state || ctx.body?.state; if (!state || typeof state !== 'string') return; @@ -234,9 +227,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { // This is a relay request - handle it ourselves const code = ctx.query?.code || ctx.body?.code; if (!code) { - throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=no_code` - ); + throw ctx.redirect(`${relayPackage.relayOrigin}/api/auth/error?error=no_code`); } // Get the provider ID from the request URL (not ctx.path which is the route pattern) @@ -255,20 +246,16 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { if (!providerId) { ctx.context.logger.error('Could not extract provider ID from URL:', requestUrl); - throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=no_provider` - ); + throw ctx.redirect(`${relayPackage.relayOrigin}/api/auth/error?error=no_provider`); } // Find the OAuth provider - const provider = ctx.context.socialProviders.find( - (p) => p.id === providerId - ); + const provider = ctx.context.socialProviders.find(p => p.id === providerId); if (!provider) { ctx.context.logger.error('Provider not found:', providerId); throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=provider_not_found` + `${relayPackage.relayOrigin}/api/auth/error?error=provider_not_found`, ); } @@ -283,7 +270,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { } catch (e) { ctx.context.logger.error('Failed to decrypt state cookie:', e); throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=invalid_state_cookie` + `${relayPackage.relayOrigin}/api/auth/error?error=invalid_state_cookie`, ); } @@ -298,14 +285,12 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { } catch (e) { ctx.context.logger.error('Token exchange failed:', e); throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=token_exchange_failed` + `${relayPackage.relayOrigin}/api/auth/error?error=token_exchange_failed`, ); } if (!tokens) { - throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=no_tokens` - ); + throw ctx.redirect(`${relayPackage.relayOrigin}/api/auth/error?error=no_tokens`); } // Get user info from provider @@ -314,9 +299,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { if (!userInfo || !userInfo.email) { ctx.context.logger.error('Failed to get user info'); - throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=no_user_info` - ); + throw ctx.redirect(`${relayPackage.relayOrigin}/api/auth/error?error=no_user_info`); } // Build relay payload @@ -370,7 +353,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { context.path?.startsWith('/sign-in/oauth2') ); }, - handler: createAuthMiddleware(async (ctx) => { + handler: createAuthMiddleware(async ctx => { const relayOrigin = (ctx.context as any)._relayOrigin; if (!relayOrigin) return; @@ -378,7 +361,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { if (ctx.context.oauthConfig.storeStateStrategy !== 'cookie') { ctx.context.logger.warn( 'OAuth relay requires storeStateStrategy: "cookie". Current:', - ctx.context.oauthConfig.storeStateStrategy + ctx.context.oauthConfig.storeStateStrategy, ); return; } @@ -410,10 +393,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { const stateCookie = ctx.context.createAuthCookie('oauth_state'); // Cookie header can have multiple cookies, need to find the right one // Format: "name=value; attributes, name2=value2; attributes" - const cookieRegex = new RegExp( - `(?:^|,\\s*)${stateCookie.name}=([^;]+)`, - 'i' - ); + const cookieRegex = new RegExp(`(?:^|,\\s*)${stateCookie.name}=([^;]+)`, 'i'); const match = setCookieHeader.match(cookieRegex); const stateCookieValue = match?.[1];