create-stackr 0.2.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 (274) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +642 -0
  3. package/bin/cli.js +12 -0
  4. package/dist/cli.d.ts +3 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +113 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/config/dependencies.d.ts +82 -0
  9. package/dist/config/dependencies.d.ts.map +1 -0
  10. package/dist/config/dependencies.js +82 -0
  11. package/dist/config/dependencies.js.map +1 -0
  12. package/dist/config/presets.d.ts +3 -0
  13. package/dist/config/presets.d.ts.map +1 -0
  14. package/dist/config/presets.js +174 -0
  15. package/dist/config/presets.js.map +1 -0
  16. package/dist/generators/index.d.ts +40 -0
  17. package/dist/generators/index.d.ts.map +1 -0
  18. package/dist/generators/index.js +130 -0
  19. package/dist/generators/index.js.map +1 -0
  20. package/dist/generators/onboarding.d.ts +8 -0
  21. package/dist/generators/onboarding.d.ts.map +1 -0
  22. package/dist/generators/onboarding.js +141 -0
  23. package/dist/generators/onboarding.js.map +1 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +65 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/prompts/features.d.ts +14 -0
  29. package/dist/prompts/features.d.ts.map +1 -0
  30. package/dist/prompts/features.js +96 -0
  31. package/dist/prompts/features.js.map +1 -0
  32. package/dist/prompts/index.d.ts +3 -0
  33. package/dist/prompts/index.d.ts.map +1 -0
  34. package/dist/prompts/index.js +93 -0
  35. package/dist/prompts/index.js.map +1 -0
  36. package/dist/prompts/onboarding.d.ts +6 -0
  37. package/dist/prompts/onboarding.d.ts.map +1 -0
  38. package/dist/prompts/onboarding.js +37 -0
  39. package/dist/prompts/onboarding.js.map +1 -0
  40. package/dist/prompts/orm.d.ts +3 -0
  41. package/dist/prompts/orm.d.ts.map +1 -0
  42. package/dist/prompts/orm.js +23 -0
  43. package/dist/prompts/orm.js.map +1 -0
  44. package/dist/prompts/packageManager.d.ts +2 -0
  45. package/dist/prompts/packageManager.d.ts.map +1 -0
  46. package/dist/prompts/packageManager.js +18 -0
  47. package/dist/prompts/packageManager.js.map +1 -0
  48. package/dist/prompts/platform.d.ts +3 -0
  49. package/dist/prompts/platform.d.ts.map +1 -0
  50. package/dist/prompts/platform.js +21 -0
  51. package/dist/prompts/platform.js.map +1 -0
  52. package/dist/prompts/preset.d.ts +4 -0
  53. package/dist/prompts/preset.d.ts.map +1 -0
  54. package/dist/prompts/preset.js +165 -0
  55. package/dist/prompts/preset.js.map +1 -0
  56. package/dist/prompts/project.d.ts +2 -0
  57. package/dist/prompts/project.d.ts.map +1 -0
  58. package/dist/prompts/project.js +27 -0
  59. package/dist/prompts/project.js.map +1 -0
  60. package/dist/prompts/sdks.d.ts +2 -0
  61. package/dist/prompts/sdks.d.ts.map +1 -0
  62. package/dist/prompts/sdks.js +46 -0
  63. package/dist/prompts/sdks.js.map +1 -0
  64. package/dist/types/index.d.ts +77 -0
  65. package/dist/types/index.d.ts.map +1 -0
  66. package/dist/types/index.js +25 -0
  67. package/dist/types/index.js.map +1 -0
  68. package/dist/utils/cleanup.d.ts +5 -0
  69. package/dist/utils/cleanup.d.ts.map +1 -0
  70. package/dist/utils/cleanup.js +38 -0
  71. package/dist/utils/cleanup.js.map +1 -0
  72. package/dist/utils/copy.d.ts +10 -0
  73. package/dist/utils/copy.d.ts.map +1 -0
  74. package/dist/utils/copy.js +53 -0
  75. package/dist/utils/copy.js.map +1 -0
  76. package/dist/utils/errors.d.ts +33 -0
  77. package/dist/utils/errors.d.ts.map +1 -0
  78. package/dist/utils/errors.js +136 -0
  79. package/dist/utils/errors.js.map +1 -0
  80. package/dist/utils/git.d.ts +5 -0
  81. package/dist/utils/git.d.ts.map +1 -0
  82. package/dist/utils/git.js +33 -0
  83. package/dist/utils/git.js.map +1 -0
  84. package/dist/utils/logger.d.ts +9 -0
  85. package/dist/utils/logger.d.ts.map +1 -0
  86. package/dist/utils/logger.js +22 -0
  87. package/dist/utils/logger.js.map +1 -0
  88. package/dist/utils/package.d.ts +16 -0
  89. package/dist/utils/package.d.ts.map +1 -0
  90. package/dist/utils/package.js +86 -0
  91. package/dist/utils/package.js.map +1 -0
  92. package/dist/utils/system-validation.d.ts +9 -0
  93. package/dist/utils/system-validation.d.ts.map +1 -0
  94. package/dist/utils/system-validation.js +31 -0
  95. package/dist/utils/system-validation.js.map +1 -0
  96. package/dist/utils/template.d.ts +20 -0
  97. package/dist/utils/template.d.ts.map +1 -0
  98. package/dist/utils/template.js +234 -0
  99. package/dist/utils/template.js.map +1 -0
  100. package/dist/utils/validation.d.ts +8 -0
  101. package/dist/utils/validation.d.ts.map +1 -0
  102. package/dist/utils/validation.js +94 -0
  103. package/dist/utils/validation.js.map +1 -0
  104. package/package.json +96 -0
  105. package/templates/base/backend/.dockerignore.ejs +62 -0
  106. package/templates/base/backend/.env.example.ejs +116 -0
  107. package/templates/base/backend/Dockerfile.ejs +142 -0
  108. package/templates/base/backend/controllers/event-queue/index.ts +20 -0
  109. package/templates/base/backend/controllers/event-queue/workers/user.ts +39 -0
  110. package/templates/base/backend/controllers/rest-api/index.ts +48 -0
  111. package/templates/base/backend/controllers/rest-api/plugins/auth.ts +152 -0
  112. package/templates/base/backend/controllers/rest-api/plugins/config.ts +64 -0
  113. package/templates/base/backend/controllers/rest-api/plugins/error-handler.ts +118 -0
  114. package/templates/base/backend/controllers/rest-api/routes/auth.ts.ejs +180 -0
  115. package/templates/base/backend/controllers/rest-api/routes/device-sessions.ts +197 -0
  116. package/templates/base/backend/controllers/rest-api/routes/oauth-web.ts.ejs +375 -0
  117. package/templates/base/backend/controllers/rest-api/server.ts.ejs +87 -0
  118. package/templates/base/backend/domain/device-session/repository.drizzle.ts +209 -0
  119. package/templates/base/backend/domain/device-session/repository.prisma.ts +248 -0
  120. package/templates/base/backend/domain/device-session/schema.ts +72 -0
  121. package/templates/base/backend/domain/session/repository.drizzle.ts +72 -0
  122. package/templates/base/backend/domain/session/repository.prisma.ts +72 -0
  123. package/templates/base/backend/domain/session/schema.ts +29 -0
  124. package/templates/base/backend/domain/user/repository.drizzle.ts +127 -0
  125. package/templates/base/backend/domain/user/repository.prisma.ts +115 -0
  126. package/templates/base/backend/domain/user/schema.ts +14 -0
  127. package/templates/base/backend/drizzle/schema.drizzle.ts +111 -0
  128. package/templates/base/backend/drizzle.config.drizzle.ts +13 -0
  129. package/templates/base/backend/lib/auth.drizzle.ts.ejs +104 -0
  130. package/templates/base/backend/lib/auth.prisma.ts.ejs +97 -0
  131. package/templates/base/backend/lib/constants.ts.ejs +29 -0
  132. package/templates/base/backend/package.json.ejs +50 -0
  133. package/templates/base/backend/prisma/schema.prisma.ejs +102 -0
  134. package/templates/base/backend/prisma.config.prisma.ts +12 -0
  135. package/templates/base/backend/tsconfig.json +39 -0
  136. package/templates/base/backend/utils/db.drizzle.ts +41 -0
  137. package/templates/base/backend/utils/db.prisma.ts +51 -0
  138. package/templates/base/backend/utils/email.ts.ejs +35 -0
  139. package/templates/base/backend/utils/errors.ts +348 -0
  140. package/templates/base/backend/utils/redis.ts.ejs +279 -0
  141. package/templates/base/mobile/.env.example.ejs +35 -0
  142. package/templates/base/mobile/.gitignore.ejs +167 -0
  143. package/templates/base/mobile/app/+not-found.tsx +85 -0
  144. package/templates/base/mobile/app/_layout.tsx.ejs +71 -0
  145. package/templates/base/mobile/app.json.ejs +88 -0
  146. package/templates/base/mobile/assets/images/adaptive-icon.png +0 -0
  147. package/templates/base/mobile/assets/images/favicon.png +0 -0
  148. package/templates/base/mobile/assets/images/icon.png +0 -0
  149. package/templates/base/mobile/assets/images/onboarding_page_1.png +0 -0
  150. package/templates/base/mobile/assets/images/onboarding_page_2.png +0 -0
  151. package/templates/base/mobile/assets/images/onboarding_page_3.png +0 -0
  152. package/templates/base/mobile/assets/images/paywall_image.png +0 -0
  153. package/templates/base/mobile/assets/images/splash.png +0 -0
  154. package/templates/base/mobile/eas.json.ejs +49 -0
  155. package/templates/base/mobile/metro.config.js +9 -0
  156. package/templates/base/mobile/package.json.ejs +53 -0
  157. package/templates/base/mobile/src/components/ui/Button.tsx +131 -0
  158. package/templates/base/mobile/src/components/ui/Card.tsx +68 -0
  159. package/templates/base/mobile/src/components/ui/IconSymbol.tsx +90 -0
  160. package/templates/base/mobile/src/components/ui/Input.tsx +142 -0
  161. package/templates/base/mobile/src/components/ui/LoadingSpinner.tsx +98 -0
  162. package/templates/base/mobile/src/components/ui/OnboardingLayout.tsx +356 -0
  163. package/templates/base/mobile/src/components/ui/PaywallLayout.tsx +311 -0
  164. package/templates/base/mobile/src/components/ui/Skeleton.tsx +58 -0
  165. package/templates/base/mobile/src/components/ui/index.ts +6 -0
  166. package/templates/base/mobile/src/constants/Theme.ts +163 -0
  167. package/templates/base/mobile/src/context/ThemeContext.tsx +157 -0
  168. package/templates/base/mobile/src/lib/auth-client.ts.ejs +51 -0
  169. package/templates/base/mobile/src/services/api.ts.ejs +71 -0
  170. package/templates/base/mobile/src/services/errorService.ts +179 -0
  171. package/templates/base/mobile/src/services/sdkInitializer.ts.ejs +36 -0
  172. package/templates/base/mobile/src/store/index.ts.ejs +18 -0
  173. package/templates/base/mobile/src/store/ui.store.ts +100 -0
  174. package/templates/base/mobile/src/utils/formatters.ts +105 -0
  175. package/templates/base/mobile/src/utils/logger.ts +73 -0
  176. package/templates/base/mobile/src/utils/responsive.ts +234 -0
  177. package/templates/base/mobile/tsconfig.json +32 -0
  178. package/templates/base/web/.env.example.ejs +26 -0
  179. package/templates/base/web/components.json +22 -0
  180. package/templates/base/web/eslint.config.mjs +18 -0
  181. package/templates/base/web/next.config.ts +7 -0
  182. package/templates/base/web/package.json.ejs +35 -0
  183. package/templates/base/web/postcss.config.mjs +7 -0
  184. package/templates/base/web/public/.gitkeep +0 -0
  185. package/templates/base/web/public/file.svg +1 -0
  186. package/templates/base/web/public/globe.svg +1 -0
  187. package/templates/base/web/public/next.svg +1 -0
  188. package/templates/base/web/public/vercel.svg +1 -0
  189. package/templates/base/web/public/window.svg +1 -0
  190. package/templates/base/web/src/app/favicon.ico +0 -0
  191. package/templates/base/web/src/app/globals.css +152 -0
  192. package/templates/base/web/src/app/layout.tsx.ejs +54 -0
  193. package/templates/base/web/src/app/page.tsx.ejs +92 -0
  194. package/templates/base/web/src/components/auth/auth-hydrator.tsx.ejs +19 -0
  195. package/templates/base/web/src/components/auth/protected-route.tsx.ejs +109 -0
  196. package/templates/base/web/src/components/providers/device-session-setup.tsx.ejs +56 -0
  197. package/templates/base/web/src/components/providers/theme-provider.tsx +17 -0
  198. package/templates/base/web/src/components/theme-toggle.tsx +34 -0
  199. package/templates/base/web/src/components/ui/button.tsx +62 -0
  200. package/templates/base/web/src/components/ui/card.tsx +92 -0
  201. package/templates/base/web/src/components/ui/input.tsx +21 -0
  202. package/templates/base/web/src/components/ui/label.tsx +24 -0
  203. package/templates/base/web/src/components/ui/skeleton.tsx +13 -0
  204. package/templates/base/web/src/components/ui/spinner.tsx +20 -0
  205. package/templates/base/web/src/hooks/use-device-session.ts.ejs +40 -0
  206. package/templates/base/web/src/hooks/use-session.ts.ejs +56 -0
  207. package/templates/base/web/src/lib/auth/actions.ts.ejs +334 -0
  208. package/templates/base/web/src/lib/auth/config.ts.ejs +65 -0
  209. package/templates/base/web/src/lib/auth/cookies.ts.ejs +74 -0
  210. package/templates/base/web/src/lib/auth/index.ts.ejs +40 -0
  211. package/templates/base/web/src/lib/auth/oauth.ts.ejs +72 -0
  212. package/templates/base/web/src/lib/auth/pkce.ts.ejs +48 -0
  213. package/templates/base/web/src/lib/auth/sessions.ts.ejs +135 -0
  214. package/templates/base/web/src/lib/auth/user-agent.ts.ejs +47 -0
  215. package/templates/base/web/src/lib/device/actions.ts.ejs +148 -0
  216. package/templates/base/web/src/lib/device/id.ts.ejs +74 -0
  217. package/templates/base/web/src/lib/utils.ts +6 -0
  218. package/templates/base/web/src/proxy.ts.ejs +66 -0
  219. package/templates/base/web/src/store/auth.store.ts.ejs +89 -0
  220. package/templates/base/web/src/store/deviceSession.store.ts.ejs +141 -0
  221. package/templates/base/web/tsconfig.json +34 -0
  222. package/templates/features/mobile/auth/app/(auth)/_layout.tsx +16 -0
  223. package/templates/features/mobile/auth/app/(auth)/login.tsx +86 -0
  224. package/templates/features/mobile/auth/app/(auth)/register.tsx +86 -0
  225. package/templates/features/mobile/auth/components/auth/LoginForm.tsx.ejs +349 -0
  226. package/templates/features/mobile/auth/components/auth/RegisterForm.tsx.ejs +407 -0
  227. package/templates/features/mobile/auth/components/auth/index.ts +2 -0
  228. package/templates/features/mobile/auth/hooks/index.ts.ejs +1 -0
  229. package/templates/features/mobile/auth/hooks/useAuth.ts.ejs +367 -0
  230. package/templates/features/mobile/auth/services/deviceSession.ts +370 -0
  231. package/templates/features/mobile/auth/store/deviceSession.store.ts +326 -0
  232. package/templates/features/mobile/onboarding/app/(onboarding)/_layout.tsx.ejs +11 -0
  233. package/templates/features/mobile/onboarding/app/(onboarding)/page-1.tsx.ejs +52 -0
  234. package/templates/features/mobile/onboarding/app/(onboarding)/page-2.tsx.ejs +52 -0
  235. package/templates/features/mobile/onboarding/app/(onboarding)/page-3.tsx.ejs +60 -0
  236. package/templates/features/mobile/paywall/app/paywall.tsx +550 -0
  237. package/templates/features/mobile/tabs/app/(tabs)/_layout.tsx +26 -0
  238. package/templates/features/mobile/tabs/app/(tabs)/index.tsx +565 -0
  239. package/templates/features/web/.gitkeep +0 -0
  240. package/templates/features/web/auth/app/(app)/dashboard/dashboard-client.tsx.ejs +166 -0
  241. package/templates/features/web/auth/app/(app)/dashboard/page.tsx.ejs +24 -0
  242. package/templates/features/web/auth/app/(app)/layout.tsx.ejs +43 -0
  243. package/templates/features/web/auth/app/(app)/settings/sessions/page.tsx.ejs +29 -0
  244. package/templates/features/web/auth/app/(app)/settings/sessions/sessions-client.tsx.ejs +77 -0
  245. package/templates/features/web/auth/app/(auth)/forgot-password/page.tsx.ejs +127 -0
  246. package/templates/features/web/auth/app/(auth)/layout.tsx.ejs +32 -0
  247. package/templates/features/web/auth/app/(auth)/login/page.tsx.ejs +35 -0
  248. package/templates/features/web/auth/app/(auth)/register/page.tsx.ejs +19 -0
  249. package/templates/features/web/auth/app/(auth)/reset-password/page.tsx.ejs +40 -0
  250. package/templates/features/web/auth/app/(auth)/verify-email/page.tsx.ejs +198 -0
  251. package/templates/features/web/auth/app/auth/callback/route.ts.ejs +152 -0
  252. package/templates/features/web/auth/components/auth/login-form.tsx.ejs +100 -0
  253. package/templates/features/web/auth/components/auth/oauth-buttons.tsx.ejs +126 -0
  254. package/templates/features/web/auth/components/auth/password-reset-form.tsx.ejs +103 -0
  255. package/templates/features/web/auth/components/auth/register-form.tsx.ejs +139 -0
  256. package/templates/features/web/auth/components/settings/session-card.tsx.ejs +132 -0
  257. package/templates/integrations/mobile/adjust/services/adjustService.ts.ejs +163 -0
  258. package/templates/integrations/mobile/adjust/store/adjust.store.ts +243 -0
  259. package/templates/integrations/mobile/att/services/attService.ts +84 -0
  260. package/templates/integrations/mobile/att/services/trackingPermissions.ts +208 -0
  261. package/templates/integrations/mobile/att/store/att.store.ts +162 -0
  262. package/templates/integrations/mobile/revenuecat/services/revenuecatService.ts.ejs +174 -0
  263. package/templates/integrations/mobile/revenuecat/store/revenuecat.store.ts +286 -0
  264. package/templates/integrations/mobile/scate/services/scateService.ts.ejs +85 -0
  265. package/templates/integrations/mobile/scate/store/scate.store.ts +125 -0
  266. package/templates/integrations/web/.gitkeep +0 -0
  267. package/templates/shared/.env.example.ejs +21 -0
  268. package/templates/shared/.gitignore.ejs +145 -0
  269. package/templates/shared/README.md.ejs +134 -0
  270. package/templates/shared/docker-compose.prod.yml.ejs +120 -0
  271. package/templates/shared/docker-compose.yml.ejs +129 -0
  272. package/templates/shared/scripts/docker-dev.sh.ejs +395 -0
  273. package/templates/shared/scripts/docker-prod.sh.ejs +542 -0
  274. package/templates/shared/scripts/setup.sh.ejs +979 -0
