kasy-cli 1.31.13 → 1.32.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 (101) hide show
  1. package/lib/commands/new.js +15 -1
  2. package/lib/scaffold/CHANGELOG.json +9 -0
  3. package/lib/scaffold/backends/api/patch/README.md +87 -2
  4. package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +34 -0
  5. package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  6. package/lib/scaffold/backends/firebase/setup-from-scratch.js +22 -0
  7. package/lib/scaffold/backends/supabase/deploy.js +5 -0
  8. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/README.md +26 -22
  9. package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -11
  10. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +3 -1
  11. package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +60 -3
  12. package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +69 -17
  13. package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  14. package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +6 -0
  15. package/lib/scaffold/generate.js +1 -1
  16. package/lib/scaffold/shared/generator-utils.js +22 -3
  17. package/lib/utils/i18n/messages-en.js +2 -0
  18. package/lib/utils/i18n/messages-es.js +2 -0
  19. package/lib/utils/i18n/messages-pt.js +2 -0
  20. package/package.json +2 -2
  21. package/templates/firebase/docs/auth-setup.en.md +7 -1
  22. package/templates/firebase/docs/auth-setup.es.md +7 -1
  23. package/templates/firebase/docs/auth-setup.pt.md +7 -1
  24. package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +79 -5
  25. package/templates/firebase/lib/components/kasy_accordion.dart +2 -2
  26. package/templates/firebase/lib/components/kasy_alert.dart +1 -1
  27. package/templates/firebase/lib/components/kasy_app_bar.dart +3 -3
  28. package/templates/firebase/lib/components/kasy_bottom_sheet.dart +1 -1
  29. package/templates/firebase/lib/components/kasy_button.dart +8 -8
  30. package/templates/firebase/lib/components/kasy_chip.dart +1 -1
  31. package/templates/firebase/lib/components/kasy_date_picker.dart +26 -21
  32. package/templates/firebase/lib/components/kasy_dialog.dart +2 -2
  33. package/templates/firebase/lib/components/kasy_sidebar.dart +62 -11
  34. package/templates/firebase/lib/components/kasy_tabs.dart +2 -2
  35. package/templates/firebase/lib/components/kasy_text_area.dart +37 -5
  36. package/templates/firebase/lib/components/kasy_text_field.dart +77 -16
  37. package/templates/firebase/lib/components/kasy_toast.dart +1 -1
  38. package/templates/firebase/lib/components/kasy_web_header.dart +4 -3
  39. package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +6 -0
  40. package/templates/firebase/lib/core/bottom_menu/notification_bottom_item.dart +16 -37
  41. package/templates/firebase/lib/core/config/features.dart +13 -0
  42. package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +21 -0
  43. package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +1 -1
  44. package/templates/firebase/lib/core/rating/widgets/review_popup.dart +1 -1
  45. package/templates/firebase/lib/core/theme/icon_sizes.dart +47 -0
  46. package/templates/firebase/lib/core/theme/shadows.dart +13 -0
  47. package/templates/firebase/lib/core/theme/texts.dart +32 -0
  48. package/templates/firebase/lib/core/theme/theme.dart +2 -0
  49. package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -0
  50. package/templates/firebase/lib/core/web_viewport_scale.dart +23 -4
  51. package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +1 -1
  52. package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -1
  53. package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +1 -1
  54. package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -1
  55. package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +3 -1
  56. package/templates/firebase/lib/features/authentication/ui/signin_page.dart +36 -14
  57. package/templates/firebase/lib/features/authentication/ui/signup_page.dart +27 -11
  58. package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +1 -1
  59. package/templates/firebase/lib/features/feedbacks/ui/feedback_page.dart +1 -1
  60. package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -1
  61. package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -1
  62. package/templates/firebase/lib/features/home/home_components_preview_registry.dart +22 -3
  63. package/templates/firebase/lib/features/home/home_image_grid.dart +1 -1
  64. package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +2 -2
  65. package/templates/firebase/lib/features/notifications/providers/unread_notifications_count_provider.dart +17 -0
  66. package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +6 -1
  67. package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +35 -38
  68. package/templates/firebase/lib/features/notifications/ui/widgets/permission_request_view.dart +1 -1
  69. package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +1 -1
  70. package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_module_mockups.dart +3 -3
  71. package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +1 -1
  72. package/templates/firebase/lib/features/settings/settings_page.dart +264 -307
  73. package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
  74. package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +13 -6
  75. package/templates/firebase/lib/features/settings/ui/components/edit_name_sheet.dart +115 -0
  76. package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +2 -2
  77. package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +1 -1
  78. package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +13 -5
  79. package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
  80. package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +7 -1
  81. package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +11 -3
  82. package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -1
  83. package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +1 -1
  84. package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +2 -2
  85. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +1 -1
  86. package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +1 -1
  87. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +3 -3
  88. package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +1 -1
  89. package/templates/firebase/lib/i18n/en.i18n.json +10 -1
  90. package/templates/firebase/lib/i18n/es.i18n.json +10 -1
  91. package/templates/firebase/lib/i18n/pt.i18n.json +10 -1
  92. package/templates/firebase/pubspec.yaml +0 -1
  93. package/templates/firebase/web/stripe_success.html +64 -26
  94. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  95. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  96. package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  97. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
  98. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
  99. package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
  100. package/templates/firebase/lib/features/authentication/ui/components/facebook_signin.dart +0 -19
  101. package/templates/firebase/login-redesign-preview.png +0 -0
