Skip to content

Commit ef35a4d

Browse files
authored
Merge pull request #21 from MyTaskly/feature/plan-limits
feat: sistema di quota e limiti piano
2 parents 3409f44 + e2dae20 commit ef35a4d

17 files changed

Lines changed: 793 additions & 85 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
schema: spec-driven
2+
created: 2026-04-07
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
## Context
2+
3+
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.
4+
5+
Current state:
6+
- `botservice.ts` does not read rate-limit response headers
7+
- No `planService` exists
8+
- Text chat catches generic errors but not HTTP 429 specifically
9+
- Voice WebSocket does not differentiate close code 4029 from other failures
10+
- Settings screen has no plan section
11+
12+
## Goals / Non-Goals
13+
14+
**Goals:**
15+
- Introduce `planService.ts` to fetch and cache `GET /auth/me/plan`
16+
- Display plan, text/voice quotas with progress bars, and reset date in Settings
17+
- Handle HTTP `429` in text chat with an inline, localized message and CTA
18+
- Handle WS close `4029` and error frame in voice chat with a modal and CTA
19+
- Show a live `X-RateLimit-Remaining` counter in the chat input area; disable send at 0
20+
- Add all user-facing strings to `en.json` and `it.json`
21+
22+
**Non-Goals:**
23+
- In-app upgrade/payment flow (CTA navigates to Plan screen only)
24+
- Admin plan management UI
25+
- Push notification when quota resets
26+
- Caching plan data across app restarts (re-fetch on open is sufficient)
27+
28+
## Decisions
29+
30+
### D1 — New `planService.ts` instead of extending existing services
31+
Quota data is orthogonal to tasks and auth. A dedicated service keeps concerns separate and is easier to test.
32+
Alternative considered: extending `authService.ts`. Rejected because auth service is already complex and quota data has its own refresh lifecycle.
33+
34+
### D2 — Store remaining counter in React state, not AsyncStorage
35+
`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.
36+
Alternative: AsyncStorage persistence. Rejected as overkill.
37+
38+
### D3 — Inline error message in chat (not toast) for 429
39+
The server spec explicitly requests an inline error message. This also provides a persistent, tappable CTA to the Plan screen.
40+
Alternative: bottom toast. Rejected per server integration guide.
41+
42+
### D4 — Voice quota modal (not inline) for close 4029
43+
Voice chat UI doesn't have a chat message thread. A modal is the appropriate pattern for a session-ending error.
44+
45+
### D5 — Threshold `>= 9999` treated as unlimited
46+
ENTERPRISE plan returns `999999`. Any value >= 9999 is treated as unlimited in the UI (counter hidden, send never disabled).
47+
48+
### D6 — `X-RateLimit-Remaining` read from SSE/fetch response headers
49+
`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.
50+
51+
## Risks / Trade-offs
52+
53+
- [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.
54+
- [Risk] Plan screen navigation assumes a named route exists → Mitigation: verify route name in `RootStackParamList` before wiring CTAs; add route if missing.
55+
- [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.
56+
57+
## Migration Plan
58+
59+
No data migration needed. All changes are additive:
60+
1. Add `planService.ts`
61+
2. Update `botservice.ts` (header reading + 429 handling)
62+
3. Update `VoiceBotWebSocket` in `botservice.ts` (4029 handling)
63+
4. Add Plan section to Settings screen
64+
5. Update BotChat screen (inline error + counter + disabled state)
65+
6. Add i18n strings
66+
67+
Rollback: revert the above files. No server-side changes required from the client side.
68+
69+
## Open Questions
70+
71+
- Is there an existing "Plan" or "Subscription" screen in the navigation stack, or must it be created? (Affects CTA navigation target.)
72+
- Does the voice chat UI live in a dedicated screen or is it a modal within BotChat? (Affects where to show the modal.)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
## Why
2+
3+
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.
4+
5+
## What Changes
6+
7+
- **New**: `GET /auth/me/plan` API call to fetch plan info and usage counters
8+
- **New**: Plan & Usage UI section in Settings showing text/voice quotas with progress bars and reset date
9+
- **Modified**: Text chat handler now catches HTTP `429` and shows an inline quota-exceeded message with CTA to Plan screen
10+
- **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
11+
- **New**: Live quota counter in chat UI reading `X-RateLimit-Remaining` from response headers; disables send button at 0
12+
- **New**: `planService.ts` service for fetching and caching plan/quota data
13+
14+
## Capabilities
15+
16+
### New Capabilities
17+
18+
- `plan-usage`: Fetch and display user plan info, text/voice quota counters, and reset date from `GET /auth/me/plan`
19+
- `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
20+
- `quota-indicator`: Live remaining-messages counter in chat UI derived from `X-RateLimit-Remaining` response headers
21+
22+
### Modified Capabilities
23+
24+
<!-- No existing spec-level requirements are changing; new behavior is additive -->
25+
26+
## Impact
27+
28+
- `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
29+
- `src/navigation/screens/Settings.tsx`: Add Plan & Usage section with progress bars
30+
- `src/navigation/screens/BotChat.tsx` (or equivalent): Show inline quota error, soft warning, disabled send button
31+
- `src/navigation/screens/VoiceChat.tsx` (or equivalent): Show voice quota modal
32+
- New file: `src/services/planService.ts` — wraps `GET /auth/me/plan`
33+
- `src/locales/en.json` / `src/locales/it.json`: Add quota-related strings
34+
- No new native dependencies required
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Handle HTTP 429 in text chat
4+
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.
5+
6+
#### Scenario: Text quota exceeded
7+
- **WHEN** the user sends a text message and the server responds with HTTP `429`
8+
- **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}."
9+
- **AND** the message includes a tappable CTA that navigates to the Plan & Usage screen
10+
- **AND** the send button remains accessible (user is not locked out permanently)
11+
12+
#### Scenario: No automatic retry on 429
13+
- **WHEN** the server returns `429`
14+
- **THEN** the client does NOT resend the message automatically
15+
16+
### Requirement: Handle WebSocket close code 4029 in voice chat
17+
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.
18+
19+
#### Scenario: Voice quota exceeded — error frame received
20+
- **WHEN** the WebSocket receives a JSON frame with `type === "error"` and a message containing "quota exceeded"
21+
- **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."
22+
- **AND** the modal includes a CTA to navigate to the Plan & Usage screen
23+
24+
#### Scenario: Voice quota exceeded — close code 4029
25+
- **WHEN** the WebSocket closes with code `4029`
26+
- **THEN** the voice session ends and the same quota-exceeded modal is shown
27+
- **AND** the client does NOT attempt to reconnect
28+
29+
#### Scenario: Normal WebSocket close is unaffected
30+
- **WHEN** the WebSocket closes with any code other than `4029`
31+
- **THEN** existing reconnect and error-handling logic is unchanged
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Fetch user plan and quota data
4+
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.
5+
6+
#### Scenario: Successful fetch
7+
- **WHEN** `planService.getUserPlan()` is called with a valid token
8+
- **THEN** it returns an object with `plan`, `text_messages_limit`, `text_messages_used`, `voice_requests_limit`, `voice_requests_used`, and `reset_date`
9+
10+
#### Scenario: Network or auth failure
11+
- **WHEN** the request fails (network error or 401)
12+
- **THEN** `planService.getUserPlan()` throws an error that the caller can handle
13+
14+
### Requirement: Display plan and usage in Settings
15+
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.
16+
17+
#### Scenario: Data loaded successfully
18+
- **WHEN** the Settings screen mounts and the plan fetch succeeds
19+
- **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
20+
21+
#### Scenario: FREE plan shown with upgrade CTA
22+
- **WHEN** the fetched plan is `"FREE"`
23+
- **THEN** an "Upgrade" CTA button is visible below the usage section
24+
25+
#### Scenario: ENTERPRISE plan — unlimited display
26+
- **WHEN** either `text_messages_limit` or `voice_requests_limit` is >= 9999
27+
- **THEN** the corresponding counter displays "Illimitato" instead of a numeric limit and the progress bar is hidden
28+
29+
#### Scenario: Loading state
30+
- **WHEN** the plan fetch is in progress
31+
- **THEN** placeholder/skeleton UI is shown for the usage section
32+
33+
#### Scenario: Fetch error
34+
- **WHEN** the plan fetch fails
35+
- **THEN** a retry button or error message is shown; the rest of Settings remains usable
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
## ADDED Requirements
2+
3+
### Requirement: Live remaining-messages counter in chat UI
4+
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).
5+
6+
#### Scenario: Counter updates after successful send
7+
- **WHEN** `POST /chat/text` returns `200 OK` with an `X-RateLimit-Remaining` header
8+
- **THEN** the counter in the chat UI updates to show "{N} messaggi rimasti questo mese"
9+
10+
#### Scenario: Counter hidden for ENTERPRISE users
11+
- **WHEN** the user's `text_messages_limit` is >= 9999
12+
- **THEN** no remaining-messages counter is displayed
13+
14+
#### Scenario: Soft warning at low quota
15+
- **WHEN** `X-RateLimit-Remaining` is <= 5
16+
- **THEN** the counter text changes to a warning style: "Ti restano solo {N} messaggi questo mese."
17+
18+
### Requirement: Disable send button at zero remaining
19+
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).
20+
21+
#### Scenario: Send disabled at quota zero
22+
- **WHEN** `X-RateLimit-Remaining` is `0` (or the last response returned `429`)
23+
- **THEN** the send button is visually disabled and tapping it does not send a message
24+
- **AND** the inline quota-exceeded message is visible in the chat thread
25+
26+
#### Scenario: Send re-enabled after plan refresh
27+
- **WHEN** the user navigates to the Plan & Usage screen and returns to chat
28+
- **THEN** if the quota has been refreshed (new month), the send button becomes active again
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
## 1. Plan Service
2+
3+
- [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)
4+
- [x] 1.2 Define and export `UserPlan` TypeScript interface matching the API response fields
5+
- [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)
6+
7+
## 2. Settings — Plan & Usage Section
8+
9+
- [x] 2.1 Add a "Piano & Utilizzo" section to `src/navigation/screens/Settings.tsx` that calls `planService.getUserPlan()` on mount
10+
- [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
11+
- [x] 2.3 Show "Upgrade" CTA button when `plan === "FREE"`
12+
- [x] 2.4 Display "Illimitato" and hide progress bar when limit >= 9999
13+
- [x] 2.5 Handle loading and error states in the Settings section (skeleton UI + retry button)
14+
15+
## 3. Text Chat — 429 Handling
16+
17+
- [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
18+
- [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)
19+
- [x] 3.3 Ensure no automatic retry occurs on 429
20+
21+
## 4. Quota Indicator in Chat UI
22+
23+
- [x] 4.1 In `botservice.ts`, extract `X-RateLimit-Remaining` from the `POST /chat/text` response headers and return it alongside the message data
24+
- [x] 4.2 In the BotChat screen, store `remainingMessages` in local state and update it after each successful send
25+
- [x] 4.3 Render the remaining-messages counter near the chat input; apply warning style when <= 5; hide for ENTERPRISE (limit >= 9999)
26+
- [x] 4.4 Disable the send button and show the exhausted state when `remainingMessages === 0` (or last response was 429)
27+
28+
## 5. Voice Chat — 4029 Handling
29+
30+
- [x] 5.1 In `VoiceBotWebSocket` (`src/services/botservice.ts`), add a handler for incoming JSON frames with `type === "error"` containing a quota-exceeded message
31+
- [x] 5.2 Add a handler for WebSocket close code `4029` that suppresses automatic reconnection and invokes a `onVoiceQuotaExceeded` callback
32+
- [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

src/components/BotChat/ChatInput.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import VoiceRecordButton from './VoiceRecordButton';
66

77
export interface ExtendedChatInputProps extends ChatInputProps {
88
modelType?: 'base' | 'advanced';
9+
isDisabled?: boolean;
910
}
1011

11-
const ChatInput: React.FC<ExtendedChatInputProps> = ({
12-
onSendMessage,
13-
onSendVoiceMessage,
14-
style,
15-
modelType = 'base'
12+
const ChatInput: React.FC<ExtendedChatInputProps> = ({
13+
onSendMessage,
14+
onSendVoiceMessage,
15+
style,
16+
modelType = 'base',
17+
isDisabled = false,
1618
}) => {
1719
const [inputText, setInputText] = useState('');
1820
const [inputHeight, setInputHeight] = useState(44);
@@ -104,7 +106,7 @@ const ChatInput: React.FC<ExtendedChatInputProps> = ({
104106
onContentSizeChange={handleContentSizeChange}
105107
blurOnSubmit={false}
106108
textAlignVertical="top"
107-
editable={!isRecording}
109+
editable={!isRecording && !isDisabled}
108110
/>
109111

110112
<View style={styles.buttonContainer}>
@@ -119,13 +121,13 @@ const ChatInput: React.FC<ExtendedChatInputProps> = ({
119121
<TouchableOpacity
120122
style={[styles.sendButton, { height: inputHeight }]}
121123
onPress={handleSend}
122-
disabled={inputText.trim() === '' || isRecording}
124+
disabled={inputText.trim() === '' || isRecording || isDisabled}
123125
activeOpacity={0.6}
124126
>
125127
<MaterialIcons
126128
name="send"
127129
size={24}
128-
color={(inputText.trim() === '' || isRecording) ? '#CCC' : '#007bff'}
130+
color={(inputText.trim() === '' || isRecording || isDisabled) ? '#CCC' : '#007bff'}
129131
/>
130132
</TouchableOpacity>
131133
</View>

src/components/BotChat/VoiceChatModal.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import { StatusBar } from 'expo-status-bar';
1616
import Svg, { Path, Defs, RadialGradient, Stop } from "react-native-svg";
1717
import { Ionicons } from "@expo/vector-icons";
1818
import { useVoiceChat, ActiveTool } from '../../hooks/useVoiceChat';
19+
import { useNavigation, NavigationProp } from '@react-navigation/native';
20+
import { useTranslation } from 'react-i18next';
21+
import { RootStackParamList } from '../../types';
1922

2023

2124
export interface VoiceChatModalProps {
@@ -226,6 +229,8 @@ const VoiceChatModal: React.FC<VoiceChatModalProps> = ({
226229
onOpenCalendar,
227230
}) => {
228231
const insets = useSafeAreaInsets();
232+
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
233+
const { t } = useTranslation();
229234
const {
230235
state,
231236
error,
@@ -237,6 +242,7 @@ const VoiceChatModal: React.FC<VoiceChatModalProps> = ({
237242
transcripts,
238243
activeTools,
239244
isMuted,
245+
isVoiceQuotaExceeded,
240246
connect,
241247
disconnect,
242248
requestPermissions,
@@ -353,6 +359,32 @@ const VoiceChatModal: React.FC<VoiceChatModalProps> = ({
353359
await disconnect();
354360
};
355361

362+
// Show quota-exceeded alert when voice quota is exhausted
363+
useEffect(() => {
364+
if (isVoiceQuotaExceeded && visible) {
365+
const message = t('planUsage.voiceQuotaExceeded', { plan: 'FREE' });
366+
Alert.alert(
367+
t('planUsage.quotaZero'),
368+
message,
369+
[
370+
{
371+
text: t('common.buttons.cancel'),
372+
style: 'cancel',
373+
onPress: handleClose,
374+
},
375+
{
376+
text: t('planUsage.goToPlan'),
377+
onPress: () => {
378+
handleClose();
379+
navigation.navigate('Settings');
380+
},
381+
},
382+
],
383+
{ cancelable: false }
384+
);
385+
}
386+
}, [isVoiceQuotaExceeded, visible]); // eslint-disable-line react-hooks/exhaustive-deps
387+
356388
// Label testo stato
357389
const getStateLabel = (): string => {
358390
switch (state) {

0 commit comments

Comments
 (0)