Compare commits
6 Commits
05ab1e10df
...
a560b0936c
| Author | SHA1 | Date | |
|---|---|---|---|
| a560b0936c | |||
| d9869bf6af | |||
| 3a7378d246 | |||
| fa7071def5 | |||
| 6de541848c | |||
| 6801001b64 |
@@ -352,6 +352,10 @@ const migrate = async () => {
|
||||
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_google_sub ON customers (google_sub) WHERE google_sub IS NOT NULL`
|
||||
await sql`CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_apple_sub ON customers (apple_sub) WHERE apple_sub IS NOT NULL`
|
||||
|
||||
// display_name is set after sign-in via the set-display-name screen for
|
||||
// direct phone/Google/Apple sign-ups (no anonymous bootstrap). Allow null.
|
||||
await sql`ALTER TABLE customers ALTER COLUMN display_name DROP NOT NULL`
|
||||
|
||||
// Control center users: password-based auth columns
|
||||
// firebase_uid stays for backward compat during migration; will be dropped in a later cleanup migration
|
||||
await sql`ALTER TABLE control_center_users ALTER COLUMN firebase_uid DROP NOT NULL`
|
||||
|
||||
@@ -14,10 +14,17 @@ const extractDeviceInfo = (request) => ({
|
||||
ip: request.ip || null,
|
||||
})
|
||||
|
||||
const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({
|
||||
success: false,
|
||||
error: { code: err.code || 'INTERNAL', message: err.message },
|
||||
})
|
||||
const sendAuthError = (reply, err) => {
|
||||
if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error')
|
||||
return reply.code(err.statusCode || 500).send({
|
||||
success: false,
|
||||
error: {
|
||||
code: err.code || 'INTERNAL',
|
||||
message: err.message,
|
||||
...(err.details && { details: err.details }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const cookieOpts = () => ({
|
||||
httpOnly: true,
|
||||
|
||||
@@ -13,10 +13,17 @@ const extractDeviceInfo = (request) => ({
|
||||
ip: request.ip || null,
|
||||
})
|
||||
|
||||
const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({
|
||||
success: false,
|
||||
error: { code: err.code || 'INTERNAL', message: err.message },
|
||||
})
|
||||
const sendAuthError = (reply, err) => {
|
||||
if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error')
|
||||
return reply.code(err.statusCode || 500).send({
|
||||
success: false,
|
||||
error: {
|
||||
code: err.code || 'INTERNAL',
|
||||
message: err.message,
|
||||
...(err.details && { details: err.details }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const clientAuthRoutes = async (app) => {
|
||||
// --- Phone OTP ---
|
||||
|
||||
@@ -9,10 +9,17 @@ const extractDeviceInfo = (request) => ({
|
||||
ip: request.ip || null,
|
||||
})
|
||||
|
||||
const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({
|
||||
success: false,
|
||||
error: { code: err.code || 'INTERNAL', message: err.message },
|
||||
})
|
||||
const sendAuthError = (reply, err) => {
|
||||
if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error')
|
||||
return reply.code(err.statusCode || 500).send({
|
||||
success: false,
|
||||
error: {
|
||||
code: err.code || 'INTERNAL',
|
||||
message: err.message,
|
||||
...(err.details && { details: err.details }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const mitraAuthRoutes = async (app) => {
|
||||
app.post('/otp/request', async (request, reply) => {
|
||||
|
||||
@@ -10,10 +10,17 @@ const extractDeviceInfo = (request) => ({
|
||||
ip: request.ip || null,
|
||||
})
|
||||
|
||||
const sendAuthError = (reply, err) => reply.code(err.statusCode || 500).send({
|
||||
success: false,
|
||||
error: { code: err.code || 'INTERNAL', message: err.message },
|
||||
})
|
||||
const sendAuthError = (reply, err) => {
|
||||
if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error')
|
||||
return reply.code(err.statusCode || 500).send({
|
||||
success: false,
|
||||
error: {
|
||||
code: err.code || 'INTERNAL',
|
||||
message: err.message,
|
||||
...(err.details && { details: err.details }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const sharedAuthRoutes = async (app) => {
|
||||
// Issue an anonymous customer session
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authenticate } from '../../plugins/auth.js'
|
||||
import { getMessages } from '../../services/chat.service.js'
|
||||
import { getSessionClosures } from '../../services/closure.service.js'
|
||||
import { getSessionClosures, hasUserSubmittedClosure } from '../../services/closure.service.js'
|
||||
import { registerDeviceToken } from '../../services/notification.service.js'
|
||||
import { flipSessionSensitivity } from '../../services/sensitivity.service.js'
|
||||
import { getDb } from '../../db/client.js'
|
||||
@@ -55,7 +55,8 @@ export const sharedChatRoutes = async (app) => {
|
||||
if (!session) {
|
||||
return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } })
|
||||
}
|
||||
return reply.send({ success: true, data: session })
|
||||
const goodbyeSubmittedByMe = await hasUserSubmittedClosure(sessionId, request.userType)
|
||||
return reply.send({ success: true, data: { ...session, goodbye_submitted_by_me: goodbyeSubmittedByMe } })
|
||||
})
|
||||
|
||||
// Get full transcript (read-only, for history)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { authenticate } from '../../plugins/auth.js'
|
||||
import { getAnonymityConfig, getSensitivityConfig } from '../../services/config.service.js'
|
||||
import { getAnonymityConfig, getSensitivityConfig, getOtpRateLimits } from '../../services/config.service.js'
|
||||
|
||||
export const sharedConfigRoutes = async (app) => {
|
||||
app.get('/anonymity', async (request, reply) => {
|
||||
@@ -11,4 +11,12 @@ export const sharedConfigRoutes = async (app) => {
|
||||
const config = await getSensitivityConfig()
|
||||
return reply.send({ success: true, data: config })
|
||||
})
|
||||
|
||||
app.get('/otp', async (request, reply) => {
|
||||
const limits = await getOtpRateLimits()
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: { resend_cooldown_seconds: limits.resend_cooldown_seconds },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -112,16 +112,18 @@ export const signInWithGoogle = async ({ idToken, anonymousCustomerId, deviceInf
|
||||
if (existing) {
|
||||
customer = existing
|
||||
} else if (anonymousCustomerId) {
|
||||
// Preserve the anonymous display_name; we don't pull name from Google.
|
||||
customer = await upgradeCustomerIdentity(anonymousCustomerId, {
|
||||
google_sub: google.sub,
|
||||
email: google.email,
|
||||
display_name: google.name,
|
||||
})
|
||||
} else {
|
||||
// No anonymous bootstrap → display_name is null; frontend routes to
|
||||
// the set-display-name screen.
|
||||
customer = await createCustomerWithIdentity({
|
||||
google_sub: google.sub,
|
||||
email: google.email,
|
||||
display_name: google.name,
|
||||
display_name: null,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -144,3 +144,12 @@ export const getSessionClosures = async (sessionId) => {
|
||||
ORDER BY created_at ASC
|
||||
`
|
||||
}
|
||||
|
||||
export const hasUserSubmittedClosure = async (sessionId, userType) => {
|
||||
const [row] = await sql`
|
||||
SELECT 1 FROM session_closures
|
||||
WHERE session_id = ${sessionId} AND user_type = ${userType}
|
||||
LIMIT 1
|
||||
`
|
||||
return !!row
|
||||
}
|
||||
|
||||
@@ -41,13 +41,24 @@ const fazpassVerifyStub = async ({ reference, code, expectedCode }) => {
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
export class OtpError extends Error {
|
||||
constructor(message, code, statusCode) {
|
||||
constructor(message, code, statusCode, details = null) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.statusCode = statusCode
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
// Returns seconds until the oldest of N most-recent matching requests falls
|
||||
// out of the 1-hour rolling window — i.e. when the next slot opens up.
|
||||
const computeRetryAfterFromRollingWindow = async (whereClauseFragment) => {
|
||||
const [row] = await whereClauseFragment
|
||||
if (!row) return null
|
||||
const oldestTs = new Date(row.created_at).getTime()
|
||||
const slotOpensAt = oldestTs + 60 * 60 * 1000
|
||||
return Math.max(1, Math.ceil((slotOpensAt - Date.now()) / 1000))
|
||||
}
|
||||
|
||||
const checkRateLimits = async ({ phone, ipAddress, limits }) => {
|
||||
// Resend cooldown
|
||||
const [lastRow] = await sql`
|
||||
@@ -58,9 +69,11 @@ const checkRateLimits = async ({ phone, ipAddress, limits }) => {
|
||||
if (lastRow) {
|
||||
const elapsed = (Date.now() - new Date(lastRow.created_at).getTime()) / 1000
|
||||
if (elapsed < limits.resend_cooldown_seconds) {
|
||||
const retryAfter = Math.ceil(limits.resend_cooldown_seconds - elapsed)
|
||||
throw new OtpError(
|
||||
`Please wait ${Math.ceil(limits.resend_cooldown_seconds - elapsed)}s before requesting another OTP`,
|
||||
`Please wait ${retryAfter}s before requesting another OTP`,
|
||||
'OTP_COOLDOWN', 429,
|
||||
{ retry_after_seconds: retryAfter },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -71,7 +84,15 @@ const checkRateLimits = async ({ phone, ipAddress, limits }) => {
|
||||
WHERE phone = ${phone} AND created_at >= NOW() - INTERVAL '1 hour'
|
||||
`
|
||||
if (phone_count >= limits.max_per_phone_per_hour) {
|
||||
throw new OtpError('Too many OTP requests for this number', 'OTP_RATE_LIMIT_PHONE', 429)
|
||||
const retryAfter = await computeRetryAfterFromRollingWindow(sql`
|
||||
SELECT created_at FROM otp_requests
|
||||
WHERE phone = ${phone} AND created_at >= NOW() - INTERVAL '1 hour'
|
||||
ORDER BY created_at ASC LIMIT 1
|
||||
`)
|
||||
throw new OtpError(
|
||||
'Too many OTP requests for this number', 'OTP_RATE_LIMIT_PHONE', 429,
|
||||
retryAfter ? { retry_after_seconds: retryAfter } : null,
|
||||
)
|
||||
}
|
||||
|
||||
// Per-IP hourly limit (only if ip provided)
|
||||
@@ -81,7 +102,15 @@ const checkRateLimits = async ({ phone, ipAddress, limits }) => {
|
||||
WHERE ip_address = ${ipAddress} AND created_at >= NOW() - INTERVAL '1 hour'
|
||||
`
|
||||
if (ip_count >= limits.max_per_ip_per_hour) {
|
||||
throw new OtpError('Too many OTP requests from this network', 'OTP_RATE_LIMIT_IP', 429)
|
||||
const retryAfter = await computeRetryAfterFromRollingWindow(sql`
|
||||
SELECT created_at FROM otp_requests
|
||||
WHERE ip_address = ${ipAddress} AND created_at >= NOW() - INTERVAL '1 hour'
|
||||
ORDER BY created_at ASC LIMIT 1
|
||||
`)
|
||||
throw new OtpError(
|
||||
'Too many OTP requests from this network', 'OTP_RATE_LIMIT_IP', 429,
|
||||
retryAfter ? { retry_after_seconds: retryAfter } : null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ const googleClient = new OAuth2Client()
|
||||
|
||||
/**
|
||||
* Verify a Google ID token against Google's JWKS.
|
||||
* Throws on invalid; returns { sub, email, email_verified, name } on success.
|
||||
* Throws on invalid; returns { sub, email, email_verified } on success.
|
||||
* Intentionally omits the user's name — call sign is set in-app.
|
||||
*/
|
||||
export const verifyGoogleIdToken = async (idToken) => {
|
||||
const audience = getGoogleClientIds()
|
||||
@@ -30,7 +31,6 @@ export const verifyGoogleIdToken = async (idToken) => {
|
||||
sub: payload.sub,
|
||||
email: payload.email,
|
||||
email_verified: payload.email_verified === true,
|
||||
name: payload.name,
|
||||
}
|
||||
} catch (err) {
|
||||
throw Object.assign(new Error(err.message || 'Invalid Google token'), {
|
||||
|
||||
@@ -10,6 +10,20 @@ import 'token_storage.dart';
|
||||
|
||||
part 'auth_notifier.g.dart';
|
||||
|
||||
// Error envelope — carries the user-facing message plus structured details
|
||||
// (error code, optional retry_after_seconds) so screens can gate CTAs after
|
||||
// rate-limit responses without re-parsing the message string.
|
||||
class AuthErrorInfo {
|
||||
final String message;
|
||||
final String? code;
|
||||
final int? retryAfterSeconds;
|
||||
|
||||
const AuthErrorInfo(this.message, {this.code, this.retryAfterSeconds});
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
// States
|
||||
|
||||
sealed class AuthData {
|
||||
@@ -217,9 +231,12 @@ class Auth extends _$Auth {
|
||||
channelUsed: data['channel_used'] as String?,
|
||||
));
|
||||
} on DioException catch (e) {
|
||||
state = AsyncError(_otpRequestMessage(e), StackTrace.current);
|
||||
state = AsyncError(_otpRequestErrorInfo(e), StackTrace.current);
|
||||
} catch (_) {
|
||||
state = AsyncError('Gagal mengirim OTP. Coba lagi.', StackTrace.current);
|
||||
state = AsyncError(
|
||||
const AuthErrorInfo('Gagal mengirim OTP. Coba lagi.'),
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,9 +256,12 @@ class Auth extends _$Auth {
|
||||
final profile = await _applyTokens(response);
|
||||
state = AsyncData(await _stateForProfile(profile));
|
||||
} on DioException catch (e) {
|
||||
state = AsyncError(_otpVerifyMessage(e), StackTrace.current);
|
||||
state = AsyncError(_otpVerifyErrorInfo(e), StackTrace.current);
|
||||
} catch (_) {
|
||||
state = AsyncError('Gagal verifikasi. Coba lagi.', StackTrace.current);
|
||||
state = AsyncError(
|
||||
const AuthErrorInfo('Gagal verifikasi. Coba lagi.'),
|
||||
StackTrace.current,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +316,7 @@ class Auth extends _$Auth {
|
||||
state = const AsyncLoading();
|
||||
try {
|
||||
final credential = await SignInWithApple.getAppleIDCredential(
|
||||
scopes: [AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName],
|
||||
scopes: [AppleIDAuthorizationScopes.email],
|
||||
);
|
||||
final idToken = credential.identityToken;
|
||||
if (idToken == null) {
|
||||
@@ -352,41 +372,40 @@ class Auth extends _$Auth {
|
||||
|
||||
// ---------------- Error-code mapping ----------------
|
||||
|
||||
String _otpRequestMessage(DioException e) {
|
||||
final code = e.response?.data?['error']?['code'] as String?;
|
||||
switch (code) {
|
||||
case 'PHONE_INVALID':
|
||||
return 'Nomor HP tidak valid.';
|
||||
case 'OTP_COOLDOWN':
|
||||
return e.response?.data?['error']?['message'] as String? ??
|
||||
'Tunggu sebentar sebelum minta OTP lagi.';
|
||||
case 'OTP_RATE_LIMIT_PHONE':
|
||||
case 'OTP_RATE_LIMIT_IP':
|
||||
return 'Terlalu banyak permintaan OTP. Coba lagi nanti.';
|
||||
default:
|
||||
return 'Gagal mengirim OTP. Coba lagi.';
|
||||
}
|
||||
int? _retryAfterSecondsFrom(DioException e) {
|
||||
final raw = e.response?.data?['error']?['details']?['retry_after_seconds'];
|
||||
if (raw is num) return raw.toInt();
|
||||
return null;
|
||||
}
|
||||
|
||||
String _otpVerifyMessage(DioException e) {
|
||||
AuthErrorInfo _otpRequestErrorInfo(DioException e) {
|
||||
final code = e.response?.data?['error']?['code'] as String?;
|
||||
switch (code) {
|
||||
case 'WRONG_FLOW':
|
||||
return 'OTP tidak valid untuk login pelanggan.';
|
||||
case 'CODE_MISMATCH':
|
||||
case 'CODE_INVALID':
|
||||
return 'Kode OTP salah.';
|
||||
case 'OTP_EXPIRED':
|
||||
return 'Kode OTP kedaluwarsa. Minta kode baru.';
|
||||
case 'OTP_USED':
|
||||
return 'Kode OTP sudah digunakan.';
|
||||
case 'OTP_ATTEMPTS_EXCEEDED':
|
||||
return 'Terlalu banyak percobaan. Minta kode baru.';
|
||||
case 'IDENTITY_CONFLICT':
|
||||
return 'Nomor ini sudah terdaftar di akun lain.';
|
||||
default:
|
||||
return 'Gagal verifikasi. Coba lagi.';
|
||||
}
|
||||
final retryAfter = _retryAfterSecondsFrom(e);
|
||||
final message = switch (code) {
|
||||
'PHONE_INVALID' => 'Nomor HP tidak valid.',
|
||||
'OTP_COOLDOWN' =>
|
||||
e.response?.data?['error']?['message'] as String? ??
|
||||
'Tunggu sebentar sebelum minta OTP lagi.',
|
||||
'OTP_RATE_LIMIT_PHONE' || 'OTP_RATE_LIMIT_IP' =>
|
||||
'Terlalu banyak permintaan OTP. Coba lagi nanti.',
|
||||
_ => 'Gagal mengirim OTP. Coba lagi.',
|
||||
};
|
||||
return AuthErrorInfo(message, code: code, retryAfterSeconds: retryAfter);
|
||||
}
|
||||
|
||||
AuthErrorInfo _otpVerifyErrorInfo(DioException e) {
|
||||
final code = e.response?.data?['error']?['code'] as String?;
|
||||
final retryAfter = _retryAfterSecondsFrom(e);
|
||||
final message = switch (code) {
|
||||
'WRONG_FLOW' => 'OTP tidak valid untuk login pelanggan.',
|
||||
'CODE_MISMATCH' || 'CODE_INVALID' => 'Kode OTP salah.',
|
||||
'OTP_EXPIRED' => 'Kode OTP kedaluwarsa. Minta kode baru.',
|
||||
'OTP_USED' => 'Kode OTP sudah digunakan.',
|
||||
'OTP_ATTEMPTS_EXCEEDED' => 'Terlalu banyak percobaan. Minta kode baru.',
|
||||
'IDENTITY_CONFLICT' => 'Nomor ini sudah terdaftar di akun lain.',
|
||||
_ => 'Gagal verifikasi. Coba lagi.',
|
||||
};
|
||||
return AuthErrorInfo(message, code: code, retryAfterSeconds: retryAfter);
|
||||
}
|
||||
|
||||
String _socialSignInMessage(DioException e) {
|
||||
|
||||
@@ -30,6 +30,7 @@ class ChatConnectedData extends ChatData {
|
||||
final bool sessionExpired;
|
||||
final bool sessionPaused;
|
||||
final bool sessionClosing;
|
||||
final bool goodbyeSubmitted;
|
||||
final Map<String, dynamic>? extensionResponse;
|
||||
|
||||
const ChatConnectedData({
|
||||
@@ -39,6 +40,7 @@ class ChatConnectedData extends ChatData {
|
||||
this.sessionExpired = false,
|
||||
this.sessionPaused = false,
|
||||
this.sessionClosing = false,
|
||||
this.goodbyeSubmitted = false,
|
||||
this.extensionResponse,
|
||||
});
|
||||
|
||||
@@ -49,6 +51,7 @@ class ChatConnectedData extends ChatData {
|
||||
bool? sessionExpired,
|
||||
bool? sessionPaused,
|
||||
bool? sessionClosing,
|
||||
bool? goodbyeSubmitted,
|
||||
Map<String, dynamic>? extensionResponse,
|
||||
}) {
|
||||
return ChatConnectedData(
|
||||
@@ -58,6 +61,7 @@ class ChatConnectedData extends ChatData {
|
||||
sessionExpired: sessionExpired ?? this.sessionExpired,
|
||||
sessionPaused: sessionPaused ?? this.sessionPaused,
|
||||
sessionClosing: sessionClosing ?? this.sessionClosing,
|
||||
goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted,
|
||||
extensionResponse: extensionResponse ?? this.extensionResponse,
|
||||
);
|
||||
}
|
||||
@@ -150,8 +154,12 @@ class Chat extends _$Chat {
|
||||
state = current.copyWith(sessionExpired: true);
|
||||
return;
|
||||
}
|
||||
final goodbyeSubmittedByMe = data?['goodbye_submitted_by_me'] as bool? ?? false;
|
||||
state = current.copyWith(
|
||||
sessionClosing: status == SessionStatus.closing,
|
||||
sessionPaused: status == SessionStatus.extending,
|
||||
sessionExpired: false,
|
||||
goodbyeSubmitted: goodbyeSubmittedByMe,
|
||||
);
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
@@ -175,6 +183,7 @@ class Chat extends _$Chat {
|
||||
}
|
||||
|
||||
final isClosing = sessionStatus == SessionStatus.closing;
|
||||
final goodbyeSubmittedByMe = sessionData?['goodbye_submitted_by_me'] as bool? ?? false;
|
||||
|
||||
final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
|
||||
final messagesData = response['data'] as List<dynamic>;
|
||||
@@ -215,6 +224,7 @@ class Chat extends _$Chat {
|
||||
state = ChatConnectedData(
|
||||
messages: messages,
|
||||
sessionClosing: isClosing,
|
||||
goodbyeSubmitted: goodbyeSubmittedByMe,
|
||||
);
|
||||
} catch (e) {
|
||||
state = const ChatErrorData('Gagal terhubung ke chat.');
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
/// Format a remaining-seconds countdown for display in a button or label.
|
||||
/// - Under 90 seconds: "Xd" (e.g. "60d")
|
||||
/// - 90 seconds and up: "Xm Yd" (e.g. "11m 40d")
|
||||
/// `d` and `m` are Indonesian short forms for detik (second) and menit (minute).
|
||||
String formatCountdown(int totalSeconds) {
|
||||
if (totalSeconds < 90) return '${totalSeconds}d';
|
||||
final minutes = totalSeconds ~/ 60;
|
||||
final seconds = totalSeconds % 60;
|
||||
return '${minutes}m ${seconds}d';
|
||||
}
|
||||
|
||||
/// User types
|
||||
class UserType {
|
||||
static const customer = 'customer';
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/api/api_client_provider.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/constants.dart';
|
||||
|
||||
const int _kOtpLength = 6;
|
||||
const int _kFallbackResendCooldownSeconds = 60;
|
||||
|
||||
const Color _kAccentPink = Color(0xFFBE7C8A);
|
||||
const Color _kBoxBorder = Color(0xFFE0E0E0);
|
||||
|
||||
class OtpScreen extends ConsumerStatefulWidget {
|
||||
final String phone;
|
||||
@@ -11,41 +21,151 @@ class OtpScreen extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
final _otpController = TextEditingController();
|
||||
final List<TextEditingController> _controllers =
|
||||
List.generate(_kOtpLength, (_) => TextEditingController());
|
||||
final List<FocusNode> _focusNodes =
|
||||
List.generate(_kOtpLength, (_) => FocusNode());
|
||||
|
||||
String? _otpRequestId;
|
||||
bool _autoSubmitted = false;
|
||||
String? _errorMessage;
|
||||
|
||||
int _resendSeconds = _kFallbackResendCooldownSeconds;
|
||||
int _resendCooldown = _kFallbackResendCooldownSeconds;
|
||||
Timer? _resendTimer;
|
||||
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Capture OTP request id from current state
|
||||
final data = ref.read(authProvider).valueOrNull;
|
||||
if (data is AuthOtpSentData) {
|
||||
_otpRequestId = data.otpRequestId;
|
||||
}
|
||||
if (data is AuthOtpSentData) _otpRequestId = data.otpRequestId;
|
||||
|
||||
// Register the auth listener ONCE — must NOT live in build(), or the
|
||||
// resend countdown's setState will pile up duplicate listeners every
|
||||
// second and the error toast will fire many times per state change.
|
||||
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
|
||||
if (next is AsyncError) {
|
||||
if (!mounted) return;
|
||||
final err = next.error;
|
||||
setState(() => _errorMessage = err.toString());
|
||||
_clearBoxes();
|
||||
// If the server says we're rate-limited, extend the resend countdown
|
||||
// to match — disables "Kirim ulang kode" until the lockout clears.
|
||||
if (err is AuthErrorInfo &&
|
||||
err.retryAfterSeconds != null &&
|
||||
(err.code == 'OTP_COOLDOWN' ||
|
||||
err.code == 'OTP_RATE_LIMIT_PHONE' ||
|
||||
err.code == 'OTP_RATE_LIMIT_IP')) {
|
||||
_resendCooldown = err.retryAfterSeconds!;
|
||||
_startResendCountdown();
|
||||
}
|
||||
} else if (next is AsyncLoading || next is AsyncData) {
|
||||
if (_errorMessage != null && mounted) {
|
||||
setState(() => _errorMessage = null);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
_fetchResendCooldown();
|
||||
_startResendCountdown();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _focusNodes.first.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_otpController.dispose();
|
||||
_authSub?.close();
|
||||
_resendTimer?.cancel();
|
||||
for (final c in _controllers) {
|
||||
c.dispose();
|
||||
}
|
||||
for (final f in _focusNodes) {
|
||||
f.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchResendCooldown() async {
|
||||
try {
|
||||
final response =
|
||||
await ref.read(apiClientProvider).get('/api/shared/config/otp');
|
||||
final data = response['data'] as Map<String, dynamic>?;
|
||||
final value = data?['resend_cooldown_seconds'] as int?;
|
||||
if (value != null && value > 0 && mounted) {
|
||||
setState(() {
|
||||
_resendCooldown = value;
|
||||
_resendSeconds = value;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// Stick with fallback.
|
||||
}
|
||||
}
|
||||
|
||||
void _startResendCountdown() {
|
||||
_resendTimer?.cancel();
|
||||
setState(() => _resendSeconds = _resendCooldown);
|
||||
_resendTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
if (_resendSeconds > 0) _resendSeconds--;
|
||||
if (_resendSeconds <= 0) timer.cancel();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
String _readCode() => _controllers.map((c) => c.text).join();
|
||||
|
||||
void _clearBoxes({bool refocusFirst = true}) {
|
||||
for (final c in _controllers) {
|
||||
c.clear();
|
||||
}
|
||||
_autoSubmitted = false;
|
||||
if (refocusFirst && mounted) _focusNodes.first.requestFocus();
|
||||
}
|
||||
|
||||
void _onDigitChanged(int index, String value) {
|
||||
// Move forward when a digit is entered, back when cleared.
|
||||
if (value.isNotEmpty && index < _kOtpLength - 1) {
|
||||
_focusNodes[index + 1].requestFocus();
|
||||
}
|
||||
if (value.isEmpty && index > 0) {
|
||||
_focusNodes[index - 1].requestFocus();
|
||||
}
|
||||
|
||||
final code = _readCode();
|
||||
if (code.length == _kOtpLength && !_autoSubmitted && _otpRequestId != null) {
|
||||
_autoSubmitted = true;
|
||||
// Keep keyboard open during verify — dismissing it caused a Scaffold
|
||||
// layout shift mid-snackbar-animation, which made the error toast
|
||||
// visually duplicate.
|
||||
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, code);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resend() async {
|
||||
if (_resendSeconds > 0) return;
|
||||
_clearBoxes();
|
||||
await ref.read(authProvider.notifier).requestOtp(widget.phone);
|
||||
if (!mounted) return;
|
||||
final next = ref.read(authProvider).valueOrNull;
|
||||
if (next is AuthOtpSentData) _otpRequestId = next.otpRequestId;
|
||||
_startResendCountdown();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
|
||||
// Update OTP request id if state changes (e.g. resend)
|
||||
final data = authState.valueOrNull;
|
||||
if (data is AuthOtpSentData) {
|
||||
_otpRequestId = data.otpRequestId;
|
||||
}
|
||||
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
if (next is AsyncError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||
}
|
||||
});
|
||||
if (data is AuthOtpSentData) _otpRequestId = data.otpRequestId;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Masukkan OTP')),
|
||||
@@ -55,30 +175,100 @@ class _OtpScreenState extends ConsumerState<OtpScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Kode OTP telah dikirim ke ${widget.phone}'),
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _otpController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Kode OTP',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 6,
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: List.generate(_kOtpLength, _buildBox),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : () {
|
||||
final otp = _otpController.text.trim();
|
||||
if (otp.length != 6 || _otpRequestId == null) return;
|
||||
ref.read(authProvider.notifier).verifyOtp(_otpRequestId!, otp);
|
||||
},
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Verifikasi'),
|
||||
),
|
||||
if (_errorMessage != null)
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.red.shade700, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (isLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildResendRow(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBox(int index) {
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 56,
|
||||
// Wrap with Focus to intercept hardware backspace BEFORE the TextField:
|
||||
// when the current box is empty, TextField.onChanged doesn't fire on
|
||||
// backspace, so we'd be stuck. We catch it here and rewind one box.
|
||||
child: Focus(
|
||||
canRequestFocus: false,
|
||||
onKeyEvent: (node, event) {
|
||||
if (event is KeyDownEvent &&
|
||||
event.logicalKey == LogicalKeyboardKey.backspace &&
|
||||
_controllers[index].text.isEmpty &&
|
||||
index > 0) {
|
||||
_controllers[index - 1].clear();
|
||||
_focusNodes[index - 1].requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
},
|
||||
child: TextField(
|
||||
controller: _controllers[index],
|
||||
focusNode: _focusNodes[index],
|
||||
autofocus: index == 0,
|
||||
keyboardType: TextInputType.number,
|
||||
textAlign: TextAlign.center,
|
||||
maxLength: 1,
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600),
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
contentPadding: EdgeInsets.zero,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: _kBoxBorder, width: 1.5),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: _kAccentPink, width: 2),
|
||||
),
|
||||
),
|
||||
onChanged: (v) => _onDigitChanged(index, v),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResendRow() {
|
||||
final canResend = _resendSeconds <= 0;
|
||||
return Center(
|
||||
child: canResend
|
||||
? GestureDetector(
|
||||
onTap: _resend,
|
||||
child: const Text(
|
||||
'Kirim ulang kode',
|
||||
style: TextStyle(
|
||||
color: _kAccentPink,
|
||||
fontWeight: FontWeight.w600,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
'Kirim ulang dalam ${formatCountdown(_resendSeconds)}',
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/auth/auth_notifier.dart';
|
||||
import '../../../core/auth/social_auth_enabled.dart';
|
||||
import '../../../core/constants.dart';
|
||||
|
||||
class RegisterScreen extends ConsumerStatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
@@ -13,27 +15,73 @@ class RegisterScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
final _phoneController = TextEditingController();
|
||||
ProviderSubscription<AsyncValue<AuthData>>? _authSub;
|
||||
|
||||
// Server-imposed lockout: when /otp/request returns 429, the backend
|
||||
// includes retry_after_seconds. We disable "Kirim OTP" for that window.
|
||||
int _lockoutSeconds = 0;
|
||||
Timer? _lockoutTimer;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Listener registered once in initState — keeps it independent of the
|
||||
// build cycle so it doesn't accumulate (see feedback_riverpod_listen_in_build).
|
||||
_authSub = ref.listenManual<AsyncValue<AuthData>>(authProvider, (prev, next) {
|
||||
if (!mounted) return;
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthOtpSentData) {
|
||||
// Use go (replace) so re-submitting the phone form doesn't stack
|
||||
// multiple OtpScreen instances with active listeners.
|
||||
context.go('/auth/otp', extra: _phoneController.text.trim());
|
||||
return;
|
||||
}
|
||||
if (next is AsyncError) {
|
||||
final err = next.error;
|
||||
setState(() => _errorMessage = err.toString());
|
||||
if (err is AuthErrorInfo &&
|
||||
err.retryAfterSeconds != null &&
|
||||
(err.code == 'OTP_COOLDOWN' ||
|
||||
err.code == 'OTP_RATE_LIMIT_PHONE' ||
|
||||
err.code == 'OTP_RATE_LIMIT_IP')) {
|
||||
_startLockout(err.retryAfterSeconds!);
|
||||
}
|
||||
} else if (next is AsyncData) {
|
||||
if (_errorMessage != null) setState(() => _errorMessage = null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authSub?.close();
|
||||
_lockoutTimer?.cancel();
|
||||
_phoneController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startLockout(int seconds) {
|
||||
_lockoutTimer?.cancel();
|
||||
setState(() => _lockoutSeconds = seconds);
|
||||
_lockoutTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
if (_lockoutSeconds > 0) _lockoutSeconds--;
|
||||
if (_lockoutSeconds <= 0) timer.cancel();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
final isLoading = authState is AsyncLoading;
|
||||
|
||||
ref.listen(authProvider, (prev, next) {
|
||||
final data = next.valueOrNull;
|
||||
if (data is AuthOtpSentData) {
|
||||
context.push('/auth/otp', extra: _phoneController.text.trim());
|
||||
}
|
||||
if (next is AsyncError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString())));
|
||||
}
|
||||
});
|
||||
final isLockedOut = _lockoutSeconds > 0;
|
||||
final canSubmit = !isLoading && !isLockedOut;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Masuk / Daftar')),
|
||||
@@ -76,15 +124,25 @@ class _RegisterScreenState extends ConsumerState<RegisterScreen> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : () {
|
||||
onPressed: canSubmit ? () {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) return;
|
||||
ref.read(authProvider.notifier).requestOtp(phone);
|
||||
},
|
||||
} : null,
|
||||
child: isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: const Text('Kirim OTP'),
|
||||
: Text(isLockedOut
|
||||
? 'Coba lagi dalam ${formatCountdown(_lockoutSeconds)}'
|
||||
: 'Kirim OTP'),
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.red.shade700, fontSize: 13),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -233,15 +233,23 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
// we mounted directly into a `closing` session (e.g. opened from history).
|
||||
// The chatProvider listener can't catch this case because it only fires on
|
||||
// transitions, not the current state at mount time.
|
||||
final shouldShowGoodbye = closureState is ClosureShowGoodbyeData ||
|
||||
closureState is ClosureSubmittingData ||
|
||||
(state.sessionClosing &&
|
||||
!state.sessionExpired &&
|
||||
closureState is! ClosureCompleteData);
|
||||
// Suppress when the customer has already submitted their goodbye — the
|
||||
// session can stay in `closing` while waiting for the mitra to submit
|
||||
// their own message or for the 5-min grace timer to auto-complete.
|
||||
final shouldShowGoodbye = !state.goodbyeSubmitted &&
|
||||
(closureState is ClosureShowGoodbyeData ||
|
||||
closureState is ClosureSubmittingData ||
|
||||
(state.sessionClosing &&
|
||||
!state.sessionExpired &&
|
||||
closureState is! ClosureCompleteData));
|
||||
if (shouldShowGoodbye) {
|
||||
return _buildGoodbyeView(closureState);
|
||||
}
|
||||
|
||||
if (state.sessionClosing && state.goodbyeSubmitted) {
|
||||
return _buildAwaitingMitraGoodbyeView(state);
|
||||
}
|
||||
|
||||
if (state.sessionPaused) {
|
||||
return _buildPausedView();
|
||||
}
|
||||
@@ -496,4 +504,54 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAwaitingMitraGoodbyeView(ChatConnectedData state) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: _kBgTint,
|
||||
child: Image.asset(
|
||||
'assets/images/chat_pattern.png',
|
||||
repeat: ImageRepeat.repeat,
|
||||
fit: BoxFit.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: _kEndedBannerColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.hourglass_top, color: _kEndedBannerText, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Pesan penutupmu sudah terkirim. Menunggu Bestie...',
|
||||
style: TextStyle(color: _kEndedBannerText, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: state.messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final msg = state.messages[index];
|
||||
final isMe = msg.senderType == UserType.customer;
|
||||
return _buildMessageBubble(msg, isMe);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,17 @@ class RouterNotifier extends ChangeNotifier {
|
||||
final Ref _ref;
|
||||
|
||||
RouterNotifier(this._ref) {
|
||||
_ref.listen(authProvider, (_, __) => notifyListeners());
|
||||
_ref.listen(authProvider, (prev, next) {
|
||||
// Errors are handled locally by screens (toast) — they should never
|
||||
// trigger router/Navigator rebuilds, otherwise the active SnackBar
|
||||
// re-animates and looks like a duplicate toast.
|
||||
if (next is AsyncError) return;
|
||||
// Skip transient AsyncLoading where the data variant didn't change.
|
||||
if (prev?.valueOrNull?.runtimeType == next.valueOrNull?.runtimeType) {
|
||||
return;
|
||||
}
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class MitraChatConnectedData extends MitraChatData {
|
||||
final int? remainingSeconds;
|
||||
final bool sessionExpired;
|
||||
final bool sessionClosing;
|
||||
final bool goodbyeSubmitted;
|
||||
final Map<String, dynamic>? extensionRequest;
|
||||
final TopicSensitivity topicSensitivity;
|
||||
|
||||
@@ -38,6 +39,7 @@ class MitraChatConnectedData extends MitraChatData {
|
||||
this.remainingSeconds,
|
||||
this.sessionExpired = false,
|
||||
this.sessionClosing = false,
|
||||
this.goodbyeSubmitted = false,
|
||||
this.extensionRequest,
|
||||
this.topicSensitivity = TopicSensitivity.regular,
|
||||
});
|
||||
@@ -48,6 +50,7 @@ class MitraChatConnectedData extends MitraChatData {
|
||||
int? remainingSeconds,
|
||||
bool? sessionExpired,
|
||||
bool? sessionClosing,
|
||||
bool? goodbyeSubmitted,
|
||||
Map<String, dynamic>? extensionRequest,
|
||||
bool clearExtensionRequest = false,
|
||||
TopicSensitivity? topicSensitivity,
|
||||
@@ -58,6 +61,7 @@ class MitraChatConnectedData extends MitraChatData {
|
||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||
sessionExpired: sessionExpired ?? this.sessionExpired,
|
||||
sessionClosing: sessionClosing ?? this.sessionClosing,
|
||||
goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted,
|
||||
extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest),
|
||||
topicSensitivity: topicSensitivity ?? this.topicSensitivity,
|
||||
);
|
||||
@@ -124,6 +128,7 @@ class MitraChat extends _$MitraChat {
|
||||
}
|
||||
|
||||
final isClosing = sessionStatus == SessionStatus.closing;
|
||||
final goodbyeSubmittedByMe = sessionData?['goodbye_submitted_by_me'] as bool? ?? false;
|
||||
final sessionTopic = TopicSensitivity.fromString(sessionData?['topic_sensitivity'] as String?);
|
||||
|
||||
final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
|
||||
@@ -162,7 +167,12 @@ class MitraChat extends _$MitraChat {
|
||||
'session_id': sessionId,
|
||||
}));
|
||||
|
||||
state = MitraChatConnectedData(messages: messages, sessionClosing: isClosing, topicSensitivity: sessionTopic);
|
||||
state = MitraChatConnectedData(
|
||||
messages: messages,
|
||||
sessionClosing: isClosing,
|
||||
goodbyeSubmitted: goodbyeSubmittedByMe,
|
||||
topicSensitivity: sessionTopic,
|
||||
);
|
||||
} catch (e) {
|
||||
state = const MitraChatErrorData('Gagal terhubung ke chat.');
|
||||
}
|
||||
|
||||
@@ -33,34 +33,6 @@ class _ActiveSessionsScreenState extends ConsumerState<ActiveSessionsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _endSession(String sessionId) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Akhiri Sesi?'),
|
||||
content: const Text('Apakah kamu yakin ingin mengakhiri sesi ini?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Batal')),
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text('Ya, Akhiri')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
try {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
await apiClient.post('/api/mitra/chat-requests/sessions/$sessionId/end');
|
||||
_loadSessions();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Gagal mengakhiri sesi.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -78,10 +50,7 @@ class _ActiveSessionsScreenState extends ConsumerState<ActiveSessionsScreen> {
|
||||
leading: const Icon(Icons.chat),
|
||||
title: Text(customerName),
|
||||
subtitle: Text('Status: ${session['status']}'),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _endSession(session['id'] as String),
|
||||
child: const Text('Akhiri', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.push(
|
||||
'/chat/session/${session['id']}',
|
||||
extra: {'customerName': customerName},
|
||||
|
||||
@@ -268,11 +268,19 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
||||
return _buildExtensionView(state.extensionRequest!, extState);
|
||||
}
|
||||
|
||||
// Goodbye view
|
||||
if (state.sessionClosing || extState is ExtensionShowGoodbyeData || extState is ExtensionSubmittingData) {
|
||||
// Goodbye view — suppress if mitra has already submitted their goodbye;
|
||||
// session can stay in `closing` while waiting for the customer to submit
|
||||
// theirs or for the 5-min grace timer to auto-complete.
|
||||
final showGoodbye = !state.goodbyeSubmitted &&
|
||||
(state.sessionClosing || extState is ExtensionShowGoodbyeData || extState is ExtensionSubmittingData);
|
||||
if (showGoodbye) {
|
||||
return _buildGoodbyeView(extState);
|
||||
}
|
||||
|
||||
if (state.sessionClosing && state.goodbyeSubmitted) {
|
||||
return _buildAwaitingCustomerGoodbyeView(state);
|
||||
}
|
||||
|
||||
final bgTint = SensitivityTheme.of(state.topicSensitivity).bgTint;
|
||||
|
||||
return Stack(
|
||||
@@ -544,4 +552,55 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAwaitingCustomerGoodbyeView(MitraChatConnectedData state) {
|
||||
final bgTint = SensitivityTheme.of(state.topicSensitivity).bgTint;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
color: bgTint,
|
||||
child: Image.asset(
|
||||
'assets/images/chat_pattern.png',
|
||||
repeat: ImageRepeat.repeat,
|
||||
fit: BoxFit.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Colors.amber.shade100,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.hourglass_top, color: Colors.amber.shade900, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Pesan penutupmu sudah terkirim. Menunggu user...',
|
||||
style: TextStyle(color: Colors.amber.shade900, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: state.messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final msg = state.messages[index];
|
||||
final isMe = msg.senderType == UserType.mitra;
|
||||
return _buildMessageBubble(msg, isMe);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
182
requirement/phase3.5.md
Normal file
182
requirement/phase3.5.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# PRD: Mitra Chat Request History
|
||||
|
||||
# Overview
|
||||
|
||||
**Goal:** Replace the home-screen "Pending Requests" banner on the mitra app with a single **"Riwayat Permintaan"** CTA that opens a list of the mitra's last 20 chat requests (regardless of status), and surfaces a count badge on the CTA whenever there are pending requests waiting for a response.
|
||||
|
||||
**Success looks like:** A mitra can open one screen from the home and see, at a glance, every chat request that was blasted to them recently — who requested, when, the topic flag, and what happened (accepted / declined / missed / ignored, or still pending). Pending requests appear at the top, can be acted on directly from this screen, and are visually obvious from the home thanks to a count badge on the CTA.
|
||||
|
||||
**Affects:** `mitra_app`, `backend`
|
||||
|
||||
## Background
|
||||
|
||||
- The mitra home today shows a `_PendingRequestsBanner` Card ([mitra_app/lib/features/home/home_screen.dart:128](mitra_app/lib/features/home/home_screen.dart#L128)) that displays the live count of pending requests and re-opens the incoming overlay on tap.
|
||||
- The backend already stores a per-mitra log row in `chat_request_notifications` for every blast, with `response` ∈ {`accepted`, `declined`, `missed`, `ignored`, `NULL`}.
|
||||
- That log is currently exposed only to the control center (`/internal/mitra-activity/log`); the mitra app sees only currently-pending pings via `GET /api/mitra/chat/pending`.
|
||||
- The system-wide "ringing phone" overlay that auto-pops on every incoming `CHAT_REQUEST` WebSocket message is **out of scope** for this change and stays as-is.
|
||||
|
||||
---
|
||||
|
||||
# Functional Requirement
|
||||
|
||||
## 1. Home Screen CTA
|
||||
|
||||
### Replaces
|
||||
- The existing `_PendingRequestsBanner` Card is removed.
|
||||
- A new **"Riwayat Permintaan"** Card replaces it in the same slot.
|
||||
|
||||
### Appearance
|
||||
- Card style consistent with the existing `Sesi Aktif` and `Riwayat Chat` Cards.
|
||||
- **Title:** "Riwayat Permintaan"
|
||||
- **Subtitle (no pending):** "Lihat permintaan chat sebelumnya"
|
||||
- **Subtitle (with pending):** "{N} permintaan baru" — same copy as today's banner.
|
||||
- **Trailing:** chevron (`>`) plus the badge below.
|
||||
|
||||
### Badge
|
||||
- A red **count** badge (matches existing pattern, not a dot) is shown on the trailing edge whenever pending count > 0.
|
||||
- Pending = `response IS NULL` for this mitra in `chat_request_notifications`, with the corresponding session still in `pending_acceptance`.
|
||||
- Badge disappears as soon as that count returns to zero.
|
||||
|
||||
### Tap
|
||||
- Navigates to the **Chat Request History screen** (Section 2).
|
||||
|
||||
---
|
||||
|
||||
## 2. Chat Request History Screen
|
||||
|
||||
### Route
|
||||
- New GoRouter route: `/chat/requests/history`.
|
||||
- Reachable only from the home CTA.
|
||||
|
||||
### Data
|
||||
- Last **20** rows from `chat_request_notifications` for the calling mitra, ordered by `notified_at DESC`.
|
||||
- No pagination, no filters, no search in this phase.
|
||||
|
||||
### List ordering
|
||||
- **Pending rows** (`response IS NULL` AND session still `pending_acceptance`) are pinned to the top, newest-first within the group.
|
||||
- All other rows follow, sorted by `notified_at DESC`.
|
||||
|
||||
### Row content
|
||||
Each row shows:
|
||||
|
||||
- **Customer call name** — always whatever the customer chose as their call sign (`call_name` / display name). If empty or if global `anonymity_enabled = false`, show **"Anonim"**. **Never display phone, email, social id, or internal user id.**
|
||||
- **Sensitive topic flag** — when `topic_sensitivity = 'sensitive'`, show the same warning-yellow **"Topik sensitif"** badge used on the incoming overlay (Phase 3.3).
|
||||
- **Status badge** with localized label and color:
|
||||
- Pending → yellow, **"Menunggu respon"**
|
||||
- Accepted → green, **"Diterima"**
|
||||
- Declined → gray, **"Ditolak"**
|
||||
- Missed → gray, **"Terlewat"** (another mitra accepted)
|
||||
- Ignored + session `cancelled` → gray, **"Dibatalkan"**
|
||||
- Ignored + session `expired` → gray, **"Kedaluwarsa"**
|
||||
- **Relative timestamp** of `notified_at` — "Baru saja", "2 menit lalu", "Kemarin", "3 hari lalu", etc.
|
||||
|
||||
### Empty state
|
||||
- Copy: **"Belum ada permintaan chat"**.
|
||||
|
||||
### Refresh
|
||||
- Pull-to-refresh (Material `RefreshIndicator`) re-calls the endpoint.
|
||||
- The screen does **not** auto-update when WebSocket events arrive while open. Pull-to-refresh is the only manual refresh affordance.
|
||||
- The home-screen badge count, however, stays live (driven by the same WS-backed provider that powers today's banner).
|
||||
|
||||
---
|
||||
|
||||
## 3. Tap Behavior on a Row
|
||||
|
||||
### Pending row (`response IS NULL` AND `session_status = 'pending_acceptance'`)
|
||||
- Re-opens the **existing incoming-request accept/decline overlay** for that session.
|
||||
- If the request is no longer actionable by the time the overlay opens (another mitra accepted, customer cancelled, 60s expired), the overlay opens in its standard closed state ("Permintaan sudah tidak tersedia") — no special handling needed.
|
||||
|
||||
### Non-pending row (any non-NULL `response`)
|
||||
- Opens a **read-only detail screen** showing:
|
||||
- Customer call name (or "Anonim")
|
||||
- Topic sensitivity
|
||||
- Status (localized label per Section 2)
|
||||
- `notified_at` (absolute date/time)
|
||||
- `responded_at` if present
|
||||
- For `accepted` rows only: a **"Lihat percakapan"** CTA that navigates to the existing chat-history transcript screen for that `session_id`.
|
||||
- For all other non-pending rows: no transcript link.
|
||||
|
||||
---
|
||||
|
||||
## 4. Live Behavior
|
||||
|
||||
### Incoming auto-overlay — unchanged
|
||||
- When a `CHAT_REQUEST` WebSocket message arrives, the system-wide overlay continues to pop up no matter which screen the mitra is on. This remains the primary "you have a new request" UX.
|
||||
|
||||
### Home CTA badge — live
|
||||
- Driven by the same Riverpod provider that powers today's `_PendingRequestsBanner` count.
|
||||
- Increments on `CHAT_REQUEST`, decrements on `CHAT_REQUEST_CLOSED` (accept-by-other / cancel / expiry) and on this mitra's own accept/decline.
|
||||
|
||||
### History screen — pull-only
|
||||
- No WS subscription. Refreshes only when the screen is opened or pulled-to-refresh.
|
||||
- Race conditions on accept are handled by the server's existing atomic `UPDATE … WHERE status = 'pending_acceptance'` — a stale "pending" row that's actually been taken simply fails the accept gracefully via the existing overlay flow.
|
||||
|
||||
---
|
||||
|
||||
## 5. Backend Changes
|
||||
|
||||
### New route
|
||||
- **`GET /api/mitra/chat/requests/recent`** (default and max `limit = 20`)
|
||||
- Auth: existing mitra JWT.
|
||||
- Strictly per-mitra-scoped — the calling mitra never sees other mitras' rows.
|
||||
|
||||
### Response shape (per row)
|
||||
```jsonc
|
||||
{
|
||||
"notification_id": "uuid",
|
||||
"session_id": "uuid",
|
||||
"notified_at": "ISO-8601",
|
||||
"responded_at": "ISO-8601 | null",
|
||||
"response": "accepted | declined | missed | ignored | null",
|
||||
"session_status": "pending_acceptance | active | closing | completed | cancelled | expired | ...",
|
||||
"customer_call_name": "string (already masked to 'Anonim' if anonymity rule applies)",
|
||||
"topic_sensitivity": "regular | sensitive"
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
- Reuse the SQL shape of `getMitraActivityLog` ([backend/src/services/mitra-activity.service.js:6-40](backend/src/services/mitra-activity.service.js#L6-L40)), scoped to `WHERE crn.mitra_id = <self>`, `ORDER BY crn.notified_at DESC`, `LIMIT 20`.
|
||||
- Apply the anonymity mask **server-side** so the mitra app never has to know the raw value: if `app_config.anonymity_enabled = false` OR the customer's call name is empty, return `"Anonim"`; never return phone, email, social id, or user id under any circumstance.
|
||||
|
||||
### No new tables, columns, or migrations.
|
||||
|
||||
---
|
||||
|
||||
## 6. Mitra App Changes
|
||||
|
||||
| Item | Detail |
|
||||
|---|---|
|
||||
| Removed | `_PendingRequestsBanner` Card in `home_screen.dart` |
|
||||
| Added | `_RequestHistoryButton` Card in `home_screen.dart`, with count badge |
|
||||
| Added | Chat Request History screen — `features/chat/screens/request_history_screen.dart` |
|
||||
| Added | Read-only detail screen — `features/chat/screens/request_history_detail_screen.dart` |
|
||||
| Reused | Incoming accept/decline overlay; existing chat-history transcript screen |
|
||||
| Routes | `/chat/requests/history`, `/chat/requests/history/:notificationId` |
|
||||
| Providers | New `requestHistoryProvider` (list state); reuses `chatRequestProvider` for the live badge count |
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge Cases
|
||||
|
||||
- **Stale pending row tapped after the request is gone** — overlay handles via existing closed-state UX.
|
||||
- **Anonymity flipped mid-fetch** — display rule is evaluated server-side at fetch time. Open lists do not retroactively update; next fetch reflects the new value.
|
||||
- **More than 20 rows** — older rows simply don't appear in this view. The control center retains full history.
|
||||
- **Mitra has zero notifications** — empty state per Section 2.
|
||||
- **Mitra offline** — list shows last fetched data (or empty if never fetched); pull-to-refresh required to recover.
|
||||
|
||||
---
|
||||
|
||||
## 8. Non-Goals (this phase)
|
||||
|
||||
- Pagination, infinite scroll, "load more" beyond 20 rows.
|
||||
- Filters by status / date / topic.
|
||||
- Search.
|
||||
- Push notification when a non-current request changes status.
|
||||
- Aggregated stats (acceptance rate, avg response time) — those stay in the control center.
|
||||
- Per-mitra notification preferences.
|
||||
|
||||
---
|
||||
|
||||
# Open Questions
|
||||
|
||||
_None — ready for plan document._
|
||||
57
requirement/phase3.6.md
Normal file
57
requirement/phase3.6.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# PRD: Mitra Force-Close — Re-enable Plan
|
||||
|
||||
# Overview
|
||||
|
||||
**Goal:** Restore the mitra-side ability to force-close (early-end) an active session, gated behind the existing backend `early_end_mitra_enabled` config flag.
|
||||
|
||||
**Status:** Currently **disabled in the mitra app** (button hidden). Backend support already exists and is gated by `app_config.early_end_mitra_enabled` (default `false`).
|
||||
|
||||
**Affects:** `mitra_app`, `control_center` (config UI), backend (no functional change — already implemented)
|
||||
|
||||
## Background
|
||||
|
||||
- Backend has `initiateEarlyEnd(sessionId, UserType.MITRA)` ([closure.service.js:88-137](backend/src/services/closure.service.js#L88-L137)) which moves an `active`/`extending` session to `closing` (with the 5-min grace + goodbye composer flow), guarded by the `early_end_mitra_enabled` flag.
|
||||
- The mitra app previously surfaced an **"Akhiri"** button on the **Sesi Aktif** screen ([mitra_app/lib/features/chat/screens/active_sessions_screen.dart](mitra_app/lib/features/chat/screens/active_sessions_screen.dart)) which called `POST /api/mitra/chat-requests/sessions/:id/end`.
|
||||
- The button (and the surrounding confirmation dialog) was **removed** during testing because the UX was abruptly terminating sessions without enough guardrails. The endpoint and its config flag were intentionally left in place on the backend.
|
||||
|
||||
---
|
||||
|
||||
# Functional Requirement
|
||||
|
||||
## 1. Visibility
|
||||
|
||||
- The "Akhiri" CTA on **Sesi Aktif** is rendered **only when** `app_config.early_end_mitra_enabled = true` (read at app startup via `/api/shared/config/...` or whichever config-fetch path the mitra app already uses).
|
||||
- When the flag is `false`, the row's trailing affordance is the chevron-only state (current state after this round of cleanup).
|
||||
|
||||
## 2. Confirmation UX
|
||||
|
||||
When re-enabling, restore the existing confirmation dialog:
|
||||
- **Title:** "Akhiri Sesi?"
|
||||
- **Body:** "Apakah kamu yakin ingin mengakhiri sesi ini?"
|
||||
- **Buttons:** Batal / Ya, Akhiri.
|
||||
|
||||
Open question for the next phase: do we want to require a **reason** from the mitra (free-text or radio list — e.g., "Customer abusive", "Customer left", "Out of scope")? If yes, capture it in `session_closures.reason` (new column) for downstream QC/auto-flag analytics.
|
||||
|
||||
## 3. Backend
|
||||
|
||||
- No new endpoint or service work required — `initiateEarlyEnd` already handles this.
|
||||
- If a reason field is added (Section 2 open question), extend the request body and persist it.
|
||||
|
||||
## 4. Control Center
|
||||
|
||||
- The existing config screen already exposes the toggle; no UI change needed unless reason capture is added.
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals (this phase)
|
||||
|
||||
- Adding a force-close from the chat screen (only Sesi Aktif).
|
||||
- Auto-flagging mitras with high force-close counts (covered by separate "Mitra QC Auto-Flag" memory item).
|
||||
- Customer-side force-close (separate `early_end_customer_enabled` flag, not in scope here).
|
||||
|
||||
---
|
||||
|
||||
# Open Questions
|
||||
|
||||
- Capture a reason on force-close? (Recommended yes — feeds into QC.)
|
||||
- Should there be a per-day cap on mitra force-closes before triggering CC review?
|
||||
Reference in New Issue
Block a user