@mr.dj2u/library-registry 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +3 -0
  2. package/assets/mds/env/convex.env.example +1 -0
  3. package/assets/mds/env/firebase.env.example +6 -0
  4. package/assets/mds/env/supabase.env.example +3 -0
  5. package/assets/mds/project/auth-base.md +17 -0
  6. package/assets/mds/project/auth-convex.md +33 -0
  7. package/assets/mds/project/auth-firebase.md +22 -0
  8. package/assets/mds/project/auth-supabase.md +46 -0
  9. package/assets/mds/src/app/(auth)/reset-password.tsx +5 -0
  10. package/assets/mds/src/app/(auth)/sign-in.tsx +5 -0
  11. package/assets/mds/src/app/(auth)/sign-up.tsx +5 -0
  12. package/assets/mds/src/app/legal/updates.tsx +1 -0
  13. package/assets/mds/src/app/onboarding/complete.tsx +2 -0
  14. package/assets/mds/src/app/onboarding/features.tsx +1 -0
  15. package/assets/mds/src/app/onboarding/legal.tsx +2 -0
  16. package/assets/mds/src/app/onboarding.tsx +1 -1
  17. package/assets/mds/src/app/settings.tsx +10 -1
  18. package/assets/mds/src/db/adapter.ts +195 -0
  19. package/assets/mds/src/db/firebase.ts +77 -0
  20. package/assets/mds/src/db/index.firebase.ts +19 -0
  21. package/assets/mds/src/db/index.supabase.ts +22 -0
  22. package/assets/mds/src/db/supabase.ts +337 -0
  23. package/assets/mds/src/features/auth/adapters/base-auth-adapter.tsx +82 -0
  24. package/assets/mds/src/features/auth/adapters/convex-auth-adapter.tsx +156 -0
  25. package/assets/mds/src/features/auth/adapters/firebase-auth-adapter.tsx +147 -0
  26. package/assets/mds/src/features/auth/adapters/supabase-auth-adapter.tsx +166 -0
  27. package/assets/mds/src/features/auth/auth-guard-logic.ts +19 -0
  28. package/assets/mds/src/features/auth/auth-guard.tsx +77 -0
  29. package/assets/mds/src/features/auth/auth-provider.tsx +60 -0
  30. package/assets/mds/src/features/auth/auth-screen.tsx +265 -0
  31. package/assets/mds/src/features/auth/auth-types.ts +45 -0
  32. package/assets/mds/src/features/exposition/data-screen.tsx +4 -2
  33. package/assets/mds/src/features/exposition/expo-sdk-56-screen.tsx +119 -76
  34. package/assets/mds/src/features/exposition/exposition-screen.tsx +10 -11
  35. package/assets/mds/src/features/exposition/stylist-screen.tsx +62 -11
  36. package/assets/mds/src/features/legal/legal-acceptance-adapter.ts +190 -0
  37. package/assets/mds/src/features/legal/legal-acceptance-config.ts +79 -0
  38. package/assets/mds/src/features/legal/legal-agreement-screen.tsx +9 -4
  39. package/assets/mds/src/features/legal/legal-document-modal.tsx +7 -3
  40. package/assets/mds/src/features/legal/legal-document-view.tsx +1 -0
  41. package/assets/mds/src/features/legal/legal-documents.ts +17 -3
  42. package/assets/mds/src/features/legal/legal-update-screen.tsx +297 -0
  43. package/assets/mds/src/features/legal/use-legal-acceptance.ts +1 -38
  44. package/assets/mds/src/features/onboarding/complete-screen.tsx +138 -0
  45. package/assets/mds/src/features/onboarding/features-screen.tsx +129 -0
  46. package/assets/mds/src/features/onboarding/legal-review-screen.tsx +242 -0
  47. package/assets/mds/src/features/onboarding/onboarding-config-with-legal.ts +104 -0
  48. package/assets/mds/src/features/onboarding/onboarding-config.ts +104 -0
  49. package/assets/mds/src/features/onboarding/onboarding-persistence-sync.tsx +1 -0
  50. package/assets/mds/src/features/onboarding/welcome-screen.tsx +130 -0
  51. package/assets/mds/src/features/onboarding-state/adapters/memory-onboarding-state-adapter.ts +15 -0
  52. package/assets/mds/src/features/onboarding-state/adapters/supabase-onboarding-state-adapter.ts +45 -0
  53. package/assets/mds/src/features/onboarding-state/adapters/zustand-onboarding-state-adapter.ts +16 -0
  54. package/assets/mds/src/features/onboarding-state/adapters/zustand-supabase-onboarding-state-adapter.ts +53 -0
  55. package/assets/mds/src/features/onboarding-state/onboarding-state-core.ts +108 -0
  56. package/assets/mds/src/features/onboarding-state/onboarding-state-memory.ts +106 -0
  57. package/assets/mds/src/features/onboarding-state/onboarding-state-supabase.ts +246 -0
  58. package/assets/mds/src/features/onboarding-state/onboarding-state-types.ts +64 -0
  59. package/assets/mds/src/features/onboarding-state/onboarding-state-zustand-supabase.ts +120 -0
  60. package/assets/mds/src/features/onboarding-state/onboarding-state-zustand.ts +71 -0
  61. package/assets/mds/src/features/onboarding-state/onboarding-state.ts +22 -0
  62. package/assets/mds/src/features/onboarding-state/onboarding-store.ts +39 -0
  63. package/assets/mds/src/features/settings/settings-screen-logic.ts +122 -0
  64. package/assets/mds/src/features/settings/settings-screen.tsx +274 -28
  65. package/assets/mds/src/services/convex.ts +40 -0
  66. package/assets/mds/src/services/firebase.ts +70 -0
  67. package/assets/mds/src/services/supabase.ts +50 -0
  68. package/assets/mds/src/theme/color-utils.ts +42 -0
  69. package/assets/mds/src/theme/provider.tsx +15 -5
  70. package/assets/mds/src/types/database.ts +13 -0
  71. package/assets/mds/supabase/migrations/0001_mds_auth_onboarding.sql +107 -0
  72. package/dist/catalog.d.ts.map +1 -1
  73. package/dist/catalog.js +444 -29
  74. package/dist/catalog.js.map +1 -1
  75. package/dist/types.d.ts +5 -1
  76. package/dist/types.d.ts.map +1 -1
  77. package/package.json +1 -1
  78. package/assets/mds/src/app/onboarding/account-setup.tsx +0 -1
  79. package/assets/mds/src/app/onboarding/agreement.tsx +0 -1
  80. package/assets/mds/src/app/onboarding/terms.tsx +0 -1
  81. package/assets/mds/src/features/onboarding/account-setup-screen.tsx +0 -54
  82. package/assets/mds/src/features/onboarding/agreement-screen.tsx +0 -6
  83. package/assets/mds/src/features/onboarding/components/legal-document-view.tsx +0 -103
  84. package/assets/mds/src/features/onboarding/legal-documents.ts +0 -80
  85. package/assets/mds/src/features/onboarding/onboarding-screen.tsx +0 -167
  86. package/assets/mds/src/features/onboarding/terms-screen.tsx +0 -6
