kasy-cli 1.32.0 → 1.35.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 (169) hide show
  1. package/README.md +1 -1
  2. package/bin/kasy.js +66 -2
  3. package/docs/cli-reference.md +7 -7
  4. package/lib/commands/apple-web.js +222 -0
  5. package/lib/commands/configure.js +3 -91
  6. package/lib/commands/doctor.js +20 -0
  7. package/lib/commands/facebook.js +189 -0
  8. package/lib/commands/new.js +61 -11
  9. package/lib/commands/release-version.js +234 -0
  10. package/lib/commands/update.js +27 -0
  11. package/lib/scaffold/CHANGELOG.json +27 -0
  12. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +24 -0
  13. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api_interface.dart +15 -0
  14. package/lib/scaffold/backends/api/patch/lib/features/authentication/repositories/authentication_repository.dart +27 -0
  15. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
  16. package/lib/scaffold/backends/firebase/setup-from-scratch.js +199 -21
  17. package/lib/scaffold/backends/patch-base-hashes.json +66 -0
  18. package/lib/scaffold/backends/supabase/deploy.js +92 -0
  19. package/lib/scaffold/backends/supabase/edge-functions/stripe-webhook/index.ts +2 -0
  20. package/lib/scaffold/backends/supabase/migrations/20240101000007_welcome_notification.sql +36 -23
  21. package/lib/scaffold/backends/supabase/migrations/20240101000014_subscription_trial_end.sql +6 -0
  22. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +92 -3
  23. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/repositories/authentication_repository.dart +36 -0
  24. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/entities/subscription_entity.dart +1 -0
  25. package/lib/scaffold/generate.js +53 -4
  26. package/lib/scaffold/shared/generator-utils.js +18 -6
  27. package/lib/utils/apple-web.js +147 -0
  28. package/lib/utils/facebook.js +162 -0
  29. package/lib/utils/i18n/messages-en.js +85 -0
  30. package/lib/utils/i18n/messages-es.js +85 -0
  31. package/lib/utils/i18n/messages-pt.js +85 -0
  32. package/package.json +5 -2
  33. package/templates/firebase/.firebase/hosting.YnVpbGQvd2Vi.cache +73 -0
  34. package/templates/firebase/AGENTS.md +170 -0
  35. package/templates/firebase/CLAUDE.md +16 -0
  36. package/templates/firebase/DESIGN_SYSTEM.md +269 -0
  37. package/templates/firebase/docs/auth-setup.en.md +4 -2
  38. package/templates/firebase/docs/auth-setup.es.md +4 -2
  39. package/templates/firebase/docs/auth-setup.pt.md +4 -2
  40. package/templates/firebase/firebase.json +56 -1
  41. package/templates/firebase/functions/src/authentication/triggers.ts +10 -6
  42. package/templates/firebase/functions/src/core/data/entities/subscription_entity.ts +5 -0
  43. package/templates/firebase/functions/src/core/data/entities/user_entity.ts +9 -1
  44. package/templates/firebase/functions/src/core/data/repositories/user_repository.ts +27 -0
  45. package/templates/firebase/functions/src/subscriptions/models/subscriptions.ts +3 -0
  46. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +4 -0
  47. package/templates/firebase/lib/components/components.dart +1 -0
  48. package/templates/firebase/lib/components/kasy_alert.dart +0 -1
  49. package/templates/firebase/lib/components/kasy_app_bar.dart +35 -17
  50. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +0 -1
  51. package/templates/firebase/lib/components/kasy_date_picker.dart +0 -5
  52. package/templates/firebase/lib/components/kasy_dialog.dart +0 -1
  53. package/templates/firebase/lib/components/kasy_otp_verification_bottom_sheet.dart +1 -1
  54. package/templates/firebase/lib/components/kasy_screen.dart +114 -0
  55. package/templates/firebase/lib/components/kasy_sidebar.dart +189 -120
  56. package/templates/firebase/lib/components/kasy_text_area.dart +0 -1
  57. package/templates/firebase/lib/components/kasy_text_field.dart +0 -1
  58. package/templates/firebase/lib/components/kasy_text_field_otp.dart +0 -1
  59. package/templates/firebase/lib/components/kasy_toast.dart +108 -73
  60. package/templates/firebase/lib/core/app_update/app_update_repository.dart +54 -0
  61. package/templates/firebase/lib/core/app_update/app_update_status.dart +14 -0
  62. package/templates/firebase/lib/core/app_update/update_available_sheet.dart +70 -0
  63. package/templates/firebase/lib/core/bottom_menu/active_tab_notifier.dart +40 -3
  64. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +82 -34
  65. package/templates/firebase/lib/core/bottom_menu/web_content_wrapper.dart +6 -6
  66. package/templates/firebase/lib/core/bottom_menu/web_url.dart +20 -0
  67. package/templates/firebase/lib/core/chrome/chrome_visibility.dart +22 -0
  68. package/templates/firebase/lib/core/config/features.dart +5 -0
  69. package/templates/firebase/lib/core/data/api/remote_config_api.dart +38 -1
  70. package/templates/firebase/lib/core/guards/guard.dart +16 -2
  71. package/templates/firebase/lib/core/icons/kasy_icons.dart +3 -0
  72. package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +2 -5
  73. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +48 -124
  74. package/templates/firebase/lib/core/shared_preferences/shared_preferences.dart +14 -0
  75. package/templates/firebase/lib/core/states/components/maybe_show_update_available.dart +32 -0
  76. package/templates/firebase/lib/core/states/logout_action.dart +5 -1
  77. package/templates/firebase/lib/core/states/user_state_notifier.dart +85 -14
  78. package/templates/firebase/lib/core/theme/responsive_text_theme.dart +69 -0
  79. package/templates/firebase/lib/core/theme/texts.dart +90 -57
  80. package/templates/firebase/lib/core/theme/type_scale.dart +77 -0
  81. package/templates/firebase/lib/core/theme/web_background_sync_web.dart +20 -6
  82. package/templates/firebase/lib/core/utils/image_bytes_loader.dart +12 -0
  83. package/templates/firebase/lib/core/utils/image_bytes_loader_io.dart +28 -0
  84. package/templates/firebase/lib/core/utils/image_bytes_loader_web.dart +56 -0
  85. package/templates/firebase/lib/core/web_screen_width.dart +15 -0
  86. package/templates/firebase/lib/core/web_screen_width_io.dart +3 -0
  87. package/templates/firebase/lib/core/web_screen_width_web.dart +10 -0
  88. package/templates/firebase/lib/core/web_viewport_scale.dart +61 -25
  89. package/templates/firebase/lib/core/widgets/focus_visibility.dart +89 -0
  90. package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +29 -126
  91. package/templates/firebase/lib/features/ai_chat/ai_chat_page.dart +11 -8
  92. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_composer.dart +21 -0
  93. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_chat_conversation_view.dart +1 -2
  94. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_list.dart +2 -3
  95. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -2
  96. package/templates/firebase/lib/features/authentication/api/auth_web_support.dart +12 -0
  97. package/templates/firebase/lib/features/authentication/api/auth_web_support_web.dart +25 -0
  98. package/templates/firebase/lib/features/authentication/api/authentication_api.dart +266 -0
  99. package/templates/firebase/lib/features/authentication/api/authentication_api_interface.dart +22 -0
  100. package/templates/firebase/lib/features/authentication/navigation/post_login_navigation.dart +32 -0
  101. package/templates/firebase/lib/features/authentication/providers/phone_auth_notifier.dart +7 -7
  102. package/templates/firebase/lib/features/authentication/providers/signin_state_provider.dart +34 -10
  103. package/templates/firebase/lib/features/authentication/providers/signup_state_provider.dart +2 -2
  104. package/templates/firebase/lib/features/authentication/repositories/authentication_repository.dart +37 -0
  105. package/templates/firebase/lib/features/authentication/repositories/exceptions/authentication_exceptions.dart +8 -0
  106. package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +2 -2
  107. package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -2
  108. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +80 -15
  109. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +20 -14
  110. package/templates/firebase/lib/features/authentication/ui/widgets/auth_card_scaffold.dart +2 -2
  111. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +2 -2
  112. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +0 -1
  113. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -2
  114. package/templates/firebase/lib/features/home/design_system_page.dart +134 -67
  115. package/templates/firebase/lib/features/home/home_components_page.dart +8 -1
  116. package/templates/firebase/lib/features/home/home_components_preview_page.dart +1 -3
  117. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +186 -56
  118. package/templates/firebase/lib/features/home/home_page.dart +4 -0
  119. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +169 -208
  120. package/templates/firebase/lib/features/notifications/providers/notifications_provider.dart +10 -0
  121. package/templates/firebase/lib/features/notifications/ui/components/notification_settings_sheet.dart +2 -2
  122. package/templates/firebase/lib/features/notifications/ui/components/notification_tile.dart +21 -8
  123. package/templates/firebase/lib/features/notifications/ui/components/push_notification_switcher.dart +2 -2
  124. package/templates/firebase/lib/features/notifications/ui/notifications_page.dart +3 -4
  125. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +84 -128
  126. package/templates/firebase/lib/features/onboarding/providers/onboarding_model.dart +11 -0
  127. package/templates/firebase/lib/features/onboarding/providers/onboarding_provider.dart +30 -3
  128. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +13 -4
  129. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_illustration_scaffold.dart +3 -4
  130. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +2 -4
  131. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +2 -1
  132. package/templates/firebase/lib/features/settings/settings_page.dart +152 -11
  133. package/templates/firebase/lib/features/settings/ui/components/admin/admin_page.dart +58 -21
  134. package/templates/firebase/lib/features/settings/ui/components/admin/admin_users_tab.dart +12 -12
  135. package/templates/firebase/lib/features/settings/ui/components/admin/send_push_notification_page.dart +1 -4
  136. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
  137. package/templates/firebase/lib/features/settings/ui/components/create_password_sheet.dart +141 -0
  138. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +0 -1
  139. package/templates/firebase/lib/features/settings/ui/widgets/admin_card.dart +42 -38
  140. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +17 -2
  141. package/templates/firebase/lib/features/subscriptions/api/entities/subscription_entity.dart +3 -0
  142. package/templates/firebase/lib/features/subscriptions/api/stripe_product.dart +12 -11
  143. package/templates/firebase/lib/features/subscriptions/repositories/subscription_repository.dart +25 -2
  144. package/templates/firebase/lib/features/subscriptions/ui/component/active_premium_content.dart +319 -143
  145. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_minimal.dart +1 -5
  146. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -5
  147. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_with_switch.dart +2 -5
  148. package/templates/firebase/lib/features/subscriptions/ui/component/premium_content.dart +1 -4
  149. package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +1 -2
  150. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_banner.dart +2 -7
  151. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +2 -4
  152. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +1 -4
  153. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +0 -3
  154. package/templates/firebase/lib/i18n/en.i18n.json +54 -7
  155. package/templates/firebase/lib/i18n/es.i18n.json +54 -7
  156. package/templates/firebase/lib/i18n/pt.i18n.json +54 -7
  157. package/templates/firebase/lib/main.dart +11 -2
  158. package/templates/firebase/lib/router.dart +94 -13
  159. package/templates/firebase/pubspec.yaml +1 -1
  160. package/templates/firebase/test/core/data/api/fake_remote_config_api.dart +14 -0
  161. package/templates/firebase/test/core/states/user_state_notifier_test.dart +47 -3
  162. package/templates/firebase/test/core/web_viewport_scale_test.dart +68 -0
  163. package/templates/firebase/test/features/authentication/data/api/auth_api_fake.dart +15 -0
  164. package/templates/firebase/tool/design_check.dart +152 -0
  165. package/templates/firebase/web/index.html +162 -14
  166. package/templates/firebase/assets/images/review.png +0 -0
  167. package/templates/firebase/assets/images/update.png +0 -0
  168. package/templates/firebase/lib/core/guards/user_info_guard.dart +0 -61
  169. package/templates/firebase/lib/features/notifications/ui/components/notifications_header.dart +0 -32
