Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/chat-quota-system/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-07
72 changes: 72 additions & 0 deletions openspec/changes/chat-quota-system/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
## Context

The server has introduced a monthly quota system for AI chat (text and voice). The app currently has no awareness of user plans or usage limits. The bot service (`src/services/botservice.ts`) sends requests to `POST /chat/text` and opens WebSockets to `/chat/voice-bot-websocket` without handling quota-related error codes. The Settings screen has no plan/usage information.

Current state:
- `botservice.ts` does not read rate-limit response headers
- No `planService` exists
- Text chat catches generic errors but not HTTP 429 specifically
- Voice WebSocket does not differentiate close code 4029 from other failures
- Settings screen has no plan section

## Goals / Non-Goals

**Goals:**
- Introduce `planService.ts` to fetch and cache `GET /auth/me/plan`
- Display plan, text/voice quotas with progress bars, and reset date in Settings
- Handle HTTP `429` in text chat with an inline, localized message and CTA
- Handle WS close `4029` and error frame in voice chat with a modal and CTA
- Show a live `X-RateLimit-Remaining` counter in the chat input area; disable send at 0
- Add all user-facing strings to `en.json` and `it.json`

**Non-Goals:**
- In-app upgrade/payment flow (CTA navigates to Plan screen only)
- Admin plan management UI
- Push notification when quota resets
- Caching plan data across app restarts (re-fetch on open is sufficient)

## Decisions

### D1 — New `planService.ts` instead of extending existing services
Quota data is orthogonal to tasks and auth. A dedicated service keeps concerns separate and is easier to test.
Alternative considered: extending `authService.ts`. Rejected because auth service is already complex and quota data has its own refresh lifecycle.

### D2 — Store remaining counter in React state, not AsyncStorage
`X-RateLimit-Remaining` is up-to-date after every message send. Persisting it across app restarts adds complexity for little gain; `GET /auth/me/plan` on next open gives accurate data.
Alternative: AsyncStorage persistence. Rejected as overkill.

### D3 — Inline error message in chat (not toast) for 429
The server spec explicitly requests an inline error message. This also provides a persistent, tappable CTA to the Plan screen.
Alternative: bottom toast. Rejected per server integration guide.

### D4 — Voice quota modal (not inline) for close 4029
Voice chat UI doesn't have a chat message thread. A modal is the appropriate pattern for a session-ending error.

### D5 — Threshold `>= 9999` treated as unlimited
ENTERPRISE plan returns `999999`. Any value >= 9999 is treated as unlimited in the UI (counter hidden, send never disabled).

### D6 — `X-RateLimit-Remaining` read from SSE/fetch response headers
`botservice.ts` already wraps the `/chat/text` call. The remaining count will be extracted from the response headers at that call site and returned alongside the message response so the caller can update UI state.

## Risks / Trade-offs

- [Risk] SSE streaming responses may not expose headers via the Fetch API in React Native → Mitigation: test on Android/iOS; fall back to `GET /auth/me/plan` if headers are inaccessible in the streaming path.
- [Risk] Plan screen navigation assumes a named route exists → Mitigation: verify route name in `RootStackParamList` before wiring CTAs; add route if missing.
- [Risk] WS close code `4029` may be swallowed by reconnect logic → Mitigation: check existing reconnect guard in `VoiceBotWebSocket` and add an explicit `4029` branch that skips reconnect.

## Migration Plan

No data migration needed. All changes are additive:
1. Add `planService.ts`
2. Update `botservice.ts` (header reading + 429 handling)
3. Update `VoiceBotWebSocket` in `botservice.ts` (4029 handling)
4. Add Plan section to Settings screen
5. Update BotChat screen (inline error + counter + disabled state)
6. Add i18n strings

Rollback: revert the above files. No server-side changes required from the client side.

## Open Questions

- Is there an existing "Plan" or "Subscription" screen in the navigation stack, or must it be created? (Affects CTA navigation target.)
- Does the voice chat UI live in a dedicated screen or is it a modal within BotChat? (Affects where to show the modal.)
34 changes: 34 additions & 0 deletions openspec/changes/chat-quota-system/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
## Why

The server now enforces monthly usage quotas on AI chat (text and voice) per user plan (FREE/PRO/ENTERPRISE). The client must surface quota status, handle exhaustion errors gracefully, and guide users toward upgrading — otherwise users will hit silent failures or confusing errors when their quota runs out.

## What Changes