@@ -0,0 +1,53 @@
1
+ import { useEffect } from 'react';
2
+
3
+ import { useAuth } from '../auth/auth-provider';
4
+ import {
5
+ configureLegalAcceptanceAdapter,
6
+ setLegalAcceptanceUserId,
7
+ } from '../legal/legal-acceptance-config';
8
+ import { getSupabaseClient } from '../../services/supabase';
9
+ import {
10
+ configureOnboardingStateAdapter,
11
+ getOnboardingStateAdapter,
12
+ setOnboardingStateUserId,
13
+ } from './onboarding-state-core';
14
+ import type { SupabaseClientFactory } from './onboarding-state-supabase';
15
+ import {
16
+ getPersistedOnboardingState,
17
+ setPersistedOnboardingState,
18
+ } from './onboarding-store';
19
+ import {
20
+ createZustandSupabaseLegalAcceptanceAdapter,
21
+ createZustandSupabaseOnboardingStateAdapter,
22
+ } from './onboarding-state-zustand-supabase';
23
+
24
+ const cache = {
25
+ getState: getPersistedOnboardingState,
26
+ setState: setPersistedOnboardingState,
27
+ };
28
+
29
+ const getClient: SupabaseClientFactory = () =>
30
+ getSupabaseClient() as ReturnType<SupabaseClientFactory>;
31
+
32
+ export const onboardingStateAdapter = createZustandSupabaseOnboardingStateAdapter(getClient, cache);
33
+ export const legalAcceptanceAdapter = createZustandSupabaseLegalAcceptanceAdapter(getClient, cache);
34
+
35
+ configureOnboardingStateAdapter(onboardingStateAdapter);
36
+ configureLegalAcceptanceAdapter(legalAcceptanceAdapter);
37
+
38
+ export function OnboardingPersistenceSync() {
39
+ const auth = useAuth();
40
+
41
+ useEffect(() => {
42
+ const userId = auth.user?.id;
43
+ setOnboardingStateUserId(userId);
44
+ setLegalAcceptanceUserId(userId);
45
+
46
+ if (!userId) {
47
+ return;
48
+ }
49
+ void getOnboardingStateAdapter().syncPending?.(userId);
50
+ }, [auth.user?.id]);
51
+
52
+ return null;
53
+ }
@@ -0,0 +1,108 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+
3
+ import { createMemoryOnboardingStateAdapter } from './onboarding-state-memory';
4
+ import type { OnboardingState, OnboardingStateAdapter } from './onboarding-state-types';
5
+
6
+ let onboardingStateAdapter: OnboardingStateAdapter = createMemoryOnboardingStateAdapter();
7
+ type OnboardingStateListener = (state?: OnboardingState) => void;
8
+
9
+ const onboardingStateListeners = new Set<OnboardingStateListener>();
10
+ let currentOnboardingUserId: string | undefined;
11
+
12
+ function notifyOnboardingStateChanged(state?: OnboardingState): void {
13
+ for (const listener of onboardingStateListeners) {
14
+ listener(state);
15
+ }
16
+ }
17
+
18
+ export function configureOnboardingStateAdapter(adapter: OnboardingStateAdapter): void {
19
+ onboardingStateAdapter = adapter;
20
+ notifyOnboardingStateChanged();
21
+ }
22
+
23
+ export function getOnboardingStateAdapter(): OnboardingStateAdapter {
24
+ return onboardingStateAdapter;
25
+ }
26
+
27
+ export function setOnboardingStateUserId(userId?: string | null): void {
28
+ currentOnboardingUserId = userId ?? undefined;
29
+ }
30
+
31
+ export function subscribeToOnboardingStateChanges(listener: OnboardingStateListener): () => void {
32
+ onboardingStateListeners.add(listener);
33
+ return () => {
34
+ onboardingStateListeners.delete(listener);
35
+ };
36
+ }
37
+
38
+ export async function markOnboardingComplete(input?: {
39
+ userId?: string;
40
+ completedAt?: string;
41
+ }): Promise<OnboardingState> {
42
+ const next = await onboardingStateAdapter.markComplete({
43
+ ...input,
44
+ userId: input?.userId ?? currentOnboardingUserId,
45
+ });
46
+ notifyOnboardingStateChanged(next);
47
+ return next;
48
+ }
49
+
50
+ export function useOnboardingState(userId?: string) {
51
+ const adapter = onboardingStateAdapter;
52
+ const [state, setState] = useState<OnboardingState | null>(null);
53
+ const [isLoading, setIsLoading] = useState(true);
54
+ const [error, setError] = useState<string | undefined>();
55
+
56
+ const refresh = useCallback(async () => {
57
+ setIsLoading(true);
58
+ setError(undefined);
59
+ try {
60
+ const next = await adapter.loadState(userId);
61
+ setState(next);
62
+ return next;
63
+ } catch (caught) {
64
+ const message = caught instanceof Error ? caught.message : 'Unable to load onboarding state.';
65
+ setError(message);
66
+ return null;
67
+ } finally {
68
+ setIsLoading(false);
69
+ }
70
+ }, [adapter, userId]);
71
+
72
+ useEffect(() => {
73
+ const timer = setTimeout(() => {
74
+ void refresh();
75
+ }, 0);
76
+ return () => clearTimeout(timer);
77
+ }, [refresh]);
78
+
79
+ useEffect(
80
+ () =>
81
+ subscribeToOnboardingStateChanges((nextState) => {
82
+ if (nextState) {
83
+ setState(nextState);
84
+ setError(undefined);
85
+ setIsLoading(false);
86
+ return;
87
+ }
88
+ void refresh();
89
+ }),
90
+ [refresh],
91
+ );
92
+
93
+ return {
94
+ adapter,
95
+ state,
96
+ isLoading,
97
+ error,
98
+ refresh,
99
+ markComplete: async (input?: { completedAt?: string }) => {
100
+ const next = await adapter.markComplete({ userId, completedAt: input?.completedAt });
101
+ setState(next);
102
+ setError(undefined);
103
+ setIsLoading(false);
104
+ notifyOnboardingStateChanged(next);
105
+ return next;
106
+ },
107
+ };
108
+ }
@@ -0,0 +1,106 @@
1
+ import {
2
+ createEmptyOnboardingState,
3
+ upsertLegalAcceptance,
4
+ type OnboardingDocumentAcceptance,
5
+ type OnboardingState,
6
+ type OnboardingStateAdapter,
7
+ } from './onboarding-state-types';
8
+
9
+ export type MemoryLegalDocument = {
10
+ documentId: OnboardingDocumentAcceptance['documentId'];
11
+ acceptanceVersion: string;
12
+ };
13
+
14
+ export type MemoryLegalSnapshot = {
15
+ status: 'needs-legal' | 'complete';
16
+ requiredDocuments: MemoryLegalDocument[];
17
+ acceptedDocumentKeys: string[];
18
+ };
19
+
20
+ export type MemoryLegalAcceptanceAdapter = {
21
+ loadRequiredLegalAcceptances(
22
+ requiredDocuments: MemoryLegalDocument[],
23
+ userId?: string,
24
+ ): Promise<MemoryLegalSnapshot>;
25
+ acceptLegalDocument(
26
+ document: MemoryLegalDocument,
27
+ input?: { userId?: string; flowId?: string; flowVersion?: number },
28
+ ): Promise<void>;
29
+ };
30
+
31
+ type MemoryStore = {
32
+ state: OnboardingState | null;
33
+ };
34
+
35
+ export function createMemoryOnboardingStore(initial?: OnboardingState | null): MemoryStore {
36
+ return { state: initial ?? null };
37
+ }
38
+
39
+ export function createMemoryOnboardingStateAdapter(
40
+ store: MemoryStore = createMemoryOnboardingStore(),
41
+ ): OnboardingStateAdapter {
42
+ return {
43
+ mode: 'memory',
44
+ async loadState() {
45
+ return store.state ? { ...store.state, legalAcceptances: [...store.state.legalAcceptances] } : null;
46
+ },
47
+ async saveState(state: OnboardingState) {
48
+ store.state = {
49
+ ...state,
50
+ legalAcceptances: [...state.legalAcceptances],
51
+ };
52
+ },
53
+ async markComplete(input) {
54
+ const current = store.state ?? createEmptyOnboardingState();
55
+ const next = {
56
+ ...current,
57
+ completedAt: input?.completedAt ?? new Date().toISOString(),
58
+ currentStep: 'complete',
59
+ };
60
+ store.state = next;
61
+ return next;
62
+ },
63
+ };
64
+ }
65
+
66
+ export function createMemoryLegalAcceptanceAdapter(
67
+ store: MemoryStore = createMemoryOnboardingStore(),
68
+ ): MemoryLegalAcceptanceAdapter {
69
+ return {
70
+ async loadRequiredLegalAcceptances(requiredDocuments: MemoryLegalDocument[]) {
71
+ const acceptedDocumentKeys = (store.state?.legalAcceptances ?? []).map(
72
+ (item) => `${item.documentId}@${item.documentVersion}`,
73
+ );
74
+ const accepted = new Set(acceptedDocumentKeys);
75
+ const missingDocuments = requiredDocuments.filter(
76
+ (document) => !accepted.has(`${document.documentId}@${document.acceptanceVersion}`),
77
+ );
78
+
79
+ return {
80
+ status: missingDocuments.length > 0 ? 'needs-legal' : 'complete',
81
+ requiredDocuments: missingDocuments,
82
+ acceptedDocumentKeys,
83
+ };
84
+ },
85
+
86
+ async acceptLegalDocument(document, input) {
87
+ const current = store.state ?? createEmptyOnboardingState();
88
+ store.state = upsertLegalAcceptance(current, {
89
+ documentId: document.documentId,
90
+ documentVersion: document.acceptanceVersion,
91
+ acceptedAt: new Date().toISOString(),
92
+ userId: input?.userId,
93
+ flowId: input?.flowId,
94
+ flowVersion: input?.flowVersion,
95
+ });
96
+ },
97
+ };
98
+ }
99
+
100
+ export function createMemoryOnboardingPersistence(store: MemoryStore = createMemoryOnboardingStore()) {
101
+ return {
102
+ store,
103
+ onboarding: createMemoryOnboardingStateAdapter(store),
104
+ legal: createMemoryLegalAcceptanceAdapter(store),
105
+ };
106
+ }
@@ -0,0 +1,246 @@
1
+ import type { MemoryLegalAcceptanceAdapter, MemoryLegalDocument } from './onboarding-state-memory';
2
+ import {
3
+ createEmptyOnboardingState,
4
+ DEFAULT_ONBOARDING_FLOW_ID,
5
+ DEFAULT_ONBOARDING_FLOW_VERSION,
6
+ type OnboardingDocumentAcceptance,
7
+ type OnboardingState,
8
+ type OnboardingStateAdapter,
9
+ } from './onboarding-state-types';
10
+
11
+ export type SupabaseOnboardingRow = {
12
+ user_id: string;
13
+ flow_id: string;
14
+ flow_version: number;
15
+ status: string;
16
+ current_step: string | null;
17
+ completed_at: string | null;
18
+ };
19
+
20
+ export type SupabaseLegalRow = {
21
+ user_id: string;
22
+ document_id: string;
23
+ document_version: string;
24
+ flow_id: string | null;
25
+ accepted_at: string;
26
+ };
27
+
28
+ export type SupabaseResult<T> = {
29
+ data: T | null;
30
+ error: { message: string } | null;
31
+ };
32
+
33
+ export type SupabaseOnboardingClient = {
34
+ from(table: string): {
35
+ select(columns: string): {
36
+ eq(column: string, value: string): PromiseLike<SupabaseResult<unknown>> & {
37
+ maybeSingle(): PromiseLike<SupabaseResult<unknown>>;
38
+ };
39
+ };
40
+ upsert(
41
+ values: Record<string, unknown>,
42
+ options?: { onConflict?: string },
43
+ ): PromiseLike<SupabaseResult<null>>;
44
+ insert(values: Record<string, unknown>): PromiseLike<SupabaseResult<null>>;
45
+ };
46
+ };
47
+
48
+ export type SupabaseClientFactory = () => SupabaseOnboardingClient;
49
+
50
+ function requireUserId(userId?: string): string {
51
+ if (!userId) {
52
+ throw new Error('Supabase onboarding persistence requires a signed-in user id.');
53
+ }
54
+ return userId;
55
+ }
56
+
57
+ function isIgnorableInsertError(message: string): boolean {
58
+ return /duplicate|unique/i.test(message);
59
+ }
60
+
61
+ function isMissingColumnError(message: string, columnName: string): boolean {
62
+ return message.includes(columnName) && /column|schema cache|not find/i.test(message);
63
+ }
64
+
65
+ const LEGACY_COMPLETED_AT_FALLBACK = '1970-01-01T00:00:00.000Z';
66
+
67
+ function toOnboardingState(
68
+ row: SupabaseOnboardingRow | null,
69
+ legalRows: SupabaseLegalRow[],
70
+ ): OnboardingState | null {
71
+ if (!row && legalRows.length === 0) {
72
+ return null;
73
+ }
74
+
75
+ return {
76
+ flowId: row?.flow_id ?? DEFAULT_ONBOARDING_FLOW_ID,
77
+ flowVersion: row?.flow_version ?? DEFAULT_ONBOARDING_FLOW_VERSION,
78
+ currentStep: row?.current_step ?? undefined,
79
+ completedAt:
80
+ row?.completed_at ??
81
+ (row?.status === 'complete' ? LEGACY_COMPLETED_AT_FALLBACK : undefined),
82
+ legalAcceptances: legalRows.map((item) => ({
83
+ documentId: item.document_id as OnboardingDocumentAcceptance['documentId'],
84
+ documentVersion: item.document_version,
85
+ acceptedAt: item.accepted_at,
86
+ userId: item.user_id,
87
+ flowId: item.flow_id ?? undefined,
88
+ })),
89
+ pendingSync: false,
90
+ };
91
+ }
92
+
93
+ async function loadRemote(
94
+ getClient: SupabaseClientFactory,
95
+ userId: string,
96
+ ): Promise<OnboardingState | null> {
97
+ const client = getClient();
98
+ const onboardingQuery = client
99
+ .from('user_onboarding_state')
100
+ .select('user_id, flow_id, flow_version, status, current_step, completed_at')
101
+ .eq('user_id', userId);
102
+ const onboardingResult = await onboardingQuery.maybeSingle();
103
+ if (onboardingResult.error) {
104
+ throw new Error(onboardingResult.error.message);
105
+ }
106
+
107
+ const legalResult = await client
108
+ .from('user_legal_acceptances')
109
+ .select('user_id, document_id, document_version, flow_id, accepted_at')
110
+ .eq('user_id', userId);
111
+ if (legalResult.error) {
112
+ throw new Error(legalResult.error.message);
113
+ }
114
+
115
+ return toOnboardingState(
116
+ (onboardingResult.data as SupabaseOnboardingRow | null) ?? null,
117
+ (legalResult.data as SupabaseLegalRow[] | null) ?? [],
118
+ );
119
+ }
120
+
121
+ export function createSupabaseOnboardingStateAdapter(
122
+ getClient: SupabaseClientFactory,
123
+ ): OnboardingStateAdapter {
124
+ return {
125
+ mode: 'supabase',
126
+ async loadState(userId) {
127
+ if (!userId) {
128
+ return null;
129
+ }
130
+ return loadRemote(getClient, userId);
131
+ },
132
+ async saveState(state, userId) {
133
+ const id = requireUserId(userId);
134
+ const result = await getClient()
135
+ .from('user_onboarding_state')
136
+ .upsert(
137
+ {
138
+ user_id: id,
139
+ flow_id: state.flowId,
140
+ flow_version: state.flowVersion,
141
+ status: state.completedAt ? 'complete' : 'in_progress',
142
+ current_step: state.currentStep ?? null,
143
+ completed_at: state.completedAt ?? null,
144
+ updated_at: new Date().toISOString(),
145
+ },
146
+ { onConflict: 'user_id' },
147
+ );
148
+ if (result.error) {
149
+ throw new Error(result.error.message);
150
+ }
151
+ },
152
+ async markComplete(input) {
153
+ const id = requireUserId(input?.userId);
154
+ const current = createEmptyOnboardingState();
155
+ const next = {
156
+ ...current,
157
+ completedAt: input?.completedAt ?? new Date().toISOString(),
158
+ currentStep: 'complete',
159
+ pendingSync: false,
160
+ };
161
+ const result = await getClient()
162
+ .from('user_onboarding_state')
163
+ .upsert(
164
+ {
165
+ user_id: id,
166
+ flow_id: next.flowId,
167
+ flow_version: next.flowVersion,
168
+ status: 'complete',
169
+ current_step: next.currentStep ?? null,
170
+ completed_at: next.completedAt ?? null,
171
+ updated_at: new Date().toISOString(),
172
+ },
173
+ { onConflict: 'user_id' },
174
+ );
175
+ if (result.error) {
176
+ throw new Error(result.error.message);
177
+ }
178
+ return next;
179
+ },
180
+ };
181
+ }
182
+
183
+ export function createSupabaseLegalAcceptanceAdapter(
184
+ getClient: SupabaseClientFactory,
185
+ ): MemoryLegalAcceptanceAdapter {
186
+ return {
187
+ async loadRequiredLegalAcceptances(requiredDocuments: MemoryLegalDocument[], userId?: string) {
188
+ if (!userId) {
189
+ return {
190
+ status: 'needs-legal',
191
+ requiredDocuments,
192
+ acceptedDocumentKeys: [],
193
+ };
194
+ }
195
+
196
+ const legalResult = await getClient()
197
+ .from('user_legal_acceptances')
198
+ .select('user_id, document_id, document_version, flow_id, accepted_at')
199
+ .eq('user_id', userId);
200
+ if (legalResult.error) {
201
+ throw new Error(legalResult.error.message);
202
+ }
203
+
204
+ const acceptedDocumentKeys = ((legalResult.data as SupabaseLegalRow[] | null) ?? []).map(
205
+ (item) => `${item.document_id}@${item.document_version}`,
206
+ );
207
+ const accepted = new Set(acceptedDocumentKeys);
208
+ const missingDocuments = requiredDocuments.filter(
209
+ (document) => !accepted.has(`${document.documentId}@${document.acceptanceVersion}`),
210
+ );
211
+
212
+ return {
213
+ status: missingDocuments.length > 0 ? 'needs-legal' : 'complete',
214
+ requiredDocuments: missingDocuments,
215
+ acceptedDocumentKeys,
216
+ };
217
+ },
218
+
219
+ async acceptLegalDocument(document, input) {
220
+ const userId = requireUserId(input?.userId);
221
+ const payload = {
222
+ user_id: userId,
223
+ document_id: document.documentId,
224
+ document_version: document.acceptanceVersion,
225
+ acceptance_version: document.acceptanceVersion,
226
+ flow_id: input?.flowId ?? DEFAULT_ONBOARDING_FLOW_ID,
227
+ accepted_at: new Date().toISOString(),
228
+ metadata: {
229
+ flowVersion: input?.flowVersion ?? DEFAULT_ONBOARDING_FLOW_VERSION,
230
+ },
231
+ };
232
+ let result = await getClient().from('user_legal_acceptances').insert(payload);
233
+ if (
234
+ result.error &&
235
+ isMissingColumnError(result.error.message, 'acceptance_version')
236
+ ) {
237
+ const modernPayload: Record<string, unknown> = { ...payload };
238
+ delete modernPayload.acceptance_version;
239
+ result = await getClient().from('user_legal_acceptances').insert(modernPayload);
240
+ }
241
+ if (result.error && !isIgnorableInsertError(result.error.message)) {
242
+ throw new Error(result.error.message);
243
+ }
244
+ },
245
+ };
246
+ }
@@ -0,0 +1,64 @@
1
+ export type OnboardingPersistenceMode =
2
+ | 'memory'
3
+ | 'zustand-local'
4
+ | 'supabase'
5
+ | 'zustand-supabase';
6
+
7
+ export const DEFAULT_ONBOARDING_FLOW_ID = 'mds/onboarding';
8
+ export const DEFAULT_ONBOARDING_FLOW_VERSION = 1;
9
+
10
+ export type OnboardingDocumentAcceptance = {
11
+ documentId: 'terms' | 'privacy';
12
+ documentVersion: string;
13
+ acceptedAt: string;
14
+ userId?: string;
15
+ flowId?: string;
16
+ flowVersion?: number;
17
+ };
18
+
19
+ export type OnboardingState = {
20
+ flowId: string;
21
+ flowVersion: number;
22
+ currentStep?: string;
23
+ completedAt?: string;
24
+ legalAcceptances: OnboardingDocumentAcceptance[];
25
+ pendingSync?: boolean;
26
+ };
27
+
28
+ export type OnboardingStateAdapter = {
29
+ mode: OnboardingPersistenceMode;
30
+ loadState(userId?: string): Promise<OnboardingState | null>;
31
+ saveState(state: OnboardingState, userId?: string): Promise<void>;
32
+ markComplete(input?: { userId?: string; completedAt?: string }): Promise<OnboardingState>;
33
+ syncPending?(userId: string): Promise<OnboardingState>;
34
+ };
35
+
36
+ export function createEmptyOnboardingState(
37
+ input: Partial<OnboardingState> = {},
38
+ ): OnboardingState {
39
+ return {
40
+ flowId: input.flowId ?? DEFAULT_ONBOARDING_FLOW_ID,
41
+ flowVersion: input.flowVersion ?? DEFAULT_ONBOARDING_FLOW_VERSION,
42
+ currentStep: input.currentStep,
43
+ completedAt: input.completedAt,
44
+ legalAcceptances: input.legalAcceptances ?? [],
45
+ pendingSync: input.pendingSync,
46
+ };
47
+ }
48
+
49
+ export function upsertLegalAcceptance(
50
+ state: OnboardingState,
51
+ acceptance: OnboardingDocumentAcceptance,
52
+ ): OnboardingState {
53
+ const remaining = state.legalAcceptances.filter(
54
+ (item) =>
55
+ !(
56
+ item.documentId === acceptance.documentId &&
57
+ item.documentVersion === acceptance.documentVersion
58
+ ),
59
+ );
60
+ return {
61
+ ...state,
62
+ legalAcceptances: [...remaining, acceptance],
63
+ };
64
+ }
@@ -0,0 +1,120 @@
1
+ import type { MemoryLegalAcceptanceAdapter, MemoryLegalDocument } from './onboarding-state-memory';
2
+ import {
3
+ createSupabaseLegalAcceptanceAdapter,
4
+ createSupabaseOnboardingStateAdapter,
5
+ type SupabaseClientFactory,
6
+ } from './onboarding-state-supabase';
7
+ import {
8
+ createEmptyOnboardingState,
9
+ upsertLegalAcceptance,
10
+ type OnboardingState,
11
+ type OnboardingStateAdapter,
12
+ } from './onboarding-state-types';
13
+
14
+ export type OnboardingStateCache = {
15
+ getState(): OnboardingState;
16
+ setState(state: OnboardingState): void;
17
+ };
18
+
19
+ export function createZustandSupabaseOnboardingStateAdapter(
20
+ getClient: SupabaseClientFactory,
21
+ cache: OnboardingStateCache,
22
+ ): OnboardingStateAdapter {
23
+ const remote = createSupabaseOnboardingStateAdapter(getClient);
24
+
25
+ return {
26
+ mode: 'zustand-supabase',
27
+ async loadState(userId) {
28
+ const cached = cache.getState();
29
+ if (!userId) {
30
+ return { ...cached, pendingSync: true };
31
+ }
32
+
33
+ const remoteState = await remote.loadState(userId);
34
+ if (!remoteState) {
35
+ return { ...cached, pendingSync: Boolean(cached.completedAt || cached.currentStep) };
36
+ }
37
+
38
+ const merged: OnboardingState = {
39
+ ...remoteState,
40
+ currentStep: remoteState.currentStep ?? cached.currentStep,
41
+ completedAt: remoteState.completedAt ?? cached.completedAt,
42
+ legalAcceptances: remoteState.legalAcceptances,
43
+ pendingSync: false,
44
+ };
45
+ cache.setState(merged);
46
+ return merged;
47
+ },
48
+ async saveState(state, userId) {
49
+ cache.setState({ ...state, pendingSync: !userId });
50
+ if (userId) {
51
+ await remote.saveState({ ...state, pendingSync: false }, userId);
52
+ cache.setState({ ...state, pendingSync: false });
53
+ }
54
+ },
55
+ async markComplete(input) {
56
+ const current = cache.getState();
57
+ const next = {
58
+ ...current,
59
+ completedAt: input?.completedAt ?? new Date().toISOString(),
60
+ currentStep: 'complete',
61
+ pendingSync: !input?.userId,
62
+ };
63
+ cache.setState(next);
64
+ if (input?.userId) {
65
+ const remoteState = await remote.markComplete(input);
66
+ cache.setState({ ...remoteState, pendingSync: false });
67
+ return { ...remoteState, pendingSync: false };
68
+ }
69
+ return next;
70
+ },
71
+ async syncPending(userId) {
72
+ const cached = cache.getState();
73
+ const remoteState = await remote.loadState(userId);
74
+ if (remoteState) {
75
+ cache.setState({ ...remoteState, pendingSync: false });
76
+ return { ...remoteState, pendingSync: false };
77
+ }
78
+ if (cached.completedAt || cached.currentStep) {
79
+ await remote.saveState({ ...cached, pendingSync: false }, userId);
80
+ cache.setState({ ...cached, pendingSync: false });
81
+ }
82
+ return { ...cache.getState(), pendingSync: false };
83
+ },
84
+ };
85
+ }
86
+
87
+ export function createZustandSupabaseLegalAcceptanceAdapter(
88
+ getClient: SupabaseClientFactory,
89
+ cache: OnboardingStateCache,
90
+ ): MemoryLegalAcceptanceAdapter {
91
+ const remote = createSupabaseLegalAcceptanceAdapter(getClient);
92
+
93
+ return {
94
+ async loadRequiredLegalAcceptances(requiredDocuments: MemoryLegalDocument[], userId?: string) {
95
+ if (!userId) {
96
+ return {
97
+ status: 'needs-legal',
98
+ requiredDocuments,
99
+ acceptedDocumentKeys: [],
100
+ };
101
+ }
102
+ return remote.loadRequiredLegalAcceptances(requiredDocuments, userId);
103
+ },
104
+
105
+ async acceptLegalDocument(document, input) {
106
+ if (!input?.userId) {
107
+ const next = upsertLegalAcceptance(cache.getState() ?? createEmptyOnboardingState(), {
108
+ documentId: document.documentId,
109
+ documentVersion: document.acceptanceVersion,
110
+ acceptedAt: new Date().toISOString(),
111
+ flowId: input?.flowId,
112
+ flowVersion: input?.flowVersion,
113
+ });
114
+ cache.setState({ ...next, pendingSync: true });
115
+ return;
116
+ }
117
+ await remote.acceptLegalDocument(document, input);
118
+ },
119
+ };
120
+ }