# Kasy App

Flutter app with REST API backend — generated by kasy.

---

## Documentation

Full Kasy documentation lives at **[kasy.dev/docs](https://kasy.dev/docs)** — install, features, customization, publishing and troubleshooting, step by step.

This project also ships local guides (they work offline):

| Guide | Content |
|-------|---------|
| [docs/auth-setup.md](docs/auth-setup.md) | Enable Google, Apple and Facebook login |
| [docs/revenuecat-setup.md](docs/revenuecat-setup.md) | Enable subscriptions (RevenueCat) from test to production |
| [docs/ad_mobs.md](docs/ad_mobs.md) | Ads (AdMob) and verified rewards |
| [docs/ios-release.md](docs/ios-release.md) | Publish to iOS on Mac (`kasy ios`) |
| [docs/codemagic-release.md](docs/codemagic-release.md) | Publish without a Mac (`kasy codemagic`) |
| [docs/figma-workflow.md](docs/figma-workflow.md) | Figma → Flutter workflow for AI assistants |
| [docs/figma-guia.md](docs/figma-guia.md) | Step-by-step Figma guide (rebrand and screens) |
| [design/README.md](design/README.md) | Figma design system (Community + duplicate link) |

---

## Getting started

```sh
kasy run             # recommended — reads .env and picks the right keys
kasy run --ios       # iOS simulator
kasy run --android   # Android emulator
kasy run --web       # web at localhost:5555
```

Alternatives: `make run` or `flutter run` work too, but without the `kasy run` extras (automatic RevenueCat key selection, log at `.kasy/run.log`, update notice).

**Physical device via cable**
- iOS: connect iPhone → trust this computer → Xcode → Window → Devices → pair
- Android: Settings → Developer options → enable USB debugging

**Backend URL** is configured as `BACKEND_URL` in the root `.env`.
To update the URL, edit the `.env` and run `kasy run` again.

---

## Keys and credentials

This project uses two types of credentials. Understanding the difference avoids confusion when configuring.

### App keys (stay in the project)

They live in the **`.env`** file at the project root (each key has an explanatory comment). `kasy run` reads the `.env` and injects the values into the build via `--dart-define`; Flutter reads them with `String.fromEnvironment()`. **Never go to the server.**

| Variable | Module | How to get |
|----------|--------|------------|
| `BACKEND_URL` | REST API | Your API's URL |
| `RC_TEST_KEY` | RevenueCat | RevenueCat dashboard → Apps → Test Store (`test_` key, works for iOS+Android, auto-used on simulator) |
| `RC_IOS_PROD_KEY` | RevenueCat | RevenueCat dashboard → Apps → App Store (`appl_` key, auto-used on physical iPhone) |
| `RC_ANDROID_PROD_KEY` | RevenueCat | RevenueCat dashboard → Apps → Google Play (`goog_` key, auto-used on physical Android) |
| `SENTRY_DSN` | Sentry | Sentry dashboard → Project → DSN |
| `MIXPANEL_TOKEN` | Mixpanel | Mixpanel dashboard → Settings → Token |
| `AI_CHAT_ENDPOINT` | AI Chat | URL of your chat SSE endpoint (e.g. `https://your-api/ai-chat`) |

To update a key, edit the `.env` and run `kasy run` again.

---

## Internationalization (i18n)

The app supports **3 languages**: English (`en`), Portuguese (`pt`), and Spanish (`es`).

### How the language is chosen

```
App opens
  ├─ Has language saved by user? → use saved
  └─ No → read device/browser language
            ├─ Is en, pt or es? → use that language
            └─ None of these → use default language (base_locale)
```

### Change default language (fallback)

**`slang.yaml`**
```yaml
base_locale: pt   # change here: en | pt | es
```

Then run:
```sh
dart run slang
```

### Add or edit translations

Files are in `lib/i18n/`:
- `en.i18n.json` — English
- `pt.i18n.json` — Portuguese
- `es.i18n.json` — Spanish

After editing any `.i18n.json`, always run `dart run slang`.

---

## Admin (internal console)

The app has an **admin console** (Users tab, Requests, etc.) unlocked only for users with `role == "admin"`. `role` is an **access-control** field that **your backend controls** — the app can never write to it.

### `role` field on the user

Your user endpoint (`GET /users/{id}`) must return `role` along with the other data:

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

- `role` absent / `null` → regular user.
- `role: "admin"` → unlocks the admin console.

**Security rule (mandatory):** `role` can only be set on the server (database/panel). The backend must **reject** any client attempt to write `role` (e.g. in a `PATCH /users/{id}`), otherwise anyone can become admin. Set it manually in your database to promote someone.

### Endpoint: list users

```
GET /admin/users
  Auth: Authorization: Bearer <token>
  Query (all optional):
    page=0              page index (0-based)
    pageSize=10         items per page (max 50)
    search=text         filter by name or email (contains)
    subscribersOnly=true
    sort=default|user|status|plan|joined
    sortAsc=true|false

  The server MUST validate role == "admin" and respond 403 otherwise.

  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 (admin only)
  200 OK: { totalUsers, subscribers, new7d, daily[14], firstDayMs, lastDayMs }
```

The app requests **one page at a time** (10 users by default). Search, filter and sort trigger a new server call.

### Endpoint: moderate requests (Requests tab)

```
GET   /admin/feature-requests        → lists ALL (active + hidden), most voted first
PATCH /admin/feature-requests/{id}   body: {"active": true|false}
PATCH /admin/feature-requests/{id}   body: {"title": {...}, "description": {...}}
```

Same rule: validate `role == "admin"` and respond 403 otherwise. `title`/`description` are per-language maps (`{"en": "...", "pt": "...", "es": "..."}`).

### Endpoints: AI Chat (conversation history)

The assistant keeps several conversations per user, each with several messages.
The user is identified by the `Authorization: Bearer` token.

```
GET    /ai-conversations                  → lists the user's conversations, most recent first
POST   /ai-conversations                  → creates an empty conversation and returns the created object
DELETE /ai-conversations/{id}             → deletes the conversation and all its messages
GET    /ai-conversations/{id}/messages    → conversation messages, oldest first
POST   /ai-conversations/{id}/messages    body: {"role": "...", "content": "...", "created_at": "..."}
```

Shape of a conversation (the "last message" is denormalized so the list stays cheap):

```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
}
```

When saving a message, the server must update the conversation's `updated_at`, `last_message_role` and `last_message_content`.

### Endpoint: AI Chat (streaming response)

The AI response is streamed word by word via SSE through a separate endpoint,
whose URL comes from `AI_CHAT_ENDPOINT` in the `.env` (credentials table above).
This endpoint **persists nothing** — it only proxies to the provider (OpenAI/Gemini)
and returns the text as a stream. The provider key stays **server-side only**.

```
POST {AI_CHAT_ENDPOINT}
  Auth: Authorization: Bearer <token>   (sent automatically by the app)
  Content-Type: application/json
  body:
  {
    "message": "the user's latest message",
    "history": [ { "role": "user" | "assistant", "content": "..." } ]
  }

  200 OK  (text/event-stream)
  → return the response text in chunks (stream); the app concatenates and renders
    in real time. Keep the AI key (OPENAI/GEMINI) server-side only.
```

If `AI_CHAT_ENDPOINT` is not set, the chat shows the "not configured" state
(the app doesn't break). Ready-made reference: the `ai-chat` Edge Function from the Supabase backend.

---

## Delete account

The app calls an endpoint for the user to delete their own account. **It is mandatory
for publishing to the App Store and Play Store**, so your backend needs to implement it.

```
DELETE /users/me
  Auth: Authorization: Bearer <token>   (identifies the user; sent by the app)

  The server MUST:
   1. Delete the user from the auth system so that login never works again.
   2. Cascade-delete ALL user data: profile, devices/push tokens,
      AI conversations + messages, feature request votes, subscriptions, avatar.
  → respond 2xx on success.
```

Without this endpoint, account deletion fails silently (404/405) on a fresh API project.

---

## Push notifications (FCM)

**Native (Android/iOS)** push depends on your server: the app registers the device
token and your backend sends via **FCM HTTP v1**. On the **web**, push is a no-op by design
(the app doesn't register tokens and doesn't try to send — it only shows existing notifications).

The Firebase Service Account key was saved by `kasy new` at
`.kasy/fcm-service-account.json`. Load it on your server (e.g. as a
`FIREBASE_SERVICE_ACCOUNT_JSON` variable) and use it to call FCM HTTP v1. Ready-made
implementation reference: the `send-push-notification` Edge Function from the Supabase backend.

### Endpoints: devices (push tokens)

```
POST   /users/{userId}/devices                          → registers/updates a device (body with token, platform, etc.)
PUT    /devices/{deviceId}                               → updates an existing device
DELETE /devices/{deviceId}                               → removes a device
PATCH  /users/{userId}/devices/{installationId}/touch    → marks the device as active now (last-seen)
POST   /users/{userId}/devices/cleanup-stale             → removes old/invalid devices
DELETE /users/{userId}/devices                           → removes all the user's devices (e.g. on logout)
```

> **Device without a push token (important):** the app registers the device **even
> without push permission** — in that case `token` comes **empty** (`""`). This is
> on purpose: it tracks the install and triggers the welcome notification (below)
> without depending on push; the token is filled in later, via `PUT /devices/{deviceId}`,
> when the user enables notifications. Your backend must **accept empty tokens** and,
> when sending push, **skip** devices with an empty token (don't treat them as invalid
> and don't delete them).

> **Welcome notification:** create it **once per account**, on the first device
> registration (`POST /users/{userId}/devices`), **independently of push** (works for
> anonymous accounts too). Persist it in the database only (without firing a push) and
> use the `extra_data.deviceLocale` sent by the device to localize the message
> (`pt`/`es`/`en`). Exact reference: the `trigger_welcome_notification` trigger from the
> Supabase backend and `onFirstDeviceRegistered` from Firebase.

### Endpoints: notifications

```
GET    /users/{userId}/notifications?page=&pageSize=     → paginated list, most recent first
PUT    /users/{userId}/notifications/{id}                → marks as read
DELETE /users/{userId}/notifications/{id}                → deletes a notification
GET    /users/{userId}/notifications/unread              → SSE: unread-count stream (feeds the badge)
POST   /users/{userId}/notifications                     → creates/sends to ONE user (body: title, body, image_url?, data.route?, type)
POST   /notifications/broadcast                          → sends to EVERYONE (same body)
```

All of them require `Authorization: Bearer <token>` (sent automatically). When creating a
notification, the server persists the record **and** fires the push via FCM to the
recipient's devices.

---

## Ads (AdMob) — server-verified rewards (SSV)

Ads are **native (Android/iOS)** and run 100% in the app via `google_mobile_ads`
(banner, interstitial, rewarded and rewarded-interstitial). On the **web** everything is
a no-op by design. The **only** part that depends on your backend is validating
**rewarded** ads securely, the **SSV (Server-Side Verification)**: without it, a
tampered app can forge the reward.

When the user finishes a rewarded ad, **Google calls your endpoint** with the reward
data and a signature. Your server verifies the signature and grants the reward
**exactly once** (idempotent by `transaction_id`).

```
GET /ads/verify-reward    (called by GOOGLE, not by the app — public endpoint)
  Query params (sent by Google):
    ad_network, ad_unit, custom_data, key_id, reward_amount, reward_item,
    signature, timestamp, transaction_id, user_id

  The server MUST:
   1. Verify the ECDSA (SHA-256) signature over the query string UP TO (not including)
      "&signature=". `signature` and `key_id` are always the last two parameters.
      Use Google's public keys (cache for ~1h):
        https://gstatic.com/admob/reward/verifier-keys.json
      Find the key whose keyId == key_id and validate the signature (base64url → DER).
   2. If invalid → 403. If valid but missing user_id/transaction_id → respond 200 (ack).
   3. Grant the reward IDEMPOTENTLY: if this transaction_id was already processed,
      don't grant again; otherwise credit the user_id (coins, lives, ad-free pass…)
      and mark the transaction_id as processed.
  → respond 200 on success (Google retries on error).
```

Ready-made reference implementations (same logic), copy from one of them:
  - Firebase: `functions/src/ads/ads_functions.ts`
  - Supabase: `verify-ad-reward` Edge Function + `grant_ad_reward` SQL function

In the app, set your endpoint's URL as the **SSV callback** of each rewarded ad unit
in the AdMob console. The app already sends the `user_id` automatically (via
`setServerSideOptions`), so your endpoint knows who to credit.

Optional (so the app can show the balance):

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

---

## Security

`.gitignore` already excludes: `.env`, `.env.*`, `*.pem`, `*.keystore`.

Never commit credentials to the repository.