- **New**: `GET /auth/me/plan` API call to fetch plan info and usage counters
- **New**: Plan & Usage UI section in Settings showing text/voice quotas with progress bars and reset date
- **Modified**: Text chat handler now catches HTTP `429` and shows an inline quota-exceeded message with CTA to Plan screen
- **Modified**: Voice WebSocket handler now catches close code `4029` and error frame `{"type":"error","message":"Voice request quota exceeded..."}`, showing a modal with CTA to Plan screen
- **New**: Live quota counter in chat UI reading `X-RateLimit-Remaining` from response headers; disables send button at 0
- **New**: `planService.ts` service for fetching and caching plan/quota data

## Capabilities

### New Capabilities

- `plan-usage`: Fetch and display user plan info, text/voice quota counters, and reset date from `GET /auth/me/plan`
- `chat-quota-enforcement`: Handle `429` HTTP errors in text chat and WS close code `4029` in voice chat with inline messages and Plan screen CTAs
- `quota-indicator`: Live remaining-messages counter in chat UI derived from `X-RateLimit-Remaining` response headers

### Modified Capabilities

<!-- No existing spec-level requirements are changing; new behavior is additive -->

## Impact

- `src/services/botservice.ts`: Add header reading for `X-RateLimit-Remaining`; handle `429` in text send; handle WS close `4029` and error frame in voice
- `src/navigation/screens/Settings.tsx`: Add Plan & Usage section with progress bars
- `src/navigation/screens/BotChat.tsx` (or equivalent): Show inline quota error, soft warning, disabled send button
- `src/navigation/screens/VoiceChat.tsx` (or equivalent): Show voice quota modal
- New file: `src/services/planService.ts` — wraps `GET /auth/me/plan`
- `src/locales/en.json` / `src/locales/it.json`: Add quota-related strings
- No new native dependencies required
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
## ADDED Requirements

### Requirement: Handle HTTP 429 in text chat
The text chat send handler SHALL catch HTTP `429` responses from `POST /chat/text` and display an inline error message in the chat thread. The message SHALL include the plan name and quota reset date. The system SHALL NOT retry automatically on 429.

#### Scenario: Text quota exceeded
- **WHEN** the user sends a text message and the server responds with HTTP `429`
- **THEN** an inline bot-style message appears in the chat: "Hai raggiunto il limite mensile di messaggi per il tuo piano {PLAN}. I contatori si resettano il {RESET_DATE}."
- **AND** the message includes a tappable CTA that navigates to the Plan & Usage screen
- **AND** the send button remains accessible (user is not locked out permanently)

#### Scenario: No automatic retry on 429
- **WHEN** the server returns `429`
- **THEN** the client does NOT resend the message automatically

### Requirement: Handle WebSocket close code 4029 in voice chat
The voice WebSocket handler SHALL detect the `{"type":"error","message":"Voice request quota exceeded..."}` frame and the close code `4029`, and SHALL display a quota-exceeded modal. The system SHALL NOT attempt automatic reconnection after a `4029` close.

#### Scenario: Voice quota exceeded — error frame received
- **WHEN** the WebSocket receives a JSON frame with `type === "error"` and a message containing "quota exceeded"
- **THEN** the voice session ends and a modal is shown: "Hai esaurito le richieste vocali mensili per il piano {PLAN}. Puoi continuare a usare la chat testuale."
- **AND** the modal includes a CTA to navigate to the Plan & Usage screen

#### Scenario: Voice quota exceeded — close code 4029
- **WHEN** the WebSocket closes with code `4029`
- **THEN** the voice session ends and the same quota-exceeded modal is shown
- **AND** the client does NOT attempt to reconnect

#### Scenario: Normal WebSocket close is unaffected
- **WHEN** the WebSocket closes with any code other than `4029`
- **THEN** existing reconnect and error-handling logic is unchanged
35 changes: 35 additions & 0 deletions openspec/changes/chat-quota-system/specs/plan-usage/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
## ADDED Requirements

### Requirement: Fetch user plan and quota data
The system SHALL provide a `planService.ts` that calls `GET /auth/me/plan` with the user's Bearer token and API key, returning plan name, text/voice usage counters, and reset date.

#### Scenario: Successful fetch
- **WHEN** `planService.getUserPlan()` is called with a valid token
- **THEN** it returns an object with `plan`, `text_messages_limit`, `text_messages_used`, `voice_requests_limit`, `voice_requests_used`, and `reset_date`

#### Scenario: Network or auth failure
- **WHEN** the request fails (network error or 401)
- **THEN** `planService.getUserPlan()` throws an error that the caller can handle

### Requirement: Display plan and usage in Settings
The Settings screen SHALL display a "Piano & Utilizzo" section that shows the user's current plan, text and voice usage progress bars, and the quota reset date. The section SHALL be populated by calling `planService.getUserPlan()` on screen mount.