@@ -44,7 +44,7 @@ const { generateApiProject } = require('../scaffold/backends/api/generator');
44
44
  const { createProjectAndGetKeys, setupLinkedProject, checkLoggedIn, getOrgsList, getProjectsByOrg, getProjectKeys, classifyCreateError } = require('../scaffold/backends/supabase/deploy');
45
45
  const { writeSupabaseGoogleAuthOptions, readSupabaseGoogleCredentials, getGoogleClientSecretViaGcloud, flutterfireConfigure, writeGoogleAuthOptions, ensureGoogleServiceInfoPlist, writeGoogleIosUrlScheme, writeGoogleIosUrlSchemeFromClientId } = require('../scaffold/shared/post-build');
46
46
  const { toPackageName } = require('../scaffold/backends/firebase/tokens');
47
- const { setupFromScratch, setupExistingProject, listBillingAccounts, listGcpOrganizations, checkGcloudAuth, getFirebaseAccount, getGcloudInstallInstructions, enableAuthProviders, ensureFirebaseAuthInitialized, registerDebugSha1 } = require('../scaffold/backends/firebase/setup-from-scratch');
47
+ const { setupFromScratch, setupExistingProject, listBillingAccounts, listGcpOrganizations, checkGcloudAuth, getFirebaseAccount, getGcloudInstallInstructions, enableAuthProviders, ensureFirebaseAuthInitialized, authorizeLocalhostForProject, registerDebugSha1 } = require('../scaffold/backends/firebase/setup-from-scratch');
48
48
  const { enableAuthViaFirebaseCli } = require('../scaffold/backends/firebase/enable-auth-via-cli');
49
49
  const { createFcmServiceAccountKey } = require('../scaffold/shared/fcm-service-account');
50
50
 
@@ -509,6 +509,11 @@ function printSuccessCard(tr, answers, targetDir) {
509
509
  }
510
510
  }
511
511
 
512
+ if (answers.backend === 'api') {
513
+ lines.push(kleur.yellow(`! ${tr('new.success.api.serverContracts')}`));
514
+ lines.push('');
515
+ }
516
+
512
517
  lines.push(kleur.bold(tr('new.success.nextSteps')));
513
518
  lines.push('');
514
519
 
