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.
- package/lib/commands/new.js +15 -1
- package/lib/scaffold/CHANGELOG.json +9 -0
- package/lib/scaffold/backends/api/patch/README.md +87 -2
- package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart +34 -0
- package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
- package/lib/scaffold/backends/firebase/setup-from-scratch.js +22 -0
- package/lib/scaffold/backends/supabase/deploy.js +5 -0
- package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/README.md +26 -22
- package/lib/scaffold/backends/supabase/edge-functions/send-push-notification/index.ts +8 -11
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts +3 -1
- package/lib/scaffold/backends/supabase/edge-functions/stripe-create-portal-session/index.ts +60 -3
- package/lib/scaffold/backends/supabase/patch/lib/features/authentication/api/authentication_api.dart +69 -17
- package/lib/scaffold/backends/supabase/patch/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
- package/lib/scaffold/backends/supabase/pubspec.yaml.tpl +6 -0
- package/lib/scaffold/generate.js +1 -1
- package/lib/scaffold/shared/generator-utils.js +22 -3
- package/lib/utils/i18n/messages-en.js +2 -0
- package/lib/utils/i18n/messages-es.js +2 -0
- package/lib/utils/i18n/messages-pt.js +2 -0
- package/package.json +2 -2
- package/templates/firebase/docs/auth-setup.en.md +7 -1
- package/templates/firebase/docs/auth-setup.es.md +7 -1
- package/templates/firebase/docs/auth-setup.pt.md +7 -1
- package/templates/firebase/functions/src/subscriptions/stripe_functions.ts +79 -5
- package/templates/firebase/lib/components/kasy_accordion.dart +2 -2
- package/templates/firebase/lib/components/kasy_alert.dart +1 -1
- package/templates/firebase/lib/components/kasy_app_bar.dart +3 -3
- package/templates/firebase/lib/components/kasy_bottom_sheet.dart +1 -1
- package/templates/firebase/lib/components/kasy_button.dart +8 -8
- package/templates/firebase/lib/components/kasy_chip.dart +1 -1
- package/templates/firebase/lib/components/kasy_date_picker.dart +26 -21
- package/templates/firebase/lib/components/kasy_dialog.dart +2 -2
- package/templates/firebase/lib/components/kasy_sidebar.dart +62 -11
- package/templates/firebase/lib/components/kasy_tabs.dart +2 -2
- package/templates/firebase/lib/components/kasy_text_area.dart +37 -5
- package/templates/firebase/lib/components/kasy_text_field.dart +77 -16
- package/templates/firebase/lib/components/kasy_toast.dart +1 -1
- package/templates/firebase/lib/components/kasy_web_header.dart +4 -3
- package/templates/firebase/lib/core/bottom_menu/bottom_menu.dart +6 -0
- package/templates/firebase/lib/core/bottom_menu/notification_bottom_item.dart +16 -37
- package/templates/firebase/lib/core/config/features.dart +13 -0
- package/templates/firebase/lib/core/dev_inspector/dev_inspector.dart +21 -0
- package/templates/firebase/lib/core/rating/widgets/rate_banner.dart +1 -1
- package/templates/firebase/lib/core/rating/widgets/review_popup.dart +1 -1
- package/templates/firebase/lib/core/theme/icon_sizes.dart +47 -0
- package/templates/firebase/lib/core/theme/shadows.dart +13 -0
- package/templates/firebase/lib/core/theme/texts.dart +32 -0
- package/templates/firebase/lib/core/theme/theme.dart +2 -0
- package/templates/firebase/lib/core/web_device_preview/web_device_preview.dart +3 -0
- package/templates/firebase/lib/core/web_viewport_scale.dart +23 -4
- package/templates/firebase/lib/core/widgets/update_bottom_sheet.dart +1 -1
- package/templates/firebase/lib/features/ai_chat/ui/widgets/ai_conversation_tile.dart +1 -1
- package/templates/firebase/lib/features/authentication/ui/components/otp_verification.dart +1 -1
- package/templates/firebase/lib/features/authentication/ui/components/phone_input.dart +2 -1
- package/templates/firebase/lib/features/authentication/ui/recover_password_page.dart +3 -1
- package/templates/firebase/lib/features/authentication/ui/signin_page.dart +36 -14
- package/templates/firebase/lib/features/authentication/ui/signup_page.dart +27 -11
- package/templates/firebase/lib/features/authentication/ui/widgets/recover_password_result.dart +1 -1
- package/templates/firebase/lib/features/feedbacks/ui/feedback_page.dart +1 -1
- package/templates/firebase/lib/features/feedbacks/ui/widgets/add_feature_button.dart +1 -1
- package/templates/firebase/lib/features/feedbacks/ui/widgets/feature_card.dart +1 -1
- package/templates/firebase/lib/features/home/home_components_preview_registry.dart +22 -3
- package/templates/firebase/lib/features/home/home_image_grid.dart +1 -1
- package/templates/firebase/lib/features/local_reminders/ui/reminder_page.dart +2 -2
- package/templates/firebase/lib/features/notifications/providers/unread_notifications_count_provider.dart +17 -0
- package/templates/firebase/lib/features/notifications/ui/widgets/empty_notifications.dart +6 -1
- package/templates/firebase/lib/features/notifications/ui/widgets/notification_tile.dart +35 -38
- package/templates/firebase/lib/features/notifications/ui/widgets/permission_request_view.dart +1 -1
- package/templates/firebase/lib/features/onboarding/ui/components/onboarding_loader.dart +1 -1
- package/templates/firebase/lib/features/onboarding/ui/widgets/onboarding_module_mockups.dart +3 -3
- package/templates/firebase/lib/features/onboarding/ui/widgets/selectable_row_tile.dart +1 -1
- package/templates/firebase/lib/features/settings/settings_page.dart +264 -307
- package/templates/firebase/lib/features/settings/ui/components/avatar_component.dart +2 -2
- package/templates/firebase/lib/features/settings/ui/components/delete_user_component.dart +13 -6
- package/templates/firebase/lib/features/settings/ui/components/edit_name_sheet.dart +115 -0
- package/templates/firebase/lib/features/settings/ui/components/language_switcher.dart +2 -2
- package/templates/firebase/lib/features/settings/ui/widgets/settings_bottom_sheet_option_tile.dart +1 -1
- package/templates/firebase/lib/features/settings/ui/widgets/settings_tile.dart +13 -5
- package/templates/firebase/lib/features/subscriptions/api/stripe_backend_api.dart +12 -2
- package/templates/firebase/lib/features/subscriptions/api/stripe_payment_api.dart +7 -1
- package/templates/firebase/lib/features/subscriptions/providers/premium_page_provider.dart +11 -3
- package/templates/firebase/lib/features/subscriptions/ui/component/paywall_row.dart +1 -1
- package/templates/firebase/lib/features/subscriptions/ui/widgets/comparison_table.dart +1 -1
- package/templates/firebase/lib/features/subscriptions/ui/widgets/feature_line.dart +2 -2
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_close_button.dart +1 -1
- package/templates/firebase/lib/features/subscriptions/ui/widgets/premium_feature.dart +1 -1
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_col.dart +3 -3
- package/templates/firebase/lib/features/subscriptions/ui/widgets/selectable_row.dart +1 -1
- package/templates/firebase/lib/i18n/en.i18n.json +10 -1
- package/templates/firebase/lib/i18n/es.i18n.json +10 -1
- package/templates/firebase/lib/i18n/pt.i18n.json +10 -1
- package/templates/firebase/pubspec.yaml +0 -1
- package/templates/firebase/web/stripe_success.html +64 -26
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
- package/lib/scaffold/backends/api/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/animations/movefade_anim.dart +0 -34
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/components/onboarding_att_setup.dart +0 -67
- package/lib/scaffold/backends/supabase/patch/lib/features/onboarding/ui/widgets/onboarding_radio_question.dart +0 -183
- package/templates/firebase/lib/features/authentication/ui/components/facebook_signin.dart +0 -19
- package/templates/firebase/login-redesign-preview.png +0 -0
package/lib/commands/new.js
CHANGED
|
@@ -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.
|
|
161
|
-
|
|
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
|
|
package/lib/scaffold/backends/api/patch/lib/features/authentication/api/authentication_api.dart
CHANGED
|
@@ -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(
|
package/lib/scaffold/backends/api/patch/lib/features/subscriptions/api/stripe_backend_api.dart
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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)
|
|
3
|
+
Edge Function que envia push notifications via Firebase Cloud Messaging (FCM)
|
|
4
|
+
quando uma notificação é inserida na tabela `notifications`.
|
|
4
5
|
|
|
5
|
-
##
|
|
6
|
+
## Tudo isto é automático
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
O `kasy new` e o `kasy deploy` já deixam o push funcionando no Supabase, sem passo
|
|
9
|
+
manual:
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
22
|
+
## Fallback manual (só se a automação falhar)
|
|
15
23
|
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
* into the `notifications` table
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
15
|
-
*
|
|
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
|
*/
|
package/lib/scaffold/backends/supabase/edge-functions/stripe-create-checkout-session/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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) {
|