diff --git a/backend/src/routes/public/client.auth.routes.js b/backend/src/routes/public/client.auth.routes.js index a4625e2..2726814 100644 --- a/backend/src/routes/public/client.auth.routes.js +++ b/backend/src/routes/public/client.auth.routes.js @@ -1,8 +1,18 @@ import { authenticate } from '../../plugins/auth.js' -import { getCustomerByFirebaseUid } from '../../services/customer.service.js' +import { getOrCreateCustomer, getCustomerByFirebaseUid, updateCustomerDisplayName } from '../../services/customer.service.js' export const clientAuthRoutes = async (app) => { app.post('/verify', { preHandler: authenticate }, async (request, reply) => { + const { uid, phone_number, name } = request.firebaseUser + const customer = await getOrCreateCustomer({ + firebase_uid: uid, + phone: phone_number || null, + display_name: name || null, + }) + return reply.send({ success: true, data: customer }) + }) + + app.patch('/profile', { preHandler: authenticate }, async (request, reply) => { const customer = await getCustomerByFirebaseUid(request.firebaseUser.uid) if (!customer) { return reply.code(404).send({ @@ -10,6 +20,14 @@ export const clientAuthRoutes = async (app) => { error: { code: 'NOT_FOUND', message: 'Customer account not found' }, }) } - return reply.send({ success: true, data: customer }) + const { display_name } = request.body || {} + if (!display_name || typeof display_name !== 'string' || display_name.trim().length === 0) { + return reply.code(422).send({ + success: false, + error: { code: 'VALIDATION_ERROR', message: 'display_name is required' }, + }) + } + const updated = await updateCustomerDisplayName(customer.id, display_name.trim()) + return reply.send({ success: true, data: updated }) }) } diff --git a/backend/src/services/customer.service.js b/backend/src/services/customer.service.js index 70b7fc5..7aa6bd0 100644 --- a/backend/src/services/customer.service.js +++ b/backend/src/services/customer.service.js @@ -48,3 +48,40 @@ export const getCustomerByFirebaseUid = async (firebase_uid) => { ` return customer } + +export const getOrCreateCustomer = async ({ firebase_uid, phone, display_name }) => { + // Return existing customer if already linked to this Firebase UID + const existing = await getCustomerByFirebaseUid(firebase_uid) + if (existing) return existing + + // Check if a customer with this phone already exists (re-login with new Firebase UID) + if (phone) { + const [byPhone] = await sql` + SELECT id, display_name, is_anonymous, phone, created_at + FROM customers WHERE phone = ${phone} + ` + if (byPhone) { + // Link the new Firebase UID to the existing phone-based customer + await sql`UPDATE customers SET firebase_uid = ${firebase_uid} WHERE id = ${byPhone.id}` + return { ...byPhone, firebase_uid } + } + } + + // Auto-create a registered (non-anonymous) customer for phone/social login + // display_name is null — user must set it on first login + const [customer] = await sql` + INSERT INTO customers (firebase_uid, phone, display_name, is_anonymous) + VALUES (${firebase_uid}, ${phone || null}, ${display_name || null}, false) + RETURNING id, display_name, is_anonymous, phone, created_at + ` + return customer +} + +export const updateCustomerDisplayName = async (customerId, displayName) => { + const [customer] = await sql` + UPDATE customers SET display_name = ${displayName} + WHERE id = ${customerId} + RETURNING id, display_name, is_anonymous, phone, created_at + ` + return customer +} diff --git a/backend/src/services/pairing.service.js b/backend/src/services/pairing.service.js index e4076dd..3588b8c 100644 --- a/backend/src/services/pairing.service.js +++ b/backend/src/services/pairing.service.js @@ -29,6 +29,7 @@ const notifyMitra = async (mitraId, data) => { // Send notification to customer via WebSocket, fall back to FCM if offline const notifyCustomer = async (customerId, data) => { const sent = sendToUser(UserType.CUSTOMER, customerId, data) + console.log(`[notifyCustomer] customerId=${customerId} type=${data.type} ws_sent=${sent}`) if (!sent) { if (data.type === WsMessage.PAIRED) { await sendPushNotification(UserType.CUSTOMER, customerId, { diff --git a/client_app/lib/core/api/api_client.dart b/client_app/lib/core/api/api_client.dart index 8b03f4f..b7516c2 100644 --- a/client_app/lib/core/api/api_client.dart +++ b/client_app/lib/core/api/api_client.dart @@ -32,4 +32,9 @@ class ApiClient { final response = await _dio.get(path, queryParameters: queryParameters); return response.data as Map; } + + Future> patch(String path, {Map? data}) async { + final response = await _dio.patch(path, data: data); + return response.data as Map; + } } diff --git a/client_app/lib/core/auth/auth_notifier.dart b/client_app/lib/core/auth/auth_notifier.dart index 253be92..e01aec4 100644 --- a/client_app/lib/core/auth/auth_notifier.dart +++ b/client_app/lib/core/auth/auth_notifier.dart @@ -40,6 +40,11 @@ class AuthForceRegisterData extends AuthData { const AuthForceRegisterData({required this.customerId, required this.displayName}); } +class AuthNeedsDisplayNameData extends AuthData { + final Map profile; + const AuthNeedsDisplayNameData(this.profile); +} + @Riverpod(keepAlive: true) class Auth extends _$Auth { FirebaseAuth get _auth => FirebaseAuth.instance; @@ -132,7 +137,11 @@ class Auth extends _$Auth { final completer = Completer(); await _auth.verifyPhoneNumber( phoneNumber: phone, - verificationCompleted: (_) { + verificationCompleted: (credential) async { + try { + await _auth.signInWithCredential(credential); + state = AsyncData(await _verifyAndReturn()); + } catch (_) {} if (!completer.isCompleted) completer.complete(); }, verificationFailed: (e) { @@ -153,11 +162,14 @@ class Auth extends _$Auth { Future verifyOtp(String verificationId, String smsCode) async { state = const AsyncLoading(); try { - final credential = PhoneAuthProvider.credential( - verificationId: verificationId, - smsCode: smsCode, - ); - await _auth.signInWithCredential(credential); + // If already signed in via auto-verification, skip credential sign-in + if (_auth.currentUser == null || _auth.currentUser!.isAnonymous) { + final credential = PhoneAuthProvider.credential( + verificationId: verificationId, + smsCode: smsCode, + ); + await _auth.signInWithCredential(credential); + } state = AsyncData(await _verifyAndReturn()); } catch (e) { state = AsyncError('Invalid OTP. Please try again.', StackTrace.current); @@ -191,8 +203,24 @@ class Auth extends _$Auth { state = const AsyncData(AuthInitialData()); } + Future setDisplayName(String displayName) async { + state = const AsyncLoading(); + try { + final response = await _apiClient.patch('/api/client/auth/profile', data: { + 'display_name': displayName, + }); + state = AsyncData(AuthAuthenticatedData(response['data'] as Map)); + } catch (e) { + state = AsyncError('Gagal menyimpan nama. Coba lagi.', StackTrace.current); + } + } + Future _verifyAndReturn() async { final response = await _apiClient.post('/api/client/auth/verify'); - return AuthAuthenticatedData(response['data'] as Map); + final profile = response['data'] as Map; + if (profile['display_name'] == null || (profile['display_name'] as String).isEmpty) { + return AuthNeedsDisplayNameData(profile); + } + return AuthAuthenticatedData(profile); } } diff --git a/client_app/lib/features/auth/screens/set_display_name_screen.dart b/client_app/lib/features/auth/screens/set_display_name_screen.dart new file mode 100644 index 0000000..9c6a937 --- /dev/null +++ b/client_app/lib/features/auth/screens/set_display_name_screen.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/auth/auth_notifier.dart'; + +class SetDisplayNameScreen extends ConsumerStatefulWidget { + const SetDisplayNameScreen({super.key}); + + @override + ConsumerState createState() => _SetDisplayNameScreenState(); +} + +class _SetDisplayNameScreenState extends ConsumerState { + final _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final name = _controller.text.trim(); + if (name.isEmpty) return; + ref.read(authProvider.notifier).setDisplayName(name); + } + + @override + Widget build(BuildContext context) { + final authState = ref.watch(authProvider); + final isLoading = authState is AsyncLoading; + + ref.listen(authProvider, (prev, next) { + if (next is AsyncError) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(next.error.toString()))); + } + }); + + return Scaffold( + appBar: AppBar(title: const Text('Siapa namamu?')), + body: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text('Pilih nama yang ingin kamu gunakan. Nama ini akan terlihat oleh Bestie kamu.'), + const SizedBox(height: 24), + TextField( + controller: _controller, + decoration: const InputDecoration( + labelText: 'Nama panggilan', + border: OutlineInputBorder(), + ), + textInputAction: TextInputAction.done, + onSubmitted: (_) => _submit(), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: isLoading ? null : _submit, + child: isLoading + ? const CircularProgressIndicator() + : const Text('Lanjut'), + ), + ], + ), + ), + ); + } +} diff --git a/client_app/lib/router.dart b/client_app/lib/router.dart index 8282319..38f0121 100644 --- a/client_app/lib/router.dart +++ b/client_app/lib/router.dart @@ -7,6 +7,7 @@ import 'features/auth/screens/display_name_screen.dart'; import 'features/auth/screens/register_screen.dart'; import 'features/auth/screens/otp_screen.dart'; import 'features/auth/screens/force_register_screen.dart'; +import 'features/auth/screens/set_display_name_screen.dart'; import 'features/splash/splash_screen.dart'; import 'features/home/home_screen.dart'; import 'features/chat/screens/searching_screen.dart'; @@ -55,6 +56,7 @@ GoRouter buildRouter(Ref ref) { if (data is AuthAuthenticatedData || data is AuthAnonymousData) { return (isSplash || isAuthRoute) ? '/home' : null; } + if (data is AuthNeedsDisplayNameData) return '/auth/set-name'; if (data is AuthForceRegisterData) return '/auth/force-register'; if (!isAuthRoute && !isSplash) return '/welcome'; if (isSplash) return '/welcome'; @@ -66,6 +68,7 @@ GoRouter buildRouter(Ref ref) { GoRoute(path: '/auth/display-name', builder: (_, __) => const DisplayNameScreen()), GoRoute(path: '/auth/register', builder: (_, __) => const RegisterScreen()), GoRoute(path: '/auth/otp', builder: (context, state) => OtpScreen(phone: state.extra as String)), + GoRoute(path: '/auth/set-name', builder: (_, __) => const SetDisplayNameScreen()), GoRoute(path: '/auth/force-register', builder: (_, __) => const ForceRegisterScreen()), GoRoute(path: '/home', builder: (_, __) => const HomeScreen()), GoRoute(path: '/chat/searching', builder: (_, __) => const SearchingScreen()), diff --git a/mitra_app/lib/core/auth/auth_notifier.dart b/mitra_app/lib/core/auth/auth_notifier.dart index e7e0b3f..1d5650c 100644 --- a/mitra_app/lib/core/auth/auth_notifier.dart +++ b/mitra_app/lib/core/auth/auth_notifier.dart @@ -54,7 +54,11 @@ class MitraAuth extends _$MitraAuth { final completer = Completer(); await _auth.verifyPhoneNumber( phoneNumber: phone, - verificationCompleted: (_) { + verificationCompleted: (credential) async { + try { + await _auth.signInWithCredential(credential); + state = AsyncData(await _verifyAndReturn()); + } catch (_) {} if (!completer.isCompleted) completer.complete(); }, verificationFailed: (e) { @@ -78,7 +82,8 @@ class MitraAuth extends _$MitraAuth { try { if (kIsWeb && _webConfirmationResult != null) { await _webConfirmationResult!.confirm(smsCode); - } else { + } else if (_auth.currentUser == null) { + // Only sign in if not already signed in via auto-verification final credential = PhoneAuthProvider.credential( verificationId: verificationId, smsCode: smsCode, diff --git a/mitra_app/lib/core/chat/chat_request_notifier.dart b/mitra_app/lib/core/chat/chat_request_notifier.dart index 7b1880f..1478c39 100644 --- a/mitra_app/lib/core/chat/chat_request_notifier.dart +++ b/mitra_app/lib/core/chat/chat_request_notifier.dart @@ -54,6 +54,8 @@ class ChatRequest extends _$ChatRequest { ChatRequestData build() => const ChatRequestIdleData(); Future startListening() async { + // Don't reset state if actively accepting/accepted — would lose navigation + if (state is ChatRequestAcceptingData || state is ChatRequestAcceptedData) return; _closeWebSocket(); state = const ChatRequestListeningData(); await _connectWebSocket();