@@ -1827,6 +1832,15 @@ async function runNew(directory, { language: langHint = null, backend: backendHi
1827
1832
  // Supabase setup runs in fcmOnly mode, which intentionally leaves Firebase
1828
1833
  // Auth untouched, so initialize it here (idempotent) before the deploy.
1829
1834
  await ensureFirebaseAuthInitialized(answers.firebaseProjectId);
1835
+ // Web Google sign-in on Supabase brokers the Google ID token through the
1836
+ // Firebase popup (signInWithPopup), which only runs from an authorized
1837
+ // domain. fcmOnly setup skips the authorizedDomains step, so localhost is
1838
+ // missing here and the web popup dies with [firebase_auth/unauthorized-domain].
1839
+ // Add it best-effort now that auth is initialized. Native (mobile) is unaffected.
1840
+ const localhostDomains = await authorizeLocalhostForProject(answers.firebaseProjectId);
1841
+ if (!localhostDomains.ok) {
1842
+ ui.log.warn(tr('new.google.localhostDomainWarn'));
1843
+ }
1830
1844
  const cliResult = await enableAuthViaFirebaseCli({
1831
1845
  projectDir: targetDir,
1832
1846
  projectId: answers.firebaseProjectId,
@@ -1,4 +1,13 @@
1
1
  {
2
+ "1.32.0": {
3
+ "modules": {
4
+ "core": {
5
+ "pt": "Proporção da web agora se adapta à largura real da janela: telas com escala alta do sistema (Windows em 125/150/175%) não ficam mais cortadas (sidebar/header), e o Mac continua igual. Ajuste só na web, nativo intocado.",
6
+ "en": "Web proportion now adapts to the real window width: high system-scale displays (Windows at 125/150/175%) no longer look cropped (sidebar/header), and Mac stays the same. Web-only, native untouched.",
7
+ "es": "La proporción en web ahora se adapta al ancho real de la ventana: pantallas con escala alta del sistema (Windows al 125/150/175%) ya no se ven recortadas (sidebar/header), y Mac queda igual. Solo en web, nativo intacto."
8
+ }
9
+ }
10
+ },
2
11
  "1.31.12": {
3
12
  "modules": {
4
13
  "core": {
@@ -37,6 +37,7 @@ Ficam no `.vscode/launch.json` como variáveis de ambiente de build (`--dart-def
37
37
  | `RC_ANDROID_PROD_KEY` | RevenueCat | Dashboard RevenueCat → Apps → Google Play (chave `goog_`, usada automaticamente em Android físico) |
38
38
  | `SENTRY_DSN` | Sentry | Dashboard Sentry → Projeto → DSN |
39
39
  | `MIXPANEL_TOKEN` | Mixpanel | Dashboard Mixpanel → Configurações → Token |
40
+ | `AI_CHAT_ENDPOINT` | IA Chat | URL do seu endpoint SSE de chat (ex.: `https://sua-api/ai-chat`) |
40
41
 
41
42
  Para atualizar, edite o `.vscode/launch.json`.
42
43
 
@@ -157,8 +158,92 @@ Formato de uma conversa (o "última mensagem" é desnormalizado para a lista fic
157
158
  ```
158
159
 
159
160
  Ao salvar uma mensagem, o servidor deve atualizar `updated_at`, `last_message_role` e
160
- `last_message_content` da conversa. O streaming da resposta da IA continua no endpoint
161
- `AI_CHAT_ENDPOINT` (SSE) — ele só recebe `message` + `history`, não persiste nada.
161
+ `last_message_content` da conversa.
162
+
163
+ ### Endpoint: AI Chat (resposta em streaming)
164
+
165
+ A resposta da IA é transmitida palavra a palavra via SSE por um endpoint separado,
166
+ cuja URL vem do `--dart-define=AI_CHAT_ENDPOINT=...` (quadro de credenciais acima).
167
+ Este endpoint **não persiste nada** — só faz o proxy para o provedor (OpenAI/Gemini)
168
+ e devolve o texto em stream. A chave do provedor fica **só no servidor**.
169
+
170
+ ```
171
+ POST {AI_CHAT_ENDPOINT}
172
+ Auth: Authorization: Bearer <token> (enviado automaticamente pelo app)
173
+ Content-Type: application/json
174
+ body:
175
+ {
176
+ "message": "última mensagem do usuário",
177
+ "history": [ { "role": "user" | "assistant", "content": "..." } ]
178
+ }
179
+
180
+ 200 OK (text/event-stream)
181
+ → devolva o texto da resposta em chunks (stream); o app concatena e renderiza
182
+ em tempo real. Mantenha a chave da IA (OPENAI/GEMINI) apenas no servidor.
183
+ ```
184
+
185
+ Se `AI_CHAT_ENDPOINT` não estiver definido, o chat mostra o estado "não configurado"
186
+ (o app não quebra). Referência pronta: a Edge Function `ai-chat` do backend Supabase.
187
+
188
+ ---
189
+
190
+ ## Excluir conta
191
+
192
+ O app chama um endpoint para o usuário excluir a própria conta. **É obrigatório
193
+ para publicar na App Store e na Play Store**, então o seu backend precisa implementá-lo.
194
+
195
+ ```
196
+ DELETE /users/me
197
+ Auth: Authorization: Bearer <token> (identifica o usuário; enviado pelo app)
198
+
199
+ O servidor DEVE:
200
+ 1. Apagar o usuário do sistema de auth para que aquele login nunca mais entre.
201
+ 2. Apagar em cascata TODOS os dados do usuário: perfil, devices/tokens de push,
202
+ conversas + mensagens de IA, votos de feature requests, assinaturas, avatar.
203
+ → responda 2xx em caso de sucesso.
204
+ ```
205
+
206
+ Sem esse endpoint, a exclusão de conta falha silenciosamente (404/405) num projeto
207
+ API novo.
208
+
209
+ ---
210
+
211
+ ## Notificações push (FCM)
212
+
213
+ Push **nativo (Android/iOS)** depende do seu servidor: o app registra o token do
214
+ device e o seu backend envia via **FCM HTTP v1**. Na **web**, push é no-op de propósito
215
+ (o app não registra token e não tenta enviar — só mostra as notificações que já existem).
216
+
217
+ A chave de Service Account do Firebase foi salva pelo `kasy new` em
218
+ `.kasy/fcm-service-account.json`. Carregue-a no servidor (ex.: variável
219
+ `FIREBASE_SERVICE_ACCOUNT_JSON`) e use-a para chamar a FCM HTTP v1. Referência pronta
220
+ de implementação: a Edge Function `send-push-notification` do backend Supabase.
221
+
222
+ ### Endpoints: devices (tokens de push)
223
+
224
+ ```
225
+ POST /users/{userId}/devices → registra/atualiza um device (body com token, platform, etc.)
226
+ PUT /devices/{deviceId} → atualiza um device existente
227
+ DELETE /devices/{deviceId} → remove um device
228
+ PATCH /users/{userId}/devices/{installationId}/touch → marca o device como ativo agora (last-seen)
229
+ POST /users/{userId}/devices/cleanup-stale → remove devices antigos/inválidos
230
+ DELETE /users/{userId}/devices → remove todos os devices do usuário (ex.: no logout)
231
+ ```
232
+
233
+ ### Endpoints: notifications
234
+
235
+ ```
236
+ GET /users/{userId}/notifications?page=&pageSize= → lista paginada, mais recente primeiro
237
+ PUT /users/{userId}/notifications/{id} → marca como lida
238
+ DELETE /users/{userId}/notifications/{id} → apaga uma notificação
239
+ GET /users/{userId}/notifications/unread → SSE: stream da contagem de não-lidas (alimenta a "bolinha")
240
+ POST /users/{userId}/notifications → cria/envia para UM usuário (body: title, body, image_url?, data.route?, type)
241
+ POST /notifications/broadcast → envia para TODOS (mesmo body)
242
+ ```
243
+
244
+ Todas exigem `Authorization: Bearer <token>` (enviado automaticamente). Ao criar uma
245
+ notificação, o servidor persiste o registro **e** dispara o push via FCM para os devices
246
+ do destinatário.
162
247
 
163
248
  ---
164
249
 
@@ -3,6 +3,7 @@ import 'package:kasy_kit/core/data/api/http_client.dart';
3
3
  import 'package:kasy_kit/core/data/entities/user_entity.dart';
4
4
  import 'package:kasy_kit/features/authentication/api/authentication_api_interface.dart';
5
5
  import 'package:kasy_kit/features/authentication/repositories/exceptions/authentication_exceptions.dart';
6
+ import 'package:flutter/foundation.dart' show kIsWeb;
6
7
  import 'package:flutter/services.dart' show PlatformException;
7
8
  import 'package:dio/dio.dart';
8
9
  import 'package:flutter_facebook_auth/flutter_facebook_auth.dart';
@@ -119,6 +120,15 @@ class HttpAuthenticationApi implements AuthenticationApi {
119
120
 
120
121
  @override
121
122
  Future<Credentials> signinWithGoogle() async {
123
+ // google_sign_in v7 authenticate() is unsupported on web (throws). The API
124
+ // backend is wire-it-yourself, so fail clearly instead of an opaque crash.
125
+ if (kIsWeb) {
126
+ throw UnimplementedError(
127
+ 'Google sign-in on web is not wired for the API backend. Use '
128
+ 'google_sign_in_web (renderButton) or a backend OAuth redirect, then '
129
+ 'exchange the token with your API.',
130
+ );
131
+ }
122
132
  final googleSignIn = GoogleSignIn.instance;
123
133
  await googleSignIn.initialize(
124
134
  clientId: const String.fromEnvironment('GOOGLE_CLIENT_ID'),
@@ -153,6 +163,15 @@ class HttpAuthenticationApi implements AuthenticationApi {
153
163
 
154
164
  @override
155
165
  Future<Credentials> signinWithApple() async {
166
+ // Apple on web needs a paid Apple Service ID + secret and a backend token
167
+ // exchange; getAppleIDCredential crashes on web. The UI hides the Apple button
168
+ // on web; guard here too so a programmatic call fails clearly.
169
+ if (kIsWeb) {
170
+ throw UnimplementedError(
171
+ 'Apple sign-in on web needs a paid Apple Service ID + secret and a backend '
172
+ 'token exchange; it is not wired for the API backend.',
173
+ );
174
+ }
156
175
  late final AuthorizationCredentialAppleID credential;
157
176
  try {
158
177
  credential = await SignInWithApple.getAppleIDCredential(
@@ -212,6 +231,14 @@ class HttpAuthenticationApi implements AuthenticationApi {
212
231
  /// POST /auth/link-google {id_token, access_token} → Credentials
213
232
  @override
214
233
  Future<Credentials> signupFromAnonymousWithGoogle() async {
234
+ // See signinWithGoogle: authenticate() is unsupported on web.
235
+ if (kIsWeb) {
236
+ throw UnimplementedError(
237
+ 'Google sign-in on web is not wired for the API backend. Use '
238
+ 'google_sign_in_web (renderButton) or a backend OAuth redirect, then '
239
+ 'exchange the token with your API.',
240
+ );
241
+ }
215
242
  final googleSignIn = GoogleSignIn.instance;
216
243
  await googleSignIn.initialize(
217
244
  clientId: const String.fromEnvironment('GOOGLE_CLIENT_ID'),
@@ -242,6 +269,13 @@ class HttpAuthenticationApi implements AuthenticationApi {
242
269
  /// POST /auth/link-apple {identity_token, authorization_code} → Credentials
243
270
  @override
244
271
  Future<Credentials> signupFromAnonymousWithApple() async {
272
+ // See signinWithApple: Apple on web is not wired for the API backend.
273
+ if (kIsWeb) {
274
+ throw UnimplementedError(
275
+ 'Apple sign-in on web needs a paid Apple Service ID + secret and a backend '
276
+ 'token exchange; it is not wired for the API backend.',
277
+ );
278
+ }
245
279
  late final AuthorizationCredentialAppleID credential;
246
280
  try {
247
281
  credential = await SignInWithApple.getAppleIDCredential(
@@ -38,6 +38,7 @@ class StripeBackendApi {
38
38
  String? successUrl,
39
39
  String? cancelUrl,
40
40
  String? locale,
41
+ bool? allowPromoCodes,
41
42
  }) async {
42
43
  final Response res = await _client.post(
43
44
  '/stripe/checkout-session',
@@ -46,16 +47,25 @@ class StripeBackendApi {
46
47
  if (successUrl != null) 'successUrl': successUrl,
47
48
  if (cancelUrl != null) 'cancelUrl': cancelUrl,
48
49
  if (locale != null) 'locale': locale,
50
+ if (allowPromoCodes != null) 'allowPromoCodes': allowPromoCodes,
49
51
  },
50
52
  );
51
53
  return (res.data as Map)['url'] as String;
52
54
  }
53
55
 
54
56
  /// Create a Customer Portal session (manage / cancel) and return its URL.
55
- Future<String> createPortalSession({String? returnUrl}) async {
57
+ /// Pass [planSwitching] = true to auto-configure the portal with
58
+ /// upgrade/downgrade support (no manual Stripe dashboard setup needed).
59
+ Future<String> createPortalSession({
60
+ String? returnUrl,
61
+ bool? planSwitching,
62
+ }) async {
56
63
  final Response res = await _client.post(
57
64
  '/stripe/portal-session',
58
- data: {if (returnUrl != null) 'returnUrl': returnUrl},
65
+ data: {
66
+ if (returnUrl != null) 'returnUrl': returnUrl,
67
+ if (planSwitching != null) 'planSwitching': planSwitching,
68
+ },
59
69
  );
60
70
  return (res.data as Map)['url'] as String;
61
71
  }
@@ -743,6 +743,27 @@ async function ensureLocalhostAuthorizedDomains(projectId, token) {
743
743
  return { ok: true, added: missing };
744
744
  }
745
745
 
746
+ /**
747
+ * Authorize localhost (and 127.0.0.1) on a project's Firebase Auth config,
748
+ * fetching the gcloud access token internally. Exported so callers that don't
749
+ * run the full setup (e.g. the Supabase backend, which sets up Firebase in
750
+ * fcmOnly mode and never reaches enableAuthProviders) can still authorize the
751
+ * domains the web Google popup needs. Best-effort: returns { ok, error } and
752
+ * never throws.
753
+ *
754
+ * @param {string} projectId
755
+ * @returns {{ ok: boolean, added?: string[], error?: string }}
756
+ */
757
+ async function authorizeLocalhostForProject(projectId) {
758
+ let token;
759
+ try {
760
+ token = await getAccessToken();
761
+ } catch (_) {
762
+ return { ok: false, error: 'Could not get access token' };
763
+ }
764
+ return ensureLocalhostAuthorizedDomains(projectId, token);
765
+ }
766
+
746
767
  /**
747
768
  * Initialize Firebase Auth (Identity Platform) for a project. Brand-new projects
748
769
  * have no auth config, so any Admin v2 operation (PATCH /config, or the Firebase
@@ -1277,6 +1298,7 @@ module.exports = {
1277
1298
  enableAuthProviders,
1278
1299
  ensureFirebaseAuthInitialized,
1279
1300
  ensureLocalhostAuthorizedDomains,
1301
+ authorizeLocalhostForProject,
1280
1302
  listBillingAccounts,
1281
1303
  listGcpOrganizations,
1282
1304
  checkGcloudAuth,
@@ -363,6 +363,11 @@ async function enableGoogleSignIn(projectRef, webClientId, clientSecret) {
363
363
  external_google_client_id: webClientId,
364
364
  external_google_secret: clientSecret,
365
365
  external_google_skip_nonce_check: true,
366
+ // Allow the web app's dev origin as a redirect target so the web OAuth flow
367
+ // (signInWithOAuth redirectTo) and email links (recovery/confirm) land back on
368
+ // the app. http://localhost:5555 is the port `kasy run --web` uses. Add your
369
+ // production web origin here when you deploy to the web.
370
+ uri_allow_list: 'http://localhost:5555,http://localhost:5555/**',
366
371
  });
367
372
  if (!result.ok) return { ok: false, error: result.error };
368
373
  if (result.data.external_google_enabled === true) return { ok: true };
@@ -1,35 +1,39 @@
1
1
  # send-push-notification
2
2
 
3
- Edge Function que envia push notifications via Firebase Cloud Messaging (FCM) quando uma notificação é inserida na tabela `notifications`.
3
+ Edge Function que envia push notifications via Firebase Cloud Messaging (FCM)
4
+ quando uma notificação é inserida na tabela `notifications`.
4
5
 
5
- ## Setup (1x por projeto)
6
+ ## Tudo isto é automático
6
7
 
7
- ### 1. Configurar secrets no Supabase
8
+ O `kasy new` e o `kasy deploy` já deixam o push funcionando no Supabase, sem passo
9
+ manual:
8
10
 
9
- ```bash
10
- supabase secrets set FIREBASE_PROJECT_ID=your-firebase-project-id
11
- supabase secrets set FIREBASE_SERVICE_ACCOUNT_JSON='{"type":"service_account","project_id":"..."}'
12
- ```
11
+ - **Secrets** `FIREBASE_PROJECT_ID` e `FIREBASE_SERVICE_ACCOUNT_JSON` são definidos
12
+ automaticamente (a chave de Service Account é gerada pela CLI).
13
+ - **A chamada automática** da função quando uma notificação é inserida vem de um
14
+ **trigger no banco** (`pg_net`), criado pela migration
15
+ `20240101000006_notification_webhook.sql`. **Não é** um Database Webhook do painel.
16
+ - O trigger só dispara quando `notify_user IS DISTINCT FROM false` (ex.: a notificação
17
+ de boas-vindas usa `notify_user = false` porque o usuário já está dentro do app).
18
+
19
+ > ⚠️ **Não crie um Database Webhook no painel** apontando para esta função. O trigger
20
+ > `pg_net` já faz isso; um webhook duplicado faria o push **disparar duas vezes**.
13
21
 
14
- O JSON do service account: Firebase Console → Project Settings → Service Accounts → **Generate new private key**.
22
+ ## Fallback manual (só se a automação falhar)
15
23
 
16
- ### 2. Deploy da Edge Function
24
+ Se por algum motivo os secrets não tiverem sido definidos, rode:
17
25
 
18
26
  ```bash
27
+ supabase secrets set FIREBASE_PROJECT_ID=your-firebase-project-id
28
+ supabase secrets set FIREBASE_SERVICE_ACCOUNT_JSON='{"type":"service_account","project_id":"..."}'
19
29
  supabase functions deploy send-push-notification --project-ref YOUR_PROJECT_REF
20
30
  ```
21
31
 
22
- ### 3. Configurar Database Webhook
23
-
24
- No Supabase Dashboard → **Database → Webhooks → Create webhook**:
25
-
26
- | Campo | Valor |
27
- |-------|-------|
28
- | Name | `on_notification_inserted` |
29
- | Table | `notifications` |
30
- | Events | `INSERT` |
31
- | Method | POST |
32
- | URL | `https://YOUR_PROJECT_REF.supabase.co/functions/v1/send-push-notification` |
33
- | Headers | `Authorization: Bearer YOUR_SUPABASE_ANON_KEY` |
32
+ O JSON do service account: Firebase Console → Project Settings → Service Accounts →
33
+ **Generate new private key**.
34
34
 
35
- Sem este webhook, a edge function não é chamada automaticamente.
35
+ Se preferir o trigger via painel em vez do `pg_net` (não recomendado, e nunca os dois
36
+ ao mesmo tempo), use Database → Webhooks → Create webhook na tabela `notifications`,
37
+ evento `INSERT`, POST para
38
+ `https://YOUR_PROJECT_REF.supabase.co/functions/v1/send-push-notification`,
39
+ header `Authorization: Bearer YOUR_SUPABASE_ANON_KEY`.
@@ -1,22 +1,19 @@
1
1
  /**
2
2
  * Supabase Edge Function: Send Push Notification via FCM
3
3
  *
4
- * Triggered by a Supabase Database Webhook when a row is inserted
5
- * into the `notifications` table. Reads device FCM tokens for the
6
- * target user and sends a push notification via Firebase Cloud Messaging
7
- * HTTP v1 API.
4
+ * Triggered automatically by a database trigger (pg_net) when a row is inserted
5
+ * into the `notifications` table with notify_user != false (migration
6
+ * 20240101000006_notification_webhook.sql) NOT by a Dashboard Database Webhook.
7
+ * Reads device FCM tokens for the target user and sends a push notification via
8
+ * Firebase Cloud Messaging HTTP v1 API.
8
9
  *
9
- * Secrets required (set via `supabase secrets set`):
10
+ * Secrets (set automatically by `kasy new` / `kasy deploy`):
10
11
  * - FIREBASE_PROJECT_ID: Your Firebase project ID (e.g. my-app-12345)
11
12
  * - FIREBASE_SERVICE_ACCOUNT_JSON: Full JSON of your Firebase service account key
12
13
  * (Firebase Console → Project Settings → Service Accounts → Generate new private key)
13
14
  *
14
- * Database Webhook setup (Supabase Dashboard Database → Webhooks):
15
- * - Table: notifications
16
- * - Event: INSERT
17
- * - Method: POST
18
- * - URL: https://<PROJECT_REF>.supabase.co/functions/v1/send-push-notification
19
- * - HTTP Headers: Authorization: Bearer <SUPABASE_ANON_KEY>
15
+ * Do NOT also create a Dashboard Database Webhook for this function the pg_net
16
+ * trigger already calls it; a second webhook would double-fire push. See README.md.
20
17
  *
21
18
  * @see https://firebase.google.com/docs/cloud-messaging/send-message
22
19
  */
@@ -97,7 +97,7 @@ Deno.serve(async (req: Request) => {
97
97
  }
98
98
  const uid = user.id;
99
99
 
100
- let body: { priceId?: string; successUrl?: string; cancelUrl?: string; locale?: string };
100
+ let body: { priceId?: string; successUrl?: string; cancelUrl?: string; locale?: string; allowPromoCodes?: boolean };
101
101
  try {
102
102
  body = await req.json();
103
103
  } catch {
@@ -110,6 +110,7 @@ Deno.serve(async (req: Request) => {
110
110
  const successUrl = body.successUrl ?? "";
111
111
  const cancelUrl = body.cancelUrl ?? successUrl;
112
112
  const locale = body.locale?.substring(0, 2).toLowerCase();
113
+ const allowPromoCodes = body.allowPromoCodes === true;
113
114
 
114
115
  try {
115
116
  const stripe = new Stripe(secretKey);
@@ -136,6 +137,7 @@ Deno.serve(async (req: Request) => {
136
137
  metadata: { supabaseUID: uid },
137
138
  ...(trialDays ? { trial_period_days: trialDays } : {}),
138
139
  },
140
+ ...(allowPromoCodes ? { allow_promotion_codes: true } : {}),
139
141
  });
140
142
  return Response.json({ url: session.url }, { headers: corsHeaders });
141
143
  } catch (err) {
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Supabase Edge Function: Stripe — Create Customer Portal Session
3
3
  *
4
- * Creates a Stripe Customer Portal session (manage / cancel) for the
5
- * authenticated user and returns its URL. The user is identified by the
4
+ * Creates a Stripe Customer Portal session (manage / cancel / switch plan) for
5
+ * the authenticated user and returns its URL. The user is identified by the
6
6
  * verified JWT and the Stripe customer is looked up server-side.
7
7
  *
8
8
  * Deployed WITHOUT the platform JWT gate (verify_jwt = false in config.toml) so the
@@ -13,7 +13,10 @@
13
13
  * - STRIPE_SECRET_KEY
14
14
  * - SUPABASE_URL / SUPABASE_ANON_KEY / SUPABASE_SERVICE_ROLE_KEY (auto-provided)
15
15
  *
16
- * Body: { returnUrl?: string }
16
+ * Optional env var:
17
+ * - STRIPE_PRODUCT_ID: narrows plan-switching to a single product's prices.
18
+ *
19
+ * Body: { returnUrl?: string, planSwitching?: boolean }
17
20
  */
18
21
 
19
22
  import Stripe from "npm:stripe@18";
@@ -39,6 +42,52 @@ async function getUid(req: Request, supabaseUrl: string, anonKey: string): Promi
39
42
  return user.id;
40
43
  }
41
44
 
45
+ // Creates (once) a Customer Portal configuration with subscription_update
46
+ // (plan switching) enabled, then reuses it on every subsequent call.
47
+ // Uses the Stripe list API to find an existing config — no extra DB table needed.
48
+ // deno-lint-ignore no-explicit-any
49
+ async function getOrCreatePortalConfig(stripe: Stripe): Promise<string | undefined> {
50
+ // Reuse an existing active config with plan switching already enabled
51
+ const configs = await stripe.billingPortal.configurations.list({ active: true, limit: 20 });
52
+ // deno-lint-ignore no-explicit-any
53
+ const existing = configs.data.find((c: any) => c.features?.subscription_update?.enabled);
54
+ if (existing) return existing.id;
55
+
56
+ // Build the price list grouped by product so Stripe knows what to switch between
57
+ const productId = Deno.env.get("STRIPE_PRODUCT_ID") ?? "";
58
+ // deno-lint-ignore no-explicit-any
59
+ const priceListParams: any = { active: true, type: "recurring", limit: 100 };
60
+ if (productId) priceListParams.product = productId;
61
+ const { data: prices } = await stripe.prices.list(priceListParams);
62
+
63
+ if (prices.length === 0) return undefined;
64
+
65
+ const byProduct: Record<string, string[]> = {};
66
+ for (const p of prices) {
67
+ // deno-lint-ignore no-explicit-any
68
+ const pid = typeof p.product === "string" ? p.product : (p.product as any).id;
69
+ if (!byProduct[pid]) byProduct[pid] = [];
70
+ byProduct[pid].push(p.id);
71
+ }
72
+ const products = Object.entries(byProduct).map(([prod, priceIds]) => ({
73
+ product: prod,
74
+ prices: priceIds,
75
+ }));
76
+
77
+ const config = await stripe.billingPortal.configurations.create({
78
+ features: {
79
+ subscription_update: {
80
+ enabled: true,
81
+ default_allowed_updates: ["price"],
82
+ products,
83
+ },
84
+ subscription_cancel: { enabled: true, mode: "at_period_end" },
85
+ payment_method_update: { enabled: true },
86
+ },
87
+ });
88
+ return config.id;
89
+ }
90
+
42
91
  Deno.serve(async (req: Request) => {
43
92
  if (req.method === "OPTIONS") {
44
93
  return new Response(null, { status: 204, headers: corsHeaders });
@@ -61,9 +110,11 @@ Deno.serve(async (req: Request) => {
61
110
  }
62
111
 
63
112
  let returnUrl = "";
113
+ let planSwitching = false;
64
114
  try {
65
115
  const body = await req.json();
66
116
  returnUrl = (body?.returnUrl as string | undefined) ?? "";
117
+ planSwitching = body?.planSwitching === true;
67
118
  } catch {
68
119
  // body is optional
69
120
  }
@@ -84,9 +135,15 @@ Deno.serve(async (req: Request) => {
84
135
  }
85
136
 
86
137
  const stripe = new Stripe(secretKey);
138
+
139
+ // When plan switching is requested, resolve (or create) a portal configuration
140
+ // with subscription_update enabled — no manual Stripe dashboard setup needed.
141
+ const configId = planSwitching ? await getOrCreatePortalConfig(stripe) : undefined;
142
+
87
143
  const session = await stripe.billingPortal.sessions.create({
88
144
  customer: customerId,
89
145
  return_url: returnUrl,
146
+ ...(configId ? { configuration: configId } : {}),
90
147
  });
91
148
  return Response.json({ url: session.url }, { headers: corsHeaders });
92
149
  } catch (err) {