#### Scenario: Data loaded successfully
- **WHEN** the Settings screen mounts and the plan fetch succeeds
- **THEN** the screen displays the plan badge (e.g. "FREE"), two progress bars (text messages and voice requests) with used/limit labels, and the reset date formatted as a localized date string

#### Scenario: FREE plan shown with upgrade CTA
- **WHEN** the fetched plan is `"FREE"`
- **THEN** an "Upgrade" CTA button is visible below the usage section

#### Scenario: ENTERPRISE plan — unlimited display
- **WHEN** either `text_messages_limit` or `voice_requests_limit` is >= 9999
- **THEN** the corresponding counter displays "Illimitato" instead of a numeric limit and the progress bar is hidden

#### Scenario: Loading state
- **WHEN** the plan fetch is in progress
- **THEN** placeholder/skeleton UI is shown for the usage section

#### Scenario: Fetch error
- **WHEN** the plan fetch fails
- **THEN** a retry button or error message is shown; the rest of Settings remains usable
28 changes: 28 additions & 0 deletions openspec/changes/chat-quota-system/specs/quota-indicator/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## ADDED Requirements

### Requirement: Live remaining-messages counter in chat UI
After each successful text message send, the chat screen SHALL read the `X-RateLimit-Remaining` header from the response, store it in local state, and display a subtle counter near the chat input. The counter SHALL be hidden for ENTERPRISE users (limit >= 9999).

#### Scenario: Counter updates after successful send
- **WHEN** `POST /chat/text` returns `200 OK` with an `X-RateLimit-Remaining` header
- **THEN** the counter in the chat UI updates to show "{N} messaggi rimasti questo mese"

#### Scenario: Counter hidden for ENTERPRISE users
- **WHEN** the user's `text_messages_limit` is >= 9999
- **THEN** no remaining-messages counter is displayed

#### Scenario: Soft warning at low quota
- **WHEN** `X-RateLimit-Remaining` is <= 5
- **THEN** the counter text changes to a warning style: "Ti restano solo {N} messaggi questo mese."

### Requirement: Disable send button at zero remaining
When `X-RateLimit-Remaining` reaches `0`, the chat send button SHALL be disabled and the exhausted state SHALL be shown (matching the inline error from the chat-quota-enforcement spec).

#### Scenario: Send disabled at quota zero
- **WHEN** `X-RateLimit-Remaining` is `0` (or the last response returned `429`)
- **THEN** the send button is visually disabled and tapping it does not send a message
- **AND** the inline quota-exceeded message is visible in the chat thread

#### Scenario: Send re-enabled after plan refresh
- **WHEN** the user navigates to the Plan & Usage screen and returns to chat
- **THEN** if the quota has been refreshed (new month), the send button becomes active again
32 changes: 32 additions & 0 deletions openspec/changes/chat-quota-system/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
## 1. Plan Service

- [x] 1.1 Create `src/services/planService.ts` with `getUserPlan()` that calls `GET /auth/me/plan` using the existing axios instance (with Bearer token and API key headers)
- [x] 1.2 Define and export `UserPlan` TypeScript interface matching the API response fields
- [x] 1.3 Add localized strings for plan/quota UI to `src/locales/en.json` and `src/locales/it.json` (plan badge labels, progress bar labels, reset date label, upgrade CTA, "Illimitato" text)

## 2. Settings — Plan & Usage Section

- [x] 2.1 Add a "Piano & Utilizzo" section to `src/navigation/screens/Settings.tsx` that calls `planService.getUserPlan()` on mount
- [x] 2.2 Render plan badge (FREE / PRO / ENTERPRISE), two progress bars (text messages and voice requests) with used/limit labels, and formatted reset date
- [x] 2.3 Show "Upgrade" CTA button when `plan === "FREE"`
- [x] 2.4 Display "Illimitato" and hide progress bar when limit >= 9999
- [x] 2.5 Handle loading and error states in the Settings section (skeleton UI + retry button)

## 3. Text Chat — 429 Handling

- [x] 3.1 In the text message send handler (BotChat screen or `botservice.ts`), catch HTTP `429` responses and extract plan name and reset date for the error message
- [x] 3.2 Insert an inline bot-style error message into the chat thread with the localized quota-exceeded text and a tappable CTA that navigates to the Plan & Usage screen (Settings)
- [x] 3.3 Ensure no automatic retry occurs on 429

## 4. Quota Indicator in Chat UI