@@ -73,6 +73,21 @@ abstract class AuthenticationApi implements OnStartService {
73
73
 
74
74
  /// Returns the display name of the current user, or null.
75
75
  Future<String?> getCurrentUserDisplayName();
76
+
77
+ /// Returns the photo URL of the current user, or null.
78
+ Future<String?> getCurrentUserPhotoUrl();
79
+
80
+ /// Returns all sign-in providers linked to the current account.
81
+ Future<List<String>> getLinkedProviders();
82
+
83
+ /// Sets or updates an email/password credential for the current user.
84
+ Future<void> setPassword(String password);
85
+
86
+ /// Social providers the current user can still link to their account.
87
+ Future<List<String>> linkableSocialProviders();
88
+
89
+ /// Links a social provider to the current account.
90
+ Future<void> linkSocialProvider(String provider);
76
91
  }
77
92
 
78
93
  class PhoneAlreadyLinkedException implements Exception {
@@ -99,6 +99,18 @@ abstract class AuthenticationRepository {
99
99
 
100
100
  /// Returns the display name of the current user, or null.
101
101
  Future<String?> getCurrentUserDisplayName();
102
+
103
+ /// Returns the photo URL of the current user, or null.
104
+ Future<String?> getCurrentUserPhotoUrl();
105
+
106
+ /// Returns the sign-in provider used ('google'|'apple'|'facebook'|'email'|'phone'), or null.
107
+ Future<List<String>> getLinkedProviders();
108
+
109
+ Future<void> setPassword(String password);
110
+
111
+ Future<List<String>> linkableSocialProviders();
112
+
113
+ Future<void> linkSocialProvider(String provider);
102
114
  }
103
115
 
104
116
  /// this is an example on how to create an authentication repository using firebase
@@ -403,4 +415,19 @@ class HttpAuthenticationRepository implements AuthenticationRepository {
403
415
 
404
416
  @override
405
417
  Future<String?> getCurrentUserDisplayName() => _authenticationApi.getCurrentUserDisplayName();
418
+
419
+ @override
420
+ Future<String?> getCurrentUserPhotoUrl() => _authenticationApi.getCurrentUserPhotoUrl();
421
+
422
+ @override
423
+ Future<List<String>> getLinkedProviders() => _authenticationApi.getLinkedProviders();
424
+
425
+ @override
426
+ Future<void> setPassword(String password) => _authenticationApi.setPassword(password);
427
+
428
+ @override
429
+ Future<List<String>> linkableSocialProviders() => _authenticationApi.linkableSocialProviders();
430
+
431
+ @override
432
+ Future<void> linkSocialProvider(String provider) => _authenticationApi.linkSocialProvider(provider);
406
433
  }
@@ -36,6 +36,7 @@ sealed class SubscriptionEntity with _$SubscriptionEntity {
36
36
  @JsonKey(name: 'creation_date') DateTime? creationDate,
37
37
  @JsonKey(name: 'last_update_date') DateTime? lastUpdateDate,
38
38
  @JsonKey(name: 'period_end_date') DateTime? periodEndDate,
39
+ @JsonKey(name: 'trial_end') DateTime? trialEnd,
39
40
  @JsonKey(name: 'status') required SubscriptionStatus status,
40
41
  @JsonKey(name: 'store', unknownEnumValue: SubscriptionStore.unknown)
41
42
  SubscriptionStore? store,
@@ -710,37 +710,51 @@ async function checkBillingEnabled(projectId) {
710
710
  * with only [localhost] would wipe firebaseapp.com / web.app. Idempotent: it's a
711
711
  * no-op when both entries are already present.
712
712
  *
713
+ * Right after a project's auth config is initialized, the Admin v2 config endpoint
714
+ * can briefly answer 403/404/409 before it settles (and a PATCH can 409 on a
715
+ * concurrent edit). Those are retried — re-reading each round so the merge stays
716
+ * correct; 400 and other client errors are treated as fatal. Best-effort: returns
717
+ * { ok: false } after exhausting retries instead of throwing.
718
+ *
719
+ * @param {string} projectId
720
+ * @param {string} token
721
+ * @param {{ maxRetries?: number, retryDelayMs?: number }} [opts]
713
722
  * @returns {{ ok: boolean, added?: string[], error?: string }}
714
723
  */
715
- async function ensureLocalhostAuthorizedDomains(projectId, token) {
724
+ async function ensureLocalhostAuthorizedDomains(projectId, token, { maxRetries = 4, retryDelayMs = 4000 } = {}) {
716
725
  const base = `https://identitytoolkit.googleapis.com/admin/v2/projects/${projectId}/config`;
717
726
  const headers = {
718
727
  Authorization: `Bearer ${token}`,
719
728
  'Content-Type': 'application/json',
720
729
  'X-Goog-User-Project': projectId,
721
730
  };
722
- // 1. Read the current authorized domains.
723
- const getRes = await fetch(base, { headers });
724
- if (!getRes.ok) {
725
- const text = await getRes.text();
726
- return { ok: false, error: `${getRes.status}: ${text}` };
727
- }
728
- const config = await getRes.json();
729
- const current = Array.isArray(config.authorizedDomains) ? config.authorizedDomains : [];
730
731
  const required = ['localhost', '127.0.0.1'];
731
- const missing = required.filter((d) => !current.includes(d));
732
- if (missing.length === 0) return { ok: true, added: [] };
733
- // 2. Merge and write back, keeping every domain that was already there.
734
- const patchRes = await fetch(`${base}?updateMask=authorizedDomains`, {
735
- method: 'PATCH',
736
- headers,
737
- body: JSON.stringify({ authorizedDomains: [...current, ...missing] }),
738
- });
739
- if (!patchRes.ok) {
740
- const text = await patchRes.text();
741
- return { ok: false, error: `${patchRes.status}: ${text}` };
732
+ const TRANSIENT = new Set([403, 404, 409, 429, 500, 503]);
733
+ let lastError = 'unknown';
734
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
735
+ // 1. Read the current authorized domains.
736
+ const getRes = await fetch(base, { headers });
737
+ if (!getRes.ok) {
738
+ lastError = `GET ${getRes.status}: ${await getRes.text()}`;
739
+ if (attempt < maxRetries && TRANSIENT.has(getRes.status)) { await sleep(retryDelayMs); continue; }
740
+ return { ok: false, error: lastError };
741
+ }
742
+ const config = await getRes.json();
743
+ const current = Array.isArray(config.authorizedDomains) ? config.authorizedDomains : [];
744
+ const missing = required.filter((d) => !current.includes(d));
745
+ if (missing.length === 0) return { ok: true, added: [] };
746
+ // 2. Merge and write back, keeping every domain that was already there.
747
+ const patchRes = await fetch(`${base}?updateMask=authorizedDomains`, {
748
+ method: 'PATCH',
749
+ headers,
750
+ body: JSON.stringify({ authorizedDomains: [...current, ...missing] }),
751
+ });
752
+ if (patchRes.ok) return { ok: true, added: missing };
753
+ lastError = `PATCH ${patchRes.status}: ${await patchRes.text()}`;
754
+ if (attempt < maxRetries && TRANSIENT.has(patchRes.status)) { await sleep(retryDelayMs); continue; }
755
+ return { ok: false, error: lastError };
742
756
  }
743
- return { ok: true, added: missing };
757
+ return { ok: false, error: lastError };
744
758
  }
745
759
 
746
760
  /**
@@ -764,6 +778,168 @@ async function authorizeLocalhostForProject(projectId) {
764
778
  return ensureLocalhostAuthorizedDomains(projectId, token);
765
779
  }
766
780
 
781
+ /**
782
+ * Configure Apple Sign-In on Firebase for the WEB (and the OAuth code flow) by
783
+ * writing the Apple provider's codeFlowConfig (Service ID + Team ID + Key ID +
784
+ * `.p8`) via the Identity Toolkit Admin v2 API. Once stored, Firebase re-signs the
785
+ * short-lived client secret itself, so it never expires.
786
+ *
787
+ * Existing bundleIds (used by the native iOS flow) are preserved and the project's
788
+ * own bundleId is merged in, so configuring web never breaks native.
789
+ *
790
+ * @param {object} opts
791
+ * @param {string} opts.projectId
792
+ * @param {string} opts.serviceId - Apple Service ID (becomes the provider clientId)
793
+ * @param {string} opts.teamId
794
+ * @param {string} opts.keyId
795
+ * @param {string} opts.privateKey - PEM contents of the .p8
796
+ * @param {string} [opts.bundleId] - app bundle id to keep allowed for native sign-in
797
+ * @returns {{ ok: boolean, error?: string }}
798
+ */
799
+ async function configureFirebaseAppleWeb({ projectId, serviceId, teamId, keyId, privateKey, bundleId }) {
800
+ if (!serviceId || !teamId || !keyId || !privateKey) {
801
+ return { ok: false, error: 'serviceId, teamId, keyId and privateKey are required' };
802
+ }
803
+ let token;
804
+ try {
805
+ token = await getAccessToken();
806
+ } catch (_) {
807
+ return { ok: false, error: 'Could not get access token' };
808
+ }
809
+
810
+ const headers = {
811
+ Authorization: `Bearer ${token}`,
812
+ 'Content-Type': 'application/json',
813
+ 'X-Goog-User-Project': projectId,
814
+ };
815
+ const base = `https://identitytoolkit.googleapis.com/admin/v2/projects/${projectId}/defaultSupportedIdpConfigs`;
816
+
817
+ // Read the current Apple config (if any) so we preserve the native bundleIds.
818
+ let existing = null;
819
+ try {
820
+ const getRes = await fetch(`${base}/apple.com`, { headers });
821
+ if (getRes.ok) existing = await getRes.json();
822
+ } catch (_) {
823
+ // No existing config (or transient) — we'll create it below.
824
+ }
825
+
826
+ const bundleIds = Array.from(
827
+ new Set([...(existing?.appleSignInConfig?.bundleIds || []), bundleId].filter(Boolean)),
828
+ );
829
+
830
+ const body = {
831
+ name: `projects/${projectId}/defaultSupportedIdpConfigs/apple.com`,
832
+ enabled: true,
833
+ clientId: serviceId,
834
+ appleSignInConfig: {
835
+ codeFlowConfig: { teamId, keyId, privateKey },
836
+ ...(bundleIds.length ? { bundleIds } : {}),
837
+ },
838
+ };
839
+
840
+ try {
841
+ if (existing) {
842
+ const res = await fetch(`${base}/apple.com?updateMask=enabled,clientId,appleSignInConfig`, {
843
+ method: 'PATCH',
844
+ headers,
845
+ body: JSON.stringify(body),
846
+ });
847
+ if (!res.ok) {
848
+ const text = await res.text();
849
+ return { ok: false, error: `PATCH failed (${res.status}): ${text.slice(0, 200)}` };
850
+ }
851
+ } else {
852
+ const res = await fetch(`${base}?idpId=apple.com`, {
853
+ method: 'POST',
854
+ headers,
855
+ body: JSON.stringify(body),
856
+ });
857
+ if (!res.ok) {
858
+ const text = await res.text();
859
+ // Raced with another writer — fall back to PATCH.
860
+ if (res.status === 409 || text.includes('ALREADY_EXISTS')) {
861
+ const patchRes = await fetch(`${base}/apple.com?updateMask=enabled,clientId,appleSignInConfig`, {
862
+ method: 'PATCH',
863
+ headers,
864
+ body: JSON.stringify(body),
865
+ });
866
+ if (!patchRes.ok) {
867
+ const t2 = await patchRes.text();
868
+ return { ok: false, error: `PATCH failed (${patchRes.status}): ${t2.slice(0, 200)}` };
869
+ }
870
+ } else {
871
+ return { ok: false, error: `POST failed (${res.status}): ${text.slice(0, 200)}` };
872
+ }
873
+ }
874
+ }
875
+ } catch (err) {
876
+ return { ok: false, error: `Network error: ${err.message}` };
877
+ }
878
+
879
+ return { ok: true };
880
+ }
881
+
882
+ /**
883
+ * Enable the Facebook provider on Firebase via the Identity Toolkit Admin v2 API.
884
+ * Needs the Meta App ID (clientId) + App Secret (clientSecret). The native App ID /
885
+ * Client Token live in Info.plist / strings.xml and are written separately.
886
+ *
887
+ * @param {object} opts - { projectId, appId, appSecret }
888
+ * @returns {{ ok: boolean, error?: string }}
889
+ */
890
+ async function configureFirebaseFacebook({ projectId, appId, appSecret }) {
891
+ if (!appId || !appSecret) {
892
+ return { ok: false, error: 'appId and appSecret are required' };
893
+ }
894
+ let token;
895
+ try {
896
+ token = await getAccessToken();
897
+ } catch (_) {
898
+ return { ok: false, error: 'Could not get access token' };
899
+ }
900
+
901
+ const headers = {
902
+ Authorization: `Bearer ${token}`,
903
+ 'Content-Type': 'application/json',
904
+ 'X-Goog-User-Project': projectId,
905
+ };
906
+ const base = `https://identitytoolkit.googleapis.com/admin/v2/projects/${projectId}/defaultSupportedIdpConfigs`;
907
+ const body = {
908
+ name: `projects/${projectId}/defaultSupportedIdpConfigs/facebook.com`,
909
+ enabled: true,
910
+ clientId: appId,
911
+ clientSecret: appSecret,
912
+ };
913
+
914
+ try {
915
+ let res = await fetch(`${base}?idpId=facebook.com`, {
916
+ method: 'POST',
917
+ headers,
918
+ body: JSON.stringify(body),
919
+ });
920
+ if (!res.ok) {
921
+ const text = await res.text();
922
+ if (res.status === 409 || text.includes('ALREADY_EXISTS')) {
923
+ res = await fetch(`${base}/facebook.com?updateMask=enabled,clientId,clientSecret`, {
924
+ method: 'PATCH',
925
+ headers,
926
+ body: JSON.stringify(body),
927
+ });
928
+ if (!res.ok) {
929
+ const t2 = await res.text();
930
+ return { ok: false, error: `PATCH failed (${res.status}): ${t2.slice(0, 200)}` };
931
+ }
932
+ } else {
933
+ return { ok: false, error: `POST failed (${res.status}): ${text.slice(0, 200)}` };
934
+ }
935
+ }
936
+ } catch (err) {
937
+ return { ok: false, error: `Network error: ${err.message}` };
938
+ }
939
+
940
+ return { ok: true };
941
+ }
942
+
767
943
  /**
768
944
  * Initialize Firebase Auth (Identity Platform) for a project. Brand-new projects
769
945
  * have no auth config, so any Admin v2 operation (PATCH /config, or the Firebase
@@ -1299,6 +1475,8 @@ module.exports = {
1299
1475
  ensureFirebaseAuthInitialized,
1300
1476
  ensureLocalhostAuthorizedDomains,
1301
1477
  authorizeLocalhostForProject,
1478
+ configureFirebaseAppleWeb,
1479
+ configureFirebaseFacebook,
1302
1480
  listBillingAccounts,
1303
1481
  listGcpOrganizations,
1304
1482
  checkGcloudAuth,
@@ -0,0 +1,66 @@
1
+ {
2
+ "api/android/app/src/main/AndroidManifest.xml": "0579c57068ee50d43837ef9c7bee884b6232e92975301c8305ce389282f68291",
3
+ "api/ios/Runner/AppDelegate.swift": "9f1027a03ad1fef2dab45464fbd7e98364b6a9cb6d2a6abe770ee9c45fa19122",
4
+ "api/lib/core/data/api/storage_api.dart": "d77fecc6923ea9544b6ec20571dcab5c8f3d38be81ec618b0cf4f1b08be7bbae",
5
+ "api/lib/core/data/api/user_api.dart": "06f491ebf4548b87431e2d6983148f4082e120ec7d6a928108cba954d16d8c56",
6
+ "api/lib/core/data/entities/json_converters.dart": "92a5efd595a03a862fa03e77ab3ca3d556523d0c9e0daca6e296e35d24b760a8",
7
+ "api/lib/core/data/entities/user_entity.dart": "5c1e56e4be3ba2ed95cdcd276112a02b526f2a3fa7ee5bba08a3ec2b51153688",
8
+ "api/lib/environments.dart": "c41f0843a4d627b717579eec5607b7ff5c16369bdd4b2a82e081058a20ffb7a5",
9
+ "api/lib/features/ai_chat/api/ai_chat_api.dart": "749047d30390e8d624c5a17d421096b05651e28b34a42071a9b73efc71d0a5f7",
10
+ "api/lib/features/ai_chat/api/ai_chat_conversation_entity.dart": "1c4e4223e802d7fe8e93d9e7a09fcc6be81a41792d722e8b7e697837cde4737f",
11
+ "api/lib/features/ai_chat/api/ai_chat_message_entity.dart": "5aca4fdb5b1c38df04c9d377c6e6eeb6930ef85b2a32689fe6403e7cc7645373",
12
+ "api/lib/features/ai_chat/providers/ai_chat_notifier.dart": "f16ea3f3266e63a5591ceff9bc1900845f0b96e9bbbc5463ec6ab894f878eae3",
13
+ "api/lib/features/authentication/api/authentication_api_interface.dart": "ef9219237babd73e4b3ac148a24ebbbdf39ab98166d21f578c359074b528e5da",
14
+ "api/lib/features/authentication/api/authentication_api.dart": "8b460722bb3bee7eee015f34aaa39c1c97cb264df1024b2855060dac232c620a",
15
+ "api/lib/features/authentication/repositories/authentication_repository.dart": "8e98ca0d39df3aac1dab159e6bdfe8d2bbb8e43931459984eb4ecfb676698ca2",
16
+ "api/lib/features/feedbacks/api/entities/feature_request_entity.dart": "e72c683c7321bf1f3c19fc1c6d54c6964bd99970e87c9ff31d8918efa93f0090",
17
+ "api/lib/features/feedbacks/api/entities/feature_vote_entity.dart": "a13ccfc493d8f5277beffc8e28f77ec322ab0c0a56358c3669353206b7c6756b",
18
+ "api/lib/features/feedbacks/api/feature_request_api.dart": "a741395509b95e8cae135bc8bdcca1ba0d48785b1e1b201732219659d271346b",
19
+ "api/lib/features/feedbacks/api/feature_vote_api.dart": "89dcaeea29460a7f219c2ede3759707b45066272fe6837c29a40f3d79fade4e9",
20
+ "api/lib/features/notifications/api/device_api.dart": "0d6bccb6813cceec29c54220daac9d0cd35687c4fa9f10ffde07dad88b204ed3",
21
+ "api/lib/features/notifications/api/entities/device_entity.dart": "54fce6274c5fe60ba663d8693e0c9d88fd6cd6fcd9ec906e021621a2e51176b7",
22
+ "api/lib/features/notifications/api/entities/notifications_entity.dart": "f9ee62111a6f122657e105df317f1f5f803e3a525a96686c0a2728ff02cd8964",
23
+ "api/lib/features/notifications/api/notifications_api.dart": "30cdb1bc52c3249b2cda6b1c7eb7fb6a977db5744da5a9c2980784f41e75d9e7",
24
+ "api/lib/features/onboarding/api/entities/user_info_entity.dart": "cd5aabc68310025f3ab9676cab126e1f755431f9e8752f868ee9fcd6f5892ce2",
25
+ "api/lib/features/onboarding/api/user_infos_api.dart": "cf728bcef364259912ee1e87151c4f791faee0c0d49232c9af260bb5681bd429",
26
+ "api/lib/features/onboarding/models/user_info.dart": "946fd34a33b630a34a3b6248594cbca717f4fab9d23f669510145b9032bd1321",
27
+ "api/lib/features/onboarding/repositories/user_infos_repository.dart": "487a5177dcaed7f87e613b2b4f04329c4995274e6a52809d4917c037cb3c3c55",
28
+ "api/lib/features/settings/ui/components/admin/admin_users_api.dart": "0ffb232ffb193154c0366d6f7da0ca499b3e06fed4fd71af572b8dc53ecd844e",
29
+ "api/lib/features/settings/ui/widgets/avatar_utils.dart": "bb9126409bbbb245f2dec613bd096ac53c208a56bd55f3d2ab2599e43534904f",
30
+ "api/lib/features/subscriptions/api/entities/subscription_entity.dart": "20c1d75ed9d88acb96e94a592dcb4de0f63c792302ea07df53cebbdeb6d0cf7e",
31
+ "api/lib/features/subscriptions/api/stripe_backend_api.dart": "e370bd05211462f2fc94c69af1749eb5330f997e9ec5e73e5c4e119999bf666f",
32
+ "api/lib/features/subscriptions/api/subscription_api.dart": "c7484c9301d16245748025a3d420a89397e9e956b2211b7ed001c073ff2e4449",
33
+ "api/README.md": "1f30fc1ebf8fe02df6f3ee2f94c17f4bb4952abd5b125312d2c43eb6374eb354",
34
+ "supabase/android/app/src/main/AndroidManifest.xml": "0579c57068ee50d43837ef9c7bee884b6232e92975301c8305ce389282f68291",
35
+ "supabase/ios/Runner/AppDelegate.swift": "9f1027a03ad1fef2dab45464fbd7e98364b6a9cb6d2a6abe770ee9c45fa19122",
36
+ "supabase/lib/core/data/api/storage_api.dart": "d77fecc6923ea9544b6ec20571dcab5c8f3d38be81ec618b0cf4f1b08be7bbae",
37
+ "supabase/lib/core/data/api/user_api.dart": "06f491ebf4548b87431e2d6983148f4082e120ec7d6a928108cba954d16d8c56",
38
+ "supabase/lib/core/data/entities/json_converters.dart": "92a5efd595a03a862fa03e77ab3ca3d556523d0c9e0daca6e296e35d24b760a8",
39
+ "supabase/lib/core/data/entities/user_entity.dart": "5c1e56e4be3ba2ed95cdcd276112a02b526f2a3fa7ee5bba08a3ec2b51153688",
40
+ "supabase/lib/environments.dart": "c41f0843a4d627b717579eec5607b7ff5c16369bdd4b2a82e081058a20ffb7a5",
41
+ "supabase/lib/features/ai_chat/api/ai_chat_api.dart": "749047d30390e8d624c5a17d421096b05651e28b34a42071a9b73efc71d0a5f7",
42
+ "supabase/lib/features/ai_chat/api/ai_chat_conversation_entity.dart": "1c4e4223e802d7fe8e93d9e7a09fcc6be81a41792d722e8b7e697837cde4737f",
43
+ "supabase/lib/features/ai_chat/api/ai_chat_message_entity.dart": "5aca4fdb5b1c38df04c9d377c6e6eeb6930ef85b2a32689fe6403e7cc7645373",
44
+ "supabase/lib/features/ai_chat/providers/ai_chat_notifier.dart": "f16ea3f3266e63a5591ceff9bc1900845f0b96e9bbbc5463ec6ab894f878eae3",
45
+ "supabase/lib/features/authentication/api/authentication_api.dart": "8b460722bb3bee7eee015f34aaa39c1c97cb264df1024b2855060dac232c620a",
46
+ "supabase/lib/features/authentication/repositories/authentication_repository.dart": "8e98ca0d39df3aac1dab159e6bdfe8d2bbb8e43931459984eb4ecfb676698ca2",
47
+ "supabase/lib/features/feedbacks/api/entities/feature_request_entity.dart": "e72c683c7321bf1f3c19fc1c6d54c6964bd99970e87c9ff31d8918efa93f0090",
48
+ "supabase/lib/features/feedbacks/api/entities/feature_vote_entity.dart": "a13ccfc493d8f5277beffc8e28f77ec322ab0c0a56358c3669353206b7c6756b",
49
+ "supabase/lib/features/feedbacks/api/feature_request_api.dart": "a741395509b95e8cae135bc8bdcca1ba0d48785b1e1b201732219659d271346b",
50
+ "supabase/lib/features/feedbacks/api/feature_vote_api.dart": "89dcaeea29460a7f219c2ede3759707b45066272fe6837c29a40f3d79fade4e9",
51
+ "supabase/lib/features/notifications/api/device_api.dart": "0d6bccb6813cceec29c54220daac9d0cd35687c4fa9f10ffde07dad88b204ed3",
52
+ "supabase/lib/features/notifications/api/entities/device_entity.dart": "54fce6274c5fe60ba663d8693e0c9d88fd6cd6fcd9ec906e021621a2e51176b7",
53
+ "supabase/lib/features/notifications/api/entities/notifications_entity.dart": "f9ee62111a6f122657e105df317f1f5f803e3a525a96686c0a2728ff02cd8964",
54
+ "supabase/lib/features/notifications/api/notifications_api.dart": "30cdb1bc52c3249b2cda6b1c7eb7fb6a977db5744da5a9c2980784f41e75d9e7",
55
+ "supabase/lib/features/onboarding/api/entities/user_info_entity.dart": "cd5aabc68310025f3ab9676cab126e1f755431f9e8752f868ee9fcd6f5892ce2",
56
+ "supabase/lib/features/onboarding/api/user_infos_api.dart": "cf728bcef364259912ee1e87151c4f791faee0c0d49232c9af260bb5681bd429",
57
+ "supabase/lib/features/onboarding/models/user_info.dart": "946fd34a33b630a34a3b6248594cbca717f4fab9d23f669510145b9032bd1321",
58
+ "supabase/lib/features/onboarding/repositories/user_infos_repository.dart": "487a5177dcaed7f87e613b2b4f04329c4995274e6a52809d4917c037cb3c3c55",
59
+ "supabase/lib/features/settings/ui/components/admin/admin_users_api.dart": "0ffb232ffb193154c0366d6f7da0ca499b3e06fed4fd71af572b8dc53ecd844e",
60
+ "supabase/lib/features/settings/ui/widgets/avatar_utils.dart": "bb9126409bbbb245f2dec613bd096ac53c208a56bd55f3d2ab2599e43534904f",
61
+ "supabase/lib/features/subscriptions/api/entities/subscription_entity.dart": "20c1d75ed9d88acb96e94a592dcb4de0f63c792302ea07df53cebbdeb6d0cf7e",
62
+ "supabase/lib/features/subscriptions/api/stripe_backend_api.dart": "e370bd05211462f2fc94c69af1749eb5330f997e9ec5e73e5c4e119999bf666f",
63
+ "supabase/lib/features/subscriptions/api/subscription_api.dart": "c7484c9301d16245748025a3d420a89397e9e956b2211b7ed001c073ff2e4449",
64
+ "supabase/lib/google_auth_options.dart": "9d3ab9be5928f3100b9b217864d916edd1223d186e60ed130c35628812cace66",
65
+ "supabase/README.md": "1f30fc1ebf8fe02df6f3ee2f94c17f4bb4952abd5b125312d2c43eb6374eb354"
66
+ }
@@ -15,6 +15,7 @@ const path = require('node:path');
15
15
  const os = require('node:os');
16
16
  const fs = require('fs-extra');
17
17
  const { augmentedEnv } = require('../../../utils/env-tools');
18
+ const { signAppleClientSecret } = require('../../../utils/apple-web');
18
19
 
19
20
  const execAsync = promisify(exec);
20
21
 
@@ -407,6 +408,95 @@ async function enableAppleSignIn(projectRef, bundleId) {
407
408
  return { ok: false, error: result.data.message || JSON.stringify(result.data) };
408
409
  }
409
410
 
411
+ /**
412
+ * GET the current Supabase auth config (read-only). Used to merge values we must
413
+ * not clobber (e.g. the native bundle id already in external_apple_client_id).
414
+ */
415
+ async function getSupabaseAuthConfig(projectRef, token) {
416
+ try {
417
+ const res = await fetch(`https://api.supabase.com/v1/projects/${projectRef}/config/auth`, {
418
+ headers: { Authorization: `Bearer ${token}` },
419
+ });
420
+ if (!res.ok) return null;
421
+ return await res.json();
422
+ } catch {
423
+ return null;
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Enable Apple Sign-In on the WEB for Supabase.
429
+ *
430
+ * Unlike Firebase (which stores the .p8 and re-signs), Supabase stores a static,
431
+ * pre-signed client secret JWT that expires every ~6 months. We sign it here with
432
+ * the developer's `.p8` and write it as external_apple_secret. The Service ID is
433
+ * added to external_apple_client_id (the audience list) alongside the native bundle
434
+ * id, so both native iOS and the web OAuth flow validate.
435
+ *
436
+ * @param {string} projectRef
437
+ * @param {object} opts - { serviceId, teamId, keyId, privateKey, bundleId? }
438
+ * @returns {{ ok: boolean, error?: string, expiresAt?: number }}
439
+ */
440
+ async function enableAppleWebSignIn(projectRef, { serviceId, teamId, keyId, privateKey, bundleId } = {}) {
441
+ if (!serviceId || !teamId || !keyId || !privateKey) {
442
+ return { ok: false, error: 'serviceId, teamId, keyId and privateKey are required' };
443
+ }
444
+ const token = await getSupabaseAccessToken();
445
+ if (!token) return { ok: false, error: 'Could not retrieve Supabase access token' };
446
+
447
+ let secret;
448
+ let expiresAt;
449
+ try {
450
+ ({ token: secret, expiresAt } = signAppleClientSecret({ serviceId, teamId, keyId, privateKey }));
451
+ } catch (err) {
452
+ return { ok: false, error: err.message };
453
+ }
454
+
455
+ // Merge the Service ID into the existing audience list without dropping the
456
+ // native bundle id the CLI set at creation.
457
+ const current = await getSupabaseAuthConfig(projectRef, token);
458
+ const existingIds = String(current?.external_apple_client_id || '')
459
+ .split(',')
460
+ .map((s) => s.trim())
461
+ .filter(Boolean);
462
+ const clientIds = Array.from(new Set([...existingIds, bundleId, serviceId].filter(Boolean))).join(',');
463
+
464
+ const result = await patchAuthConfig(projectRef, token, {
465
+ external_apple_enabled: true,
466
+ external_apple_client_id: clientIds,
467
+ external_apple_secret: secret,
468
+ });
469
+ if (!result.ok) return { ok: false, error: result.error };
470
+ if (result.data.external_apple_enabled === true) return { ok: true, expiresAt };
471
+ return { ok: false, error: result.data.message || JSON.stringify(result.data) };
472
+ }
473
+
474
+ /**
475
+ * Enable the Facebook provider on Supabase via the Management API.
476
+ * Needs the Meta App ID (client_id) + App Secret (secret). The native App ID /
477
+ * Client Token live in Info.plist / strings.xml and are written separately.
478
+ *
479
+ * @param {string} projectRef
480
+ * @param {object} opts - { appId, appSecret }
481
+ * @returns {{ ok: boolean, error?: string }}
482
+ */
483
+ async function enableFacebookSignIn(projectRef, { appId, appSecret } = {}) {
484
+ if (!appId || !appSecret) {
485
+ return { ok: false, error: 'appId and appSecret are required' };
486
+ }
487
+ const token = await getSupabaseAccessToken();
488
+ if (!token) return { ok: false, error: 'Could not retrieve Supabase access token' };
489
+
490
+ const result = await patchAuthConfig(projectRef, token, {
491
+ external_facebook_enabled: true,
492
+ external_facebook_client_id: appId,
493
+ external_facebook_secret: appSecret,
494
+ });
495
+ if (!result.ok) return { ok: false, error: result.error };
496
+ if (result.data.external_facebook_enabled === true) return { ok: true };
497
+ return { ok: false, error: result.data.message || JSON.stringify(result.data) };
498
+ }
499
+
410
500
  /**
411
501
  * Configure auth settings via Supabase Management API:
412
502
  * - Enable anonymous sign-in
@@ -717,6 +807,8 @@ module.exports = {
717
807
  enableAnonymousSignIn,
718
808
  enableGoogleSignIn,
719
809
  enableAppleSignIn,
810
+ enableAppleWebSignIn,
811
+ enableFacebookSignIn,
720
812
  checkLoggedIn,
721
813
  getOrgsList,
722
814
  getProjectsByOrg,
@@ -101,11 +101,13 @@ Deno.serve(async (req: Request) => {
101
101
  const item = sub.items?.data?.[0];
102
102
  const priceId = item?.price?.id ?? "";
103
103
  const ms = periodEndMs(sub);
104
+ const trialMs = sub.trial_end ? sub.trial_end * 1000 : null;
104
105
  const now = new Date().toISOString();
105
106
  const payload = {
106
107
  status: statusFromStripe(sub),
107
108
  last_update_date: now,
108
109
  period_end_date: ms ? new Date(ms).toISOString() : null,
110
+ trial_end: trialMs ? new Date(trialMs).toISOString() : null,
109
111
  sku_id: priceId,
110
112
  offer_id: priceId,
111
113
  store: "STRIPE",
@@ -1,35 +1,36 @@
1
- -- Send a welcome notification when the user's first device is registered.
1
+ -- Send a welcome notification ONCE per account when a device is registered.
2
2
  --
3
3
  -- Why on device registration and not on user creation?
4
- -- At user creation time no device token exists yet. By firing on the first
5
- -- INSERT into `devices` we guarantee the locale is set and the notification
6
- -- can be localised accurately.
4
+ -- At user creation time no device token exists yet. By firing on INSERT into
5
+ -- `devices` we guarantee the locale is set and the notification can be localised
6
+ -- accurately.
7
7
  --
8
- -- notify_user = false: the user is already inside the app at registration
9
- -- time, so no push is needed the notification is saved to the DB only.
8
+ -- Why a `welcome_sent` flag and not a device count?
9
+ -- Logout removes the device row and login re-creates it, so a device count
10
+ -- ("is this the first device?") sees a brand-new first device on every
11
+ -- re-login and re-sent the welcome each time. A one-shot flag on the user,
12
+ -- claimed atomically, sends it exactly once for the life of the account.
10
13
  --
11
- -- Anonymous users (no email in public.users) are skipped.
12
- -- The message is localised using the `locale` field of public.users.
14
+ -- notify_user = false: the user is already inside the app at registration time,
15
+ -- so no push is needed the notification is saved to the DB only.
16
+ --
17
+ -- Anonymous users (no email in public.users) are skipped until they have one.
18
+
19
+ -- One-time welcome guard (idempotent so re-running the migration is safe).
20
+ ALTER TABLE public.users
21
+ ADD COLUMN IF NOT EXISTS welcome_sent boolean NOT NULL DEFAULT false;
13
22
 
14
23
  CREATE OR REPLACE FUNCTION public.trigger_welcome_notification()
15
24
  RETURNS TRIGGER AS $$
16
25
  DECLARE
17
- v_email text;
18
- v_locale text;
19
- v_count int;
20
- v_title text;
21
- v_body text;
26
+ v_email text;
27
+ v_locale text;
28
+ v_claimed int;
29
+ v_title text;
30
+ v_body text;
22
31
  BEGIN
23
- -- Only fire for the very first device of this user.
24
- SELECT COUNT(*) INTO v_count
25
- FROM public.devices
26
- WHERE user_id = NEW.user_id AND id != NEW.id;
27
-
28
- IF v_count > 0 THEN
29
- RETURN NEW;
30
- END IF;
31
-
32
- -- Skip anonymous users (no email set yet).
32
+ -- Skip anonymous users (no email yet); they get welcomed once they have one,
33
+ -- WITHOUT consuming the one-time claim below.
33
34
  SELECT email, locale INTO v_email, v_locale
34
35
  FROM public.users
35
36
  WHERE id = NEW.user_id;
@@ -38,6 +39,18 @@ BEGIN
38
39
  RETURN NEW;
39
40
  END IF;
40
41
 
42
+ -- Atomically claim the one-time welcome: flip welcome_sent to true only if it
43
+ -- isn't already. ROW_COUNT is 1 for the very first claim, 0 for any later
44
+ -- device registration — including one re-created after a logout/login cycle.
45
+ UPDATE public.users
46
+ SET welcome_sent = true
47
+ WHERE id = NEW.user_id AND welcome_sent = false;
48
+
49
+ GET DIAGNOSTICS v_claimed = ROW_COUNT;
50
+ IF v_claimed = 0 THEN
51
+ RETURN NEW;
52
+ END IF;
53
+
41
54
  -- Localised welcome message (falls back to English).
42
55
  IF v_locale = 'pt' THEN
43
56
  v_title := 'Bem-vindo!';
@@ -0,0 +1,6 @@
1
+ -- Adds trial_end to subscriptions so trial periods are persisted and the app
2
+ -- can detect an active trial. Mirrors the Firebase backend, where the Stripe
3
+ -- webhook writes trial_end (RevenueCat trials are read from the SDK, not the DB).
4
+ -- Idempotent: safe to run on a fresh project or an already-deployed database.
5
+ ALTER TABLE public.subscriptions
6
+ ADD COLUMN IF NOT EXISTS trial_end TIMESTAMPTZ;