@@ -0,0 +1,370 @@
1
+ import axios from 'axios';
2
+ import api from './api';
3
+ import AsyncStorage from '@react-native-async-storage/async-storage';
4
+ import { logger } from '../utils/logger';
5
+ import { Platform } from 'react-native';
6
+ import * as Application from 'expo-application';
7
+ import * as SecureStore from 'expo-secure-store';
8
+
9
+ export interface DeviceSession {
10
+ id: string;
11
+ deviceId: string;
12
+ sessionToken: string;
13
+ createdAt: string;
14
+ lastActiveAt: string;
15
+ migrated: boolean;
16
+ migratedToUserId?: string;
17
+ preferredCurrency: string;
18
+ }
19
+
20
+ export interface CreateDeviceSessionResponse {
21
+ session: DeviceSession;
22
+ sessionToken: string;
23
+ }
24
+
25
+ export interface DeviceSessionValidationResponse {
26
+ valid: boolean;
27
+ session?: DeviceSession;
28
+ }
29
+
30
+ export interface DeviceSessionMigrationEligibilityResponse {
31
+ canMigrate: boolean;
32
+ reason?: string;
33
+ }
34
+
35
+ class DeviceSessionService {
36
+ private static instance: DeviceSessionService;
37
+ private sessionToken: string | null = null;
38
+ private deviceId: string | null = null;
39
+ private session: DeviceSession | null = null;
40
+
41
+ private readonly SESSION_TOKEN_KEY = 'device_session_token';
42
+ private readonly DEVICE_ID_KEY = 'device_id';
43
+ private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
44
+
45
+ private constructor() {
46
+ logger.debug('DeviceSessionService: Initializing...');
47
+ }
48
+
49
+ public static getInstance(): DeviceSessionService {
50
+ if (!DeviceSessionService.instance) {
51
+ DeviceSessionService.instance = new DeviceSessionService();
52
+ }
53
+ return DeviceSessionService.instance;
54
+ }
55
+
56
+ async initialize(): Promise<DeviceSession | null> {
57
+ try {
58
+ logger.info('SessionService: Initializing session...');
59
+
60
+ // Get or create device ID
61
+ const deviceId = await this.getOrCreateDeviceId();
62
+
63
+ // Check for existing session token
64
+ const existingToken = await this.getSessionToken();
65
+
66
+ if (existingToken) {
67
+ // Validate existing device session
68
+ const validation = await this.validateDeviceSession(existingToken);
69
+ if (validation.valid && validation.session) {
70
+ this.session = validation.session;
71
+ this.sessionToken = existingToken;
72
+ this.startHeartbeat();
73
+ logger.info('DeviceSessionService: Restored existing device session');
74
+ return validation.session;
75
+ }
76
+ }
77
+
78
+ // Create new device session if no valid existing session
79
+ const newSession = await this.createDeviceSession(deviceId);
80
+ this.session = newSession.session;
81
+ this.sessionToken = newSession.sessionToken;
82
+ await this.setSessionToken(newSession.sessionToken);
83
+ this.startHeartbeat();
84
+
85
+ logger.info('SessionService: Created new session');
86
+ return newSession.session;
87
+
88
+ } catch (error) {
89
+ logger.error('SessionService: Failed to initialize session', { error });
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ async createDeviceSession(deviceId: string): Promise<CreateDeviceSessionResponse> {
95
+ try {
96
+ logger.info('DeviceSessionService: Creating new device session for device:', deviceId);
97
+
98
+ const response = await api.post<CreateDeviceSessionResponse>('/device-sessions', { deviceId });
99
+
100
+ logger.info('DeviceSessionService: Device session created successfully');
101
+ return response.data;
102
+
103
+ } catch (error) {
104
+ logger.error('SessionService: Failed to create session', { error });
105
+
106
+ if (axios.isAxiosError(error)) {
107
+ throw new Error(error.response?.data?.error?.message || 'Failed to create session');
108
+ }
109
+
110
+ throw new Error('Session creation failed');
111
+ }
112
+ }
113
+
114
+ async validateDeviceSession(sessionToken: string): Promise<DeviceSessionValidationResponse> {
115
+ try {
116
+ logger.debug('DeviceSessionService: Validating device session token...');
117
+
118
+ const response = await api.post<DeviceSessionValidationResponse>('/device-sessions/validate', { sessionToken });
119
+
120
+ logger.debug('DeviceSessionService: Device session validation complete');
121
+ return response.data;
122
+
123
+ } catch (error) {
124
+ logger.error('SessionService: Failed to validate session', { error });
125
+
126
+ // Return invalid for validation errors
127
+ return { valid: false };
128
+ }
129
+ }
130
+
131
+ async updateActivity(): Promise<void> {
132
+ try {
133
+ const token = await this.getSessionToken();
134
+ if (!token) {
135
+ logger.warn('SessionService: No session token for activity update');
136
+ return;
137
+ }
138
+
139
+ logger.debug('SessionService: Updating session activity...');
140
+
141
+ await api.put('/device-sessions/activity', { sessionToken: token });
142
+
143
+ logger.debug('SessionService: Session activity updated');
144
+
145
+ } catch (error) {
146
+ logger.error('SessionService: Failed to update session activity', { error });
147
+
148
+ // Don't throw for activity update errors
149
+ }
150
+ }
151
+
152
+ async getMigrationEligibility(): Promise<DeviceSessionMigrationEligibilityResponse> {
153
+ try {
154
+ const token = await this.getSessionToken();
155
+ if (!token) {
156
+ throw new Error('No device session token available');
157
+ }
158
+
159
+ logger.debug('DeviceSessionService: Checking migration eligibility...');
160
+
161
+ const response = await api.post<DeviceSessionMigrationEligibilityResponse>('/device-sessions/migration-eligibility', { sessionToken: token });
162
+
163
+ logger.debug('DeviceSessionService: Migration eligibility checked');
164
+ return response.data;
165
+
166
+ } catch (error) {
167
+ logger.error('SessionService: Failed to check migration eligibility', { error });
168
+
169
+ if (axios.isAxiosError(error)) {
170
+ throw new Error(error.response?.data?.error?.message || 'Failed to check migration eligibility');
171
+ }
172
+
173
+ throw new Error('Migration eligibility check failed');
174
+ }
175
+ }
176
+
177
+ async deleteSession(): Promise<void> {
178
+ try {
179
+ const token = await this.getSessionToken();
180
+ if (!token) {
181
+ logger.warn('SessionService: No session token to delete');
182
+ return;
183
+ }
184
+
185
+ logger.info('SessionService: Deleting session...');
186
+
187
+ try {
188
+ await api.delete('/device-sessions', { data: { sessionToken: token } });
189
+ } catch (error) {
190
+ logger.warn('SessionService: Session deletion API call failed, continuing with local cleanup', { error });
191
+ }
192
+
193
+ // Always clear local session data
194
+ await this.clearSession();
195
+ logger.info('SessionService: Session deleted successfully');
196
+
197
+ } catch (error) {
198
+ logger.error('SessionService: Error during session deletion', { error });
199
+ // Still try to clear local data on error
200
+ await this.clearSession();
201
+ }
202
+ }
203
+
204
+ // Device ID management
205
+ private async getOrCreateDeviceId(): Promise<string> {
206
+ try {
207
+ // Try to get existing device ID from secure storage
208
+ let deviceId = await SecureStore.getItemAsync(this.DEVICE_ID_KEY);
209
+
210
+ if (deviceId) {
211
+ this.deviceId = deviceId;
212
+ logger.debug('SessionService: Found existing device ID');
213
+ return deviceId;
214
+ }
215
+
216
+ // Generate new device ID
217
+ deviceId = await this.generateDeviceId();
218
+ await SecureStore.setItemAsync(this.DEVICE_ID_KEY, deviceId);
219
+ this.deviceId = deviceId;
220
+
221
+ logger.info('SessionService: Generated new device ID');
222
+ return deviceId;
223
+
224
+ } catch (error) {
225
+ logger.error('SessionService: Failed to get/create device ID', { error });
226
+
227
+ // Fallback to AsyncStorage if SecureStore fails
228
+ try {
229
+ let deviceId = await AsyncStorage.getItem(this.DEVICE_ID_KEY);
230
+
231
+ if (deviceId) {
232
+ this.deviceId = deviceId;
233
+ return deviceId;
234
+ }
235
+
236
+ deviceId = await this.generateDeviceId();
237
+ await AsyncStorage.setItem(this.DEVICE_ID_KEY, deviceId);
238
+ this.deviceId = deviceId;
239
+
240
+ return deviceId;
241
+ } catch (fallbackError) {
242
+ logger.error('SessionService: Fallback device ID generation failed', { fallbackError });
243
+ throw new Error('Failed to generate device ID');
244
+ }
245
+ }
246
+ }
247
+
248
+ private async generateDeviceId(): Promise<string> {
249
+ try {
250
+ // Create a unique device identifier
251
+ const installId = Application.applicationId || 'unknown';
252
+ const buildId = Application.nativeBuildVersion || '0';
253
+ const platform = Platform.OS;
254
+ const timestamp = Date.now().toString();
255
+ const randomBytes = Math.random().toString(36).substring(2, 15);
256
+
257
+ const deviceId = `${platform}-${installId.replace(/\./g, '_')}-${buildId}-${timestamp}-${randomBytes}`;
258
+
259
+ logger.debug('SessionService: Generated device ID pattern:', deviceId.substring(0, 20) + '...');
260
+ return deviceId;
261
+
262
+ } catch (error) {
263
+ logger.error('SessionService: Failed to generate device ID', { error });
264
+
265
+ // Fallback to simple random ID
266
+ const fallbackId = `${Platform.OS}-${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
267
+ logger.warn('SessionService: Using fallback device ID');
268
+ return fallbackId;
269
+ }
270
+ }
271
+
272
+ // Session token management
273
+ public async getSessionToken(): Promise<string | null> {
274
+ if (this.sessionToken) {
275
+ return this.sessionToken;
276
+ }
277
+ try {
278
+ const token = await AsyncStorage.getItem(this.SESSION_TOKEN_KEY);
279
+ this.sessionToken = token;
280
+ return token;
281
+ } catch (error) {
282
+ logger.error('SessionService: Failed to get session token from storage', { error });
283
+ return null;
284
+ }
285
+ }
286
+
287
+ private async setSessionToken(token: string): Promise<void> {
288
+ try {
289
+ logger.debug('SessionService: Setting session token...');
290
+ await AsyncStorage.setItem(this.SESSION_TOKEN_KEY, token);
291
+ this.sessionToken = token;
292
+ logger.debug('SessionService: Session token set successfully');
293
+ } catch (error) {
294
+ logger.error('SessionService: Failed to save session token to storage', { error });
295
+ throw new Error('Failed to save session token');
296
+ }
297
+ }
298
+
299
+ private async removeSessionToken(): Promise<void> {
300
+ try {
301
+ logger.debug('SessionService: Removing session token...');
302
+ await AsyncStorage.removeItem(this.SESSION_TOKEN_KEY);
303
+ this.sessionToken = null;
304
+ logger.debug('SessionService: Session token removed successfully');
305
+ } catch (error) {
306
+ logger.error('SessionService: Failed to remove session token from storage', { error });
307
+ throw new Error('Failed to remove session token');
308
+ }
309
+ }
310
+
311
+ async clearSession(): Promise<void> {
312
+ try {
313
+ logger.info('SessionService: Clearing session...');
314
+ this.stopHeartbeat();
315
+ await this.removeSessionToken();
316
+ this.session = null;
317
+ logger.info('SessionService: Session cleared successfully');
318
+ } catch (error) {
319
+ logger.error('SessionService: Failed to clear session', { error });
320
+ throw new Error('Failed to clear session');
321
+ }
322
+ }
323
+
324
+ // Device session state getters
325
+ getCurrentDeviceSession(): DeviceSession | null {
326
+ return this.session;
327
+ }
328
+
329
+ async isDeviceSessionValid(): Promise<boolean> {
330
+ try {
331
+ const token = await this.getSessionToken();
332
+ if (!token) {
333
+ return false;
334
+ }
335
+
336
+ const validation = await this.validateDeviceSession(token);
337
+ return validation.valid;
338
+ } catch (error) {
339
+ logger.error('DeviceSessionService: Error checking device session validity:', error);
340
+ return false;
341
+ }
342
+ }
343
+
344
+ getDeviceId(): string | null {
345
+ return this.deviceId;
346
+ }
347
+
348
+ // Heartbeat management
349
+ private startHeartbeat(): void {
350
+ // Clear any existing heartbeat
351
+ this.stopHeartbeat();
352
+
353
+ // Update activity every 5 minutes
354
+ this.heartbeatInterval = setInterval(() => {
355
+ this.updateActivity();
356
+ }, 5 * 60 * 1000);
357
+
358
+ logger.debug('SessionService: Heartbeat started');
359
+ }
360
+
361
+ private stopHeartbeat(): void {
362
+ if (this.heartbeatInterval) {
363
+ clearInterval(this.heartbeatInterval);
364
+ this.heartbeatInterval = null;
365
+ logger.debug('SessionService: Heartbeat stopped');
366
+ }
367
+ }
368
+ }
369
+
370
+ export const deviceSessionService = DeviceSessionService.getInstance();
@@ -0,0 +1,326 @@
1
+ import { create } from 'zustand';
2
+ import { persist, createJSONStorage } from 'zustand/middleware';
3
+ import AsyncStorage from '@react-native-async-storage/async-storage';
4
+ import { deviceSessionService, DeviceSession, DeviceSessionMigrationEligibilityResponse } from '../services/deviceSession';
5
+ import { logger } from '../utils/logger';
6
+
7
+ export interface MigrateSessionData {
8
+ name: string;
9
+ email: string;
10
+ password: string;
11
+ passwordConfirmation: string;
12
+ }
13
+
14
+ export interface SessionMigrationResponse {
15
+ user: {
16
+ id: string;
17
+ email: string;
18
+ name: string;
19
+ preferredCurrency: string;
20
+ createdAt: string;
21
+ updatedAt: string;
22
+ };
23
+ token: string;
24
+ }
25
+
26
+ interface SessionState {
27
+ // State
28
+ session: DeviceSession | null;
29
+ sessionToken: string | null;
30
+ deviceId: string | null;
31
+ isLoading: boolean;
32
+ isSessionChecked: boolean;
33
+ error: string | null;
34
+
35
+ // Actions
36
+ setSession: (session: DeviceSession | null) => void;
37
+ setSessionToken: (token: string | null) => void;
38
+ setDeviceId: (deviceId: string | null) => void;
39
+ setLoading: (loading: boolean) => void;
40
+ setError: (error: string | null) => void;
41
+ setSessionChecked: (checked: boolean) => void;
42
+ clearSession: () => void;
43
+ clearError: () => void;
44
+
45
+ // Async actions
46
+ initializeSession: () => Promise<void>;
47
+ deleteSession: () => Promise<void>;
48
+ isSessionValid: () => Promise<boolean>;
49
+ getMigrationEligibility: () => Promise<DeviceSessionMigrationEligibilityResponse>;
50
+ migrateSession: (data: MigrateSessionData) => Promise<SessionMigrationResponse>;
51
+ refreshSession: () => Promise<void>;
52
+
53
+ // Computed values
54
+ hasValidSession: boolean;
55
+ }
56
+
57
+ export const useSessionStore = create<SessionState>()(
58
+ (set, get) => ({
59
+ // Initial state
60
+ session: null,
61
+ sessionToken: null,
62
+ deviceId: null,
63
+ isLoading: false,
64
+ isSessionChecked: false,
65
+ error: null,
66
+
67
+ // Actions
68
+ setSession: (session) => set({ session }),
69
+ setSessionToken: (sessionToken) => set({ sessionToken }),
70
+ setDeviceId: (deviceId) => set({ deviceId }),
71
+ setLoading: (isLoading) => set({ isLoading }),
72
+ setError: (error) => set({ error }),
73
+ setSessionChecked: (isSessionChecked) => set({ isSessionChecked }),
74
+
75
+ clearSession: () => set({
76
+ session: null,
77
+ sessionToken: null,
78
+ deviceId: null,
79
+ error: null,
80
+ isLoading: false
81
+ }),
82
+
83
+ clearError: () => set({ error: null }),
84
+
85
+ // Async actions
86
+ initializeSession: async () => {
87
+ try {
88
+ logger.debug('SessionStore: Initializing session...');
89
+ set({ isLoading: true, error: null });
90
+
91
+ const sessionData = await deviceSessionService.initialize();
92
+
93
+ if (sessionData) {
94
+ set({
95
+ session: sessionData,
96
+ sessionToken: await deviceSessionService.getSessionToken(),
97
+ deviceId: deviceSessionService.getDeviceId(),
98
+ });
99
+ logger.info('SessionStore: Session initialized successfully', {
100
+ sessionId: sessionData.id,
101
+ deviceId: sessionData.deviceId
102
+ });
103
+ } else {
104
+ logger.warn('SessionStore: No session data returned from initialize');
105
+ set({ session: null, sessionToken: null });
106
+ }
107
+
108
+ } catch (error) {
109
+ logger.error('SessionStore: Failed to initialize session', { error });
110
+
111
+ const errorMessage = error instanceof Error ? error.message : 'Session initialization failed';
112
+ set({ error: errorMessage, session: null, sessionToken: null });
113
+
114
+ } finally {
115
+ set({ isLoading: false, isSessionChecked: true });
116
+ }
117
+ },
118
+
119
+ deleteSession: async () => {
120
+ try {
121
+ logger.info('SessionStore: Deleting session...');
122
+ set({ isLoading: true, error: null });
123
+
124
+ // Clear onboarding flag to restart onboarding flow
125
+ try {
126
+ await AsyncStorage.removeItem('onboarding_completed');
127
+ logger.info('Onboarding flag cleared after session deletion');
128
+ } catch (error) {
129
+ logger.error('Failed to clear onboarding flag', { error });
130
+ // Don't throw - not critical
131
+ }
132
+
133
+ await deviceSessionService.deleteSession();
134
+ set({ session: null, sessionToken: null, deviceId: null });
135
+
136
+ logger.info('SessionStore: Session deleted successfully');
137
+
138
+ } catch (error) {
139
+ // Still clear onboarding flag even on error
140
+ try {
141
+ await AsyncStorage.removeItem('onboarding_completed');
142
+ } catch (e) {
143
+ logger.error('Failed to clear onboarding flag in error handler', { e });
144
+ }
145
+
146
+ logger.error('SessionStore: Failed to delete session', { error });
147
+
148
+ const errorMessage = error instanceof Error ? error.message : 'Session deletion failed';
149
+ set({ error: errorMessage });
150
+
151
+ throw error;
152
+
153
+ } finally {
154
+ set({ isLoading: false });
155
+ }
156
+ },
157
+
158
+ isSessionValid: async () => {
159
+ try {
160
+ logger.debug('SessionStore: Checking session validity...');
161
+
162
+ const isValid = await deviceSessionService.isDeviceSessionValid();
163
+
164
+ if (!isValid && get().session) {
165
+ // Session became invalid, clear it
166
+ logger.warn('SessionStore: Session is no longer valid, clearing');
167
+ set({ session: null, sessionToken: null, error: 'Session expired' });
168
+ }
169
+
170
+ return isValid;
171
+
172
+ } catch (error) {
173
+ logger.error('SessionStore: Error checking session validity', { error });
174
+ return false;
175
+ }
176
+ },
177
+
178
+ getMigrationEligibility: async () => {
179
+ try {
180
+ logger.debug('SessionStore: Checking migration eligibility...');
181
+ set({ error: null });
182
+
183
+ const eligibility = await deviceSessionService.getMigrationEligibility();
184
+
185
+ logger.debug('SessionStore: Migration eligibility checked', {
186
+ canMigrate: eligibility.canMigrate
187
+ });
188
+
189
+ return eligibility;
190
+
191
+ } catch (error) {
192
+ logger.error('SessionStore: Failed to check migration eligibility', { error });
193
+
194
+ const errorMessage = error instanceof Error ? error.message : 'Migration eligibility check failed';
195
+ set({ error: errorMessage });
196
+
197
+ throw error;
198
+ }
199
+ },
200
+
201
+ migrateSession: async (data: MigrateSessionData): Promise<SessionMigrationResponse> => {
202
+ try {
203
+ logger.info('SessionStore: Migrating session to user account...');
204
+ set({ isLoading: true, error: null });
205
+
206
+ const sessionToken = get().sessionToken;
207
+ if (!sessionToken) {
208
+ throw new Error('No session token available for migration');
209
+ }
210
+
211
+ // Call the migration endpoint
212
+ const migrationData = {
213
+ sessionToken,
214
+ ...data,
215
+ };
216
+
217
+ // This would be a direct API call since migration is a one-time operation
218
+ const response = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/sessions/migrate`, {
219
+ method: 'POST',
220
+ headers: {
221
+ 'Content-Type': 'application/json',
222
+ },
223
+ body: JSON.stringify(migrationData),
224
+ });
225
+
226
+ if (!response.ok) {
227
+ const errorData = await response.json() as { error?: { message?: string } };
228
+ throw new Error(errorData.error?.message || 'Migration failed');
229
+ }
230
+
231
+ const result = await response.json() as SessionMigrationResponse;
232
+
233
+ // Clear session data after successful migration
234
+ set({ session: null, sessionToken: null, deviceId: null });
235
+
236
+ logger.info('SessionStore: Session migrated successfully', {
237
+ userId: result.user.id,
238
+ });
239
+
240
+ return result;
241
+
242
+ } catch (error) {
243
+ logger.error('SessionStore: Failed to migrate session', { error });
244
+
245
+ const errorMessage = error instanceof Error ? error.message : 'Session migration failed';
246
+ set({ error: errorMessage });
247
+
248
+ throw error;
249
+
250
+ } finally {
251
+ set({ isLoading: false });
252
+ }
253
+ },
254
+
255
+ refreshSession: async () => {
256
+ try {
257
+ logger.info('SessionStore: Refreshing session...');
258
+ set({ error: null });
259
+
260
+ // Check if current session is still valid
261
+ const currentSession = deviceSessionService.getCurrentDeviceSession();
262
+ if (currentSession) {
263
+ const isValid = await deviceSessionService.isDeviceSessionValid();
264
+
265
+ if (isValid) {
266
+ // Session is still valid, just update the local state
267
+ set({ session: currentSession });
268
+ logger.debug('SessionStore: Session is still valid');
269
+ return;
270
+ }
271
+ }
272
+
273
+ // Session is invalid or doesn't exist, initialize a new one
274
+ await get().initializeSession();
275
+
276
+ } catch (error) {
277
+ logger.error('SessionStore: Failed to refresh session', { error });
278
+
279
+ const errorMessage = error instanceof Error ? error.message : 'Session refresh failed';
280
+ set({ error: errorMessage });
281
+
282
+ throw error;
283
+ }
284
+ },
285
+
286
+ // Computed values
287
+ get hasValidSession() {
288
+ const state = get();
289
+ return !!(state.session && state.sessionToken);
290
+ },
291
+ })
292
+ );
293
+
294
+ // Selectors for commonly used combinations
295
+ export const useSession = () => {
296
+ const state = useSessionStore();
297
+ return {
298
+ session: state.session,
299
+ sessionToken: state.sessionToken,
300
+ deviceId: state.deviceId,
301
+ isLoading: state.isLoading,
302
+ isSessionChecked: state.isSessionChecked,
303
+ error: state.error,
304
+ hasValidSession: state.hasValidSession,
305
+ clearError: state.clearError,
306
+ };
307
+ };
308
+
309
+ export const useSessionActions = () => {
310
+ const state = useSessionStore();
311
+ return {
312
+ setSession: state.setSession,
313
+ setSessionToken: state.setSessionToken,
314
+ setDeviceId: state.setDeviceId,
315
+ setLoading: state.setLoading,
316
+ setError: state.setError,
317
+ setSessionChecked: state.setSessionChecked,
318
+ clearSession: state.clearSession,
319
+ initializeSession: state.initializeSession,
320
+ deleteSession: state.deleteSession,
321
+ isSessionValid: state.isSessionValid,
322
+ getMigrationEligibility: state.getMigrationEligibility,
323
+ migrateSession: state.migrateSession,
324
+ refreshSession: state.refreshSession,
325
+ };
326
+ };
@@ -0,0 +1,11 @@
1
+ import { Stack } from 'expo-router';
2
+
3
+ export default function OnboardingLayout() {
4
+ return (
5
+ <Stack screenOptions={{ headerShown: false }}>
6
+ <% for (let i = 1; i <= features.onboarding.pages && i <= 3; i++) { %>
7
+ <Stack.Screen name="page-<%= i %>" />
8
+ <% } %>
9
+ </Stack>
10
+ );
11
+ }