# Kasy App

App Flutter con backend API REST — generado por kasy.

---

## Documentación

La documentación completa de Kasy está en **[kasy.dev/docs](https://kasy.dev/docs)** — instalación, features, personalización, publicación y troubleshooting, paso a paso.

Este proyecto también incluye guías locales (funcionan offline):

| Guía | Contenido |
|------|-----------|
| [docs/auth-setup.md](docs/auth-setup.md) | Activar login con Google, Apple y Facebook |
| [docs/revenuecat-setup.md](docs/revenuecat-setup.md) | Activar suscripciones (RevenueCat) del test a producción |
| [docs/ad_mobs.md](docs/ad_mobs.md) | Anuncios (AdMob) y recompensas verificadas |
| [docs/ios-release.md](docs/ios-release.md) | Publicar en iOS con Mac (`kasy ios`) |
| [docs/codemagic-release.md](docs/codemagic-release.md) | Publicar sin Mac (`kasy codemagic`) |
| [docs/figma-workflow.md](docs/figma-workflow.md) | Flujo Figma → Flutter para asistentes de IA |
| [docs/figma-guia.md](docs/figma-guia.md) | Guía Figma paso a paso (rebrand y pantallas) |
| [design/README.md](design/README.md) | Design system Figma (enlace Community + duplicate) |

---

## Cómo empezar

```sh
kasy run             # recomendado — lee el .env y elige las claves correctas
kasy run --ios       # simulador iOS
kasy run --android   # emulador Android
kasy run --web       # web en localhost:5555
```

Alternativas: `make run` o `flutter run` también funcionan, pero sin los extras de `kasy run` (selección automática de clave RevenueCat, log en `.kasy/run.log`, aviso de update).

**Dispositivo físico por cable**
- iOS: conecta el iPhone → confía en este ordenador → Xcode → Window → Devices → emparejar
- Android: Configuración → Opciones de desarrollador → activar depuración USB

**URL del backend** está configurada como `BACKEND_URL` en el `.env` de la raíz.
Para actualizar la URL, edita el `.env` y vuelve a correr `kasy run`.

---

## Claves y credenciales

Este proyecto usa dos tipos de credenciales. Entender la diferencia evita confusión al configurar.

### Claves del app (quedan en el proyecto)

Están en el archivo **`.env`** en la raíz del proyecto (cada clave tiene un comentario explicativo). `kasy run` lee el `.env` e inyecta los valores en el build vía `--dart-define`; Flutter los lee con `String.fromEnvironment()`. **Nunca van al servidor.**

| Variable | Módulo | Cómo obtener |
|----------|--------|--------------|
| `BACKEND_URL` | API REST | URL de tu API |
| `RC_TEST_KEY` | RevenueCat | Panel RevenueCat → Apps → Test Store (clave `test_`, sirve para iOS+Android, usada automáticamente en simulador) |
| `RC_IOS_PROD_KEY` | RevenueCat | Panel RevenueCat → Apps → App Store (clave `appl_`, usada automáticamente en iPhone físico) |
| `RC_ANDROID_PROD_KEY` | RevenueCat | Panel RevenueCat → Apps → Google Play (clave `goog_`, usada automáticamente en Android físico) |
| `SENTRY_DSN` | Sentry | Panel Sentry → Proyecto → DSN |
| `MIXPANEL_TOKEN` | Mixpanel | Panel Mixpanel → Configuración → Token |
| `AI_CHAT_ENDPOINT` | Chat IA | URL de tu endpoint SSE de chat (ej.: `https://tu-api/ai-chat`) |

Para actualizar una clave, edita el `.env` y vuelve a correr `kasy run`.

---

## Internacionalización (i18n)

El app soporta **3 idiomas**: inglés (`en`), portugués (`pt`) y español (`es`).

### Cómo se elige el idioma

```
El app abre
  ├─ ¿Hay idioma guardado por el usuario? → usa el guardado
  └─ No → lee el idioma del móvil/navegador
            ├─ ¿Es en, pt o es? → usa ese idioma
            └─ Ninguno de esos → usa el idioma por defecto (base_locale)
```

### Cambiar el idioma por defecto (fallback)

**`slang.yaml`**
```yaml
base_locale: pt   # cambiar aquí: en | pt | es
```

Luego correr:
```sh
dart run slang
```

### Agregar o editar traducciones

Los archivos están en `lib/i18n/`:
- `en.i18n.json` — inglés
- `pt.i18n.json` — portugués
- `es.i18n.json` — español

Después de editar cualquier `.i18n.json`, siempre corre `dart run slang`.

---

## Admin (consola interna)

El app tiene una **consola de admin** (pestaña Usuarios, Solicitudes, etc.) liberada solo para quien tiene `role == "admin"`. El `role` es un campo de **control de acceso** que **tu backend controla** — el app nunca puede escribirlo.

### Campo `role` en el usuario

Tu endpoint de usuario (`GET /users/{id}`) debe devolver el `role` junto con los demás datos:

```json
{ "id": "...", "email": "ana@b.com", "name": "Ana", "onboarded": true, "role": "admin" }
```

- `role` ausente / `null` → usuario normal.
- `role: "admin"` → libera la consola de admin.

**Regla de seguridad (obligatoria):** el `role` solo puede definirse en el servidor (base de datos/panel). El backend debe **rechazar** cualquier intento del cliente de escribir `role` (ej.: en un `PATCH /users/{id}`), si no cualquiera se vuelve admin. Defínelo manualmente en tu base de datos para promover a alguien.

### Endpoint: listar usuarios

```
GET /admin/users
  Auth: Authorization: Bearer <token>
  Query (todos opcionales):
    page=0              página (base 0)
    pageSize=10         ítems por página (máx. 50)
    search=texto        filtra nombre o e-mail (contains)
    subscribersOnly=true
    sort=default|user|status|plan|joined
    sortAsc=true|false

  El servidor DEBE validar role == "admin" y responder 403 en caso contrario.

  200 OK:
  {
    "users": [ { "id", "email", "name", "createdAt", "avatarPath", "subscriber" } ],
    "totalUsers": 142,
    "page": 0,
    "pageSize": 10,
    "pageCount": 15,
    "searchCapped": false
  }
```

```
GET /admin/users/overview
  Auth: Bearer (solo admin)
  200 OK: { totalUsers, subscribers, new7d, daily[14], firstDayMs, lastDayMs }
```

La app pide **una página por vez** (10 usuarios por defecto). Búsqueda, filtro y ordenación disparan una nueva llamada al servidor.

### Endpoint: moderar solicitudes (pestaña Solicitudes)

```
GET   /admin/feature-requests        → lista TODAS (activas + ocultas), más votadas primero
PATCH /admin/feature-requests/{id}   body: {"active": true|false}
PATCH /admin/feature-requests/{id}   body: {"title": {...}, "description": {...}}
```

Misma regla: validar `role == "admin"` y responder 403 en caso contrario. `title`/`description` son mapas por idioma (`{"en": "...", "pt": "...", "es": "..."}`).

### Endpoints: Chat IA (historial de conversaciones)

El asistente guarda varias conversaciones por usuario, cada una con varios mensajes.
El usuario se identifica por el token `Authorization: Bearer`.

```
GET    /ai-conversations                  → lista las conversaciones del usuario, más reciente primero
POST   /ai-conversations                  → crea una conversación vacía y devuelve el objeto creado
DELETE /ai-conversations/{id}             → borra la conversación y todos sus mensajes
GET    /ai-conversations/{id}/messages    → mensajes de la conversación, más antiguo primero
POST   /ai-conversations/{id}/messages    body: {"role": "...", "content": "...", "created_at": "..."}
```

Formato de una conversación (el "último mensaje" está desnormalizado para que la lista sea barata):

```json
{
  "id": "...",
  "created_at": "2026-01-01T12:00:00Z",
  "updated_at": "2026-01-01T12:05:00Z",
  "last_message_role": "user" | "assistant" | null,
  "last_message_content": "..." | null
}
```

Al guardar un mensaje, el servidor debe actualizar `updated_at`, `last_message_role` y
`last_message_content` de la conversación.

### Endpoint: Chat IA (respuesta en streaming)

La respuesta de la IA se transmite palabra por palabra vía SSE por un endpoint separado,
cuya URL viene de `AI_CHAT_ENDPOINT` en el `.env` (tabla de credenciales arriba).
Este endpoint **no persiste nada** — solo hace proxy al proveedor (OpenAI/Gemini)
y devuelve el texto en stream. La clave del proveedor queda **solo en el servidor**.

```
POST {AI_CHAT_ENDPOINT}
  Auth: Authorization: Bearer <token>   (enviado automáticamente por el app)
  Content-Type: application/json
  body:
  {
    "message": "último mensaje del usuario",
    "history": [ { "role": "user" | "assistant", "content": "..." } ]
  }

  200 OK  (text/event-stream)
  → devuelve el texto de la respuesta en chunks (stream); el app concatena y renderiza
    en tiempo real. Mantén la clave de la IA (OPENAI/GEMINI) solo en el servidor.
```

Si `AI_CHAT_ENDPOINT` no está definido, el chat muestra el estado "no configurado"
(el app no se rompe). Referencia lista: la Edge Function `ai-chat` del backend Supabase.

---

## Eliminar cuenta

El app llama a un endpoint para que el usuario elimine su propia cuenta. **Es obligatorio
para publicar en la App Store y la Play Store**, así que tu backend necesita implementarlo.

```
DELETE /users/me
  Auth: Authorization: Bearer <token>   (identifica al usuario; enviado por el app)

  El servidor DEBE:
   1. Borrar el usuario del sistema de auth para que ese login nunca más entre.
   2. Borrar en cascada TODOS los datos del usuario: perfil, devices/tokens de push,
      conversaciones + mensajes de IA, votos de feature requests, suscripciones, avatar.
  → responde 2xx en caso de éxito.
```

Sin este endpoint, la eliminación de cuenta falla silenciosamente (404/405) en un proyecto
API nuevo.

---

## Notificaciones push (FCM)

El push **nativo (Android/iOS)** depende de tu servidor: el app registra el token del
device y tu backend envía vía **FCM HTTP v1**. En la **web**, el push es no-op a propósito
(el app no registra token y no intenta enviar — solo muestra las notificaciones que ya existen).

La clave de Service Account de Firebase fue guardada por `kasy new` en
`.kasy/fcm-service-account.json`. Cárgala en tu servidor (ej.: variable
`FIREBASE_SERVICE_ACCOUNT_JSON`) y úsala para llamar a FCM HTTP v1. Referencia lista
de implementación: la Edge Function `send-push-notification` del backend Supabase.

### Endpoints: devices (tokens de push)

```
POST   /users/{userId}/devices                          → registra/actualiza un device (body con token, platform, etc.)
PUT    /devices/{deviceId}                               → actualiza un device existente
DELETE /devices/{deviceId}                               → elimina un device
PATCH  /users/{userId}/devices/{installationId}/touch    → marca el device como activo ahora (last-seen)
POST   /users/{userId}/devices/cleanup-stale             → elimina devices antiguos/inválidos
DELETE /users/{userId}/devices                           → elimina todos los devices del usuario (ej.: en el logout)
```

> **Device sin token de push (importante):** el app registra el device **incluso sin
> permiso de push** — en ese caso el `token` viene **vacío** (`""`). Es a propósito:
> rastrea la instalación y dispara la bienvenida (abajo) sin depender del push; el
> token se completa después, vía `PUT /devices/{deviceId}`, cuando el usuario active
> las notificaciones. Tu backend debe **aceptar token vacío** y, al enviar push,
> **saltar** los devices con token vacío (no los trates como inválidos ni los borres).

> **Notificación de bienvenida:** créala **una sola vez por cuenta**, en el primer
> registro de device (`POST /users/{userId}/devices`), de forma **independiente del
> push** (vale para cuenta anónima también). Persiste solo en la base de datos (sin
> disparar push) y usa el `extra_data.deviceLocale` enviado por el device para
> localizar el mensaje (`pt`/`es`/`en`). Referencia exacta: el trigger
> `trigger_welcome_notification` del backend Supabase y el `onFirstDeviceRegistered`
> de Firebase.

### Endpoints: notifications

```
GET    /users/{userId}/notifications?page=&pageSize=     → lista paginada, más reciente primero
PUT    /users/{userId}/notifications/{id}                → marca como leída
DELETE /users/{userId}/notifications/{id}                → borra una notificación
GET    /users/{userId}/notifications/unread              → SSE: stream del conteo de no leídas (alimenta el badge)
POST   /users/{userId}/notifications                     → crea/envía a UN usuario (body: title, body, image_url?, data.route?, type)
POST   /notifications/broadcast                          → envía a TODOS (mismo body)
```

Todas exigen `Authorization: Bearer <token>` (enviado automáticamente). Al crear una
notificación, el servidor persiste el registro **y** dispara el push vía FCM a los
devices del destinatario.

---

## Anuncios (AdMob) — recompensa verificada en el servidor (SSV)

Los anuncios son **nativos (Android/iOS)** y corren 100% en el app vía `google_mobile_ads`
(banner, intersticial, recompensado y recompensado-intersticial). En la **web** todo es
no-op a propósito. La **única** parte que depende de tu backend es validar los anuncios
**recompensados** con seguridad, el **SSV (Server-Side Verification)**: sin él, un app
adulterado puede falsificar la recompensa.

Cuando el usuario termina un anuncio recompensado, **Google llama a tu endpoint**
con los datos de la recompensa y una firma. Tu servidor verifica la firma y concede la
recompensa **una sola vez** (idempotente por `transaction_id`).

```
GET /ads/verify-reward    (llamado por GOOGLE, no por el app — endpoint público)
  Query params (enviados por Google):
    ad_network, ad_unit, custom_data, key_id, reward_amount, reward_item,
    signature, timestamp, transaction_id, user_id

  El servidor DEBE:
   1. Verificar la firma ECDSA (SHA-256) sobre la query string HASTA (sin incluir)
      "&signature=". `signature` y `key_id` son siempre los dos últimos parámetros.
      Usa las claves públicas de Google (cachea por ~1h):
        https://gstatic.com/admob/reward/verifier-keys.json
      Encuentra la clave cuyo keyId == key_id y valida la firma (base64url → DER).
   2. Si es inválida → 403. Si es válida y sin user_id/transaction_id → responde 200 (ack).
   3. Conceder la recompensa de forma IDEMPOTENTE: si ya procesaste ese
      transaction_id, no concedas de nuevo; si no, acredita al user_id (monedas, vidas,
      pase sin anuncios…) y marca el transaction_id como procesado.
  → responde 200 en caso de éxito (Google reintenta en caso de error).
```

Implementaciones de referencia listas (misma lógica), copia de una de ellas:
  - Firebase: `functions/src/ads/ads_functions.ts`
  - Supabase: Edge Function `verify-ad-reward` + función SQL `grant_ad_reward`

En el app, configura la URL de tu endpoint como **SSV callback** de cada ad unit
recompensado en la consola de AdMob. El app ya envía el `user_id` automáticamente (vía
`setServerSideOptions`), así que tu endpoint sabe a quién acreditar.

Opcional (para que el app muestre el saldo):

```
GET /users/{userId}/ad-rewards/balance    → { "balance": <número> }
  Auth: Authorization: Bearer <token>
```

---

## Seguridad

El `.gitignore` ya excluye: `.env`, `.env.*`, `*.pem`, `*.keystore`.

Nunca subas credenciales al repositorio.