- [x] 4.1 In `botservice.ts`, extract `X-RateLimit-Remaining` from the `POST /chat/text` response headers and return it alongside the message data
- [x] 4.2 In the BotChat screen, store `remainingMessages` in local state and update it after each successful send
- [x] 4.3 Render the remaining-messages counter near the chat input; apply warning style when <= 5; hide for ENTERPRISE (limit >= 9999)
- [x] 4.4 Disable the send button and show the exhausted state when `remainingMessages === 0` (or last response was 429)

## 5. Voice Chat — 4029 Handling

- [x] 5.1 In `VoiceBotWebSocket` (`src/services/botservice.ts`), add a handler for incoming JSON frames with `type === "error"` containing a quota-exceeded message
- [x] 5.2 Add a handler for WebSocket close code `4029` that suppresses automatic reconnection and invokes a `onVoiceQuotaExceeded` callback
- [x] 5.3 In the voice chat UI (VoiceChat screen or BotChat voice modal), show a modal with the localized voice-quota-exceeded message and a CTA to the Plan & Usage screen when `onVoiceQuotaExceeded` fires
18 changes: 10 additions & 8 deletions src/components/BotChat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import VoiceRecordButton from './VoiceRecordButton';

export interface ExtendedChatInputProps extends ChatInputProps {
modelType?: 'base' | 'advanced';
isDisabled?: boolean;
}

const ChatInput: React.FC<ExtendedChatInputProps> = ({
onSendMessage,
onSendVoiceMessage,
style,
modelType = 'base'
const ChatInput: React.FC<ExtendedChatInputProps> = ({
onSendMessage,
onSendVoiceMessage,
style,
modelType = 'base',
isDisabled = false,
}) => {
const [inputText, setInputText] = useState('');
const [inputHeight, setInputHeight] = useState(44);
Expand Down Expand Up @@ -104,7 +106,7 @@ const ChatInput: React.FC<ExtendedChatInputProps> = ({
onContentSizeChange={handleContentSizeChange}
blurOnSubmit={false}
textAlignVertical="top"
editable={!isRecording}
editable={!isRecording && !isDisabled}
/>

<View style={styles.buttonContainer}>
Expand All @@ -119,13 +121,13 @@ const ChatInput: React.FC<ExtendedChatInputProps> = ({
<TouchableOpacity
style={[styles.sendButton, { height: inputHeight }]}
onPress={handleSend}
disabled={inputText.trim() === '' || isRecording}
disabled={inputText.trim() === '' || isRecording || isDisabled}
activeOpacity={0.6}
>
<MaterialIcons
name="send"
size={24}
color={(inputText.trim() === '' || isRecording) ? '#CCC' : '#007bff'}
color={(inputText.trim() === '' || isRecording || isDisabled) ? '#CCC' : '#007bff'}
/>
</TouchableOpacity>
</View>
Expand Down
32 changes: 32 additions & 0 deletions src/components/BotChat/VoiceChatModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import { StatusBar } from 'expo-status-bar';
import Svg, { Path, Defs, RadialGradient, Stop } from "react-native-svg";
import { Ionicons } from "@expo/vector-icons";
import { useVoiceChat, ActiveTool } from '../../hooks/useVoiceChat';
import { useNavigation, NavigationProp } from '@react-navigation/native';
import { useTranslation } from 'react-i18next';
import { RootStackParamList } from '../../types';


export interface VoiceChatModalProps {
Expand Down Expand Up @@ -226,6 +229,8 @@ const VoiceChatModal: React.FC<VoiceChatModalProps> = ({
onOpenCalendar,
}) => {
const insets = useSafeAreaInsets();
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { t } = useTranslation();
const {
state,
error,
Expand All @@ -237,6 +242,7 @@ const VoiceChatModal: React.FC<VoiceChatModalProps> = ({
transcripts,
activeTools,
isMuted,
isVoiceQuotaExceeded,
connect,
disconnect,
requestPermissions,
Expand Down Expand Up @@ -353,6 +359,32 @@ const VoiceChatModal: React.FC<VoiceChatModalProps> = ({
await disconnect();
};

// Show quota-exceeded alert when voice quota is exhausted
useEffect(() => {
if (isVoiceQuotaExceeded && visible) {
const message = t('planUsage.voiceQuotaExceeded', { plan: 'FREE' });
Alert.alert(
t('planUsage.quotaZero'),
message,
[
{
text: t('common.buttons.cancel'),
style: 'cancel',
onPress: handleClose,
},
{
text: t('planUsage.goToPlan'),
onPress: () => {
handleClose();
navigation.navigate('Settings');
},
},
],
{ cancelable: false }
);
}
}, [isVoiceQuotaExceeded, visible]); // eslint-disable-line react-hooks/exhaustive-deps

// Label testo stato
const getStateLabel = (): string => {
switch (state) {
Expand Down
Loading
Loading