Phase 3: closing-overlay fix + goodbye-composer dedupe
Customer chat refreshSessionStatus now clears sessionExpired carryover so the goodbye composer renders correctly when re-opening a closing session from history. Backend /api/shared/chat/:id/info returns goodbye_submitted_by_me; both apps suppress the composer for the side that has already submitted and render an awaiting-banner view instead. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { authenticate } from '../../plugins/auth.js'
|
import { authenticate } from '../../plugins/auth.js'
|
||||||
import { getMessages } from '../../services/chat.service.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 { registerDeviceToken } from '../../services/notification.service.js'
|
||||||
import { flipSessionSensitivity } from '../../services/sensitivity.service.js'
|
import { flipSessionSensitivity } from '../../services/sensitivity.service.js'
|
||||||
import { getDb } from '../../db/client.js'
|
import { getDb } from '../../db/client.js'
|
||||||
@@ -55,7 +55,8 @@ export const sharedChatRoutes = async (app) => {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
return reply.code(404).send({ success: false, error: { code: 'NOT_FOUND', message: 'Session not found' } })
|
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)
|
// Get full transcript (read-only, for history)
|
||||||
|
|||||||
@@ -144,3 +144,12 @@ export const getSessionClosures = async (sessionId) => {
|
|||||||
ORDER BY created_at ASC
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class ChatConnectedData extends ChatData {
|
|||||||
final bool sessionExpired;
|
final bool sessionExpired;
|
||||||
final bool sessionPaused;
|
final bool sessionPaused;
|
||||||
final bool sessionClosing;
|
final bool sessionClosing;
|
||||||
|
final bool goodbyeSubmitted;
|
||||||
final Map<String, dynamic>? extensionResponse;
|
final Map<String, dynamic>? extensionResponse;
|
||||||
|
|
||||||
const ChatConnectedData({
|
const ChatConnectedData({
|
||||||
@@ -39,6 +40,7 @@ class ChatConnectedData extends ChatData {
|
|||||||
this.sessionExpired = false,
|
this.sessionExpired = false,
|
||||||
this.sessionPaused = false,
|
this.sessionPaused = false,
|
||||||
this.sessionClosing = false,
|
this.sessionClosing = false,
|
||||||
|
this.goodbyeSubmitted = false,
|
||||||
this.extensionResponse,
|
this.extensionResponse,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ class ChatConnectedData extends ChatData {
|
|||||||
bool? sessionExpired,
|
bool? sessionExpired,
|
||||||
bool? sessionPaused,
|
bool? sessionPaused,
|
||||||
bool? sessionClosing,
|
bool? sessionClosing,
|
||||||
|
bool? goodbyeSubmitted,
|
||||||
Map<String, dynamic>? extensionResponse,
|
Map<String, dynamic>? extensionResponse,
|
||||||
}) {
|
}) {
|
||||||
return ChatConnectedData(
|
return ChatConnectedData(
|
||||||
@@ -58,6 +61,7 @@ class ChatConnectedData extends ChatData {
|
|||||||
sessionExpired: sessionExpired ?? this.sessionExpired,
|
sessionExpired: sessionExpired ?? this.sessionExpired,
|
||||||
sessionPaused: sessionPaused ?? this.sessionPaused,
|
sessionPaused: sessionPaused ?? this.sessionPaused,
|
||||||
sessionClosing: sessionClosing ?? this.sessionClosing,
|
sessionClosing: sessionClosing ?? this.sessionClosing,
|
||||||
|
goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted,
|
||||||
extensionResponse: extensionResponse ?? this.extensionResponse,
|
extensionResponse: extensionResponse ?? this.extensionResponse,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -150,8 +154,12 @@ class Chat extends _$Chat {
|
|||||||
state = current.copyWith(sessionExpired: true);
|
state = current.copyWith(sessionExpired: true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final goodbyeSubmittedByMe = data?['goodbye_submitted_by_me'] as bool? ?? false;
|
||||||
state = current.copyWith(
|
state = current.copyWith(
|
||||||
sessionClosing: status == SessionStatus.closing,
|
sessionClosing: status == SessionStatus.closing,
|
||||||
|
sessionPaused: status == SessionStatus.extending,
|
||||||
|
sessionExpired: false,
|
||||||
|
goodbyeSubmitted: goodbyeSubmittedByMe,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
@@ -175,6 +183,7 @@ class Chat extends _$Chat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isClosing = sessionStatus == SessionStatus.closing;
|
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 response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
|
||||||
final messagesData = response['data'] as List<dynamic>;
|
final messagesData = response['data'] as List<dynamic>;
|
||||||
@@ -215,6 +224,7 @@ class Chat extends _$Chat {
|
|||||||
state = ChatConnectedData(
|
state = ChatConnectedData(
|
||||||
messages: messages,
|
messages: messages,
|
||||||
sessionClosing: isClosing,
|
sessionClosing: isClosing,
|
||||||
|
goodbyeSubmitted: goodbyeSubmittedByMe,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = const ChatErrorData('Gagal terhubung ke chat.');
|
state = const ChatErrorData('Gagal terhubung ke chat.');
|
||||||
|
|||||||
@@ -233,15 +233,23 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
|
|||||||
// we mounted directly into a `closing` session (e.g. opened from history).
|
// 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
|
// The chatProvider listener can't catch this case because it only fires on
|
||||||
// transitions, not the current state at mount time.
|
// transitions, not the current state at mount time.
|
||||||
final shouldShowGoodbye = closureState is ClosureShowGoodbyeData ||
|
// 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 ||
|
closureState is ClosureSubmittingData ||
|
||||||
(state.sessionClosing &&
|
(state.sessionClosing &&
|
||||||
!state.sessionExpired &&
|
!state.sessionExpired &&
|
||||||
closureState is! ClosureCompleteData);
|
closureState is! ClosureCompleteData));
|
||||||
if (shouldShowGoodbye) {
|
if (shouldShowGoodbye) {
|
||||||
return _buildGoodbyeView(closureState);
|
return _buildGoodbyeView(closureState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.sessionClosing && state.goodbyeSubmitted) {
|
||||||
|
return _buildAwaitingMitraGoodbyeView(state);
|
||||||
|
}
|
||||||
|
|
||||||
if (state.sessionPaused) {
|
if (state.sessionPaused) {
|
||||||
return _buildPausedView();
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class MitraChatConnectedData extends MitraChatData {
|
|||||||
final int? remainingSeconds;
|
final int? remainingSeconds;
|
||||||
final bool sessionExpired;
|
final bool sessionExpired;
|
||||||
final bool sessionClosing;
|
final bool sessionClosing;
|
||||||
|
final bool goodbyeSubmitted;
|
||||||
final Map<String, dynamic>? extensionRequest;
|
final Map<String, dynamic>? extensionRequest;
|
||||||
final TopicSensitivity topicSensitivity;
|
final TopicSensitivity topicSensitivity;
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ class MitraChatConnectedData extends MitraChatData {
|
|||||||
this.remainingSeconds,
|
this.remainingSeconds,
|
||||||
this.sessionExpired = false,
|
this.sessionExpired = false,
|
||||||
this.sessionClosing = false,
|
this.sessionClosing = false,
|
||||||
|
this.goodbyeSubmitted = false,
|
||||||
this.extensionRequest,
|
this.extensionRequest,
|
||||||
this.topicSensitivity = TopicSensitivity.regular,
|
this.topicSensitivity = TopicSensitivity.regular,
|
||||||
});
|
});
|
||||||
@@ -48,6 +50,7 @@ class MitraChatConnectedData extends MitraChatData {
|
|||||||
int? remainingSeconds,
|
int? remainingSeconds,
|
||||||
bool? sessionExpired,
|
bool? sessionExpired,
|
||||||
bool? sessionClosing,
|
bool? sessionClosing,
|
||||||
|
bool? goodbyeSubmitted,
|
||||||
Map<String, dynamic>? extensionRequest,
|
Map<String, dynamic>? extensionRequest,
|
||||||
bool clearExtensionRequest = false,
|
bool clearExtensionRequest = false,
|
||||||
TopicSensitivity? topicSensitivity,
|
TopicSensitivity? topicSensitivity,
|
||||||
@@ -58,6 +61,7 @@ class MitraChatConnectedData extends MitraChatData {
|
|||||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||||
sessionExpired: sessionExpired ?? this.sessionExpired,
|
sessionExpired: sessionExpired ?? this.sessionExpired,
|
||||||
sessionClosing: sessionClosing ?? this.sessionClosing,
|
sessionClosing: sessionClosing ?? this.sessionClosing,
|
||||||
|
goodbyeSubmitted: goodbyeSubmitted ?? this.goodbyeSubmitted,
|
||||||
extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest),
|
extensionRequest: clearExtensionRequest ? null : (extensionRequest ?? this.extensionRequest),
|
||||||
topicSensitivity: topicSensitivity ?? this.topicSensitivity,
|
topicSensitivity: topicSensitivity ?? this.topicSensitivity,
|
||||||
);
|
);
|
||||||
@@ -124,6 +128,7 @@ class MitraChat extends _$MitraChat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final isClosing = sessionStatus == SessionStatus.closing;
|
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 sessionTopic = TopicSensitivity.fromString(sessionData?['topic_sensitivity'] as String?);
|
||||||
|
|
||||||
final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
|
final response = await _apiClient.get('/api/shared/chat/$sessionId/messages');
|
||||||
@@ -162,7 +167,12 @@ class MitraChat extends _$MitraChat {
|
|||||||
'session_id': sessionId,
|
'session_id': sessionId,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
state = MitraChatConnectedData(messages: messages, sessionClosing: isClosing, topicSensitivity: sessionTopic);
|
state = MitraChatConnectedData(
|
||||||
|
messages: messages,
|
||||||
|
sessionClosing: isClosing,
|
||||||
|
goodbyeSubmitted: goodbyeSubmittedByMe,
|
||||||
|
topicSensitivity: sessionTopic,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = const MitraChatErrorData('Gagal terhubung ke chat.');
|
state = const MitraChatErrorData('Gagal terhubung ke chat.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,11 +268,19 @@ class _MitraChatScreenState extends ConsumerState<MitraChatScreen> {
|
|||||||
return _buildExtensionView(state.extensionRequest!, extState);
|
return _buildExtensionView(state.extensionRequest!, extState);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Goodbye view
|
// Goodbye view — suppress if mitra has already submitted their goodbye;
|
||||||
if (state.sessionClosing || extState is ExtensionShowGoodbyeData || extState is ExtensionSubmittingData) {
|
// 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);
|
return _buildGoodbyeView(extState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state.sessionClosing && state.goodbyeSubmitted) {
|
||||||
|
return _buildAwaitingCustomerGoodbyeView(state);
|
||||||
|
}
|
||||||
|
|
||||||
final bgTint = SensitivityTheme.of(state.topicSensitivity).bgTint;
|
final bgTint = SensitivityTheme.of(state.topicSensitivity).bgTint;
|
||||||
|
|
||||||
return Stack(
|
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user