Coverage for functions \ flipdare \ service \ account_service.py: 82%

128 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-05-08 12:22 +1000

1#!/usr/bin/env python 

2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved. 

3# 

4# This file is part of Flipdare's proprietary software and contains 

5# confidential and copyrighted material. Unauthorised copying, 

6# modification, distribution, or use of this file is strictly 

7# prohibited without prior written permission from Flipdare Pty Ltd. 

8# 

9# This software includes third-party components licensed under MIT, 

10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details. 

11# 

12 

13from __future__ import annotations 

14from typing import TYPE_CHECKING 

15from firebase_functions import https_fn 

16from flipdare.app_globals import is_valid_email 

17from flipdare.app_log import LOG 

18from flipdare.app_types import JsonDict 

19from flipdare.mailer.user.signup_code_email import SignupCodeEmail 

20from flipdare.generated.schema.error.error_email_schema import ErrorEmailSchema 

21from flipdare.generated.schema.error_schema import ErrorSchema 

22from flipdare.service._error_mixin import ErrorMixin 

23from flipdare.generated.schema.pin.pin_confirm_response_schema import PinConfirmResponseSchema 

24from flipdare.generated.schema.pin.pin_generate_response_schema import PinGenerateResponseSchema 

25from flipdare.generated.shared.app_error_code import AppErrorCode 

26from flipdare.message.error_message import ErrorMessage 

27from flipdare.request.app_request import AppRequest 

28from flipdare.request.data.pin_request_adapter import ( 

29 PinConfirmRequestAdapter, 

30 PinGenerateRequestAdapter, 

31) 

32from flipdare.service._service_provider import ServiceProvider 

33from flipdare.service._user_mixin import UserMixin 

34from flipdare.util.code_generator import CodeGenerator 

35from flipdare.wrapper.user_wrapper import UserWrapper 

36 

37if TYPE_CHECKING: 

38 from flipdare.manager.db_manager import DbManager 

39 from flipdare.manager.backend_manager import BackendManager 

40 from flipdare.manager.service_manager import ServiceManager 

41 

42 

43class AccountService(UserMixin, ErrorMixin, ServiceProvider): 

44 

45 def __init__( 

46 self, 

47 db_manager: DbManager | None = None, 

48 backend_manager: BackendManager | None = None, 

49 service_manager: ServiceManager | None = None, 

50 ) -> None: 

51 super().__init__( 

52 backend_manager=backend_manager, 

53 db_manager=db_manager, 

54 service_manager=service_manager, 

55 ) 

56 

57 # NOTE: No firebase auth here — the user doesn't have a complete account yet. 

58 # NOTE: Errors are raised as AppError; the callable decorator in main.py converts 

59 # NOTE: them to HttpsError so the client receives a proper error response. 

60 

61 def generate_pin( 

62 self, 

63 request: AppRequest[https_fn.CallableRequest[JsonDict]], 

64 ) -> PinGenerateResponseSchema | ErrorSchema: 

65 """Store a signup code on the user and send it via email.""" 

66 from flipdare.app_env import get_app_environment 

67 

68 endpoint = request.endpoint 

69 req = request.request_type 

70 

71 pin_request = PinGenerateRequestAdapter.from_callable(req) 

72 try: 

73 pin_request.validate() 

74 except Exception as err: 

75 LOG().error(f"Pin generate validation error: {err}") 

76 return self.callable_validation_error( 

77 endpoint=endpoint, 

78 error=err, 

79 user_error_code=pin_request.user_error_code, 

80 ) 

81 

82 email = pin_request.email 

83 uid = pin_request.uid 

84 

85 # NOTE: this is the first firebase function entry point , so this is probably where we should 

86 # NOTE: check the email is valid .. 

87 if get_app_environment().in_cloud: 

88 validation_result = is_valid_email(email, check_deliverability=True) 

89 if validation_result.is_error: 

90 msg = f"Invalid email provided for pin generation: {email}. Error: {validation_result.error}" 

91 LOG().error(msg) 

92 msg = ErrorMessage.INVALID_EMAIL.formatted(ErrorEmailSchema(email=email)) 

93 return self.callable_request_error( 

94 endpoint=endpoint, 

95 message=msg, 

96 error_code=AppErrorCode.INVALID_INPUT, 

97 ) 

98 

99 user: UserWrapper | None = None 

100 try: 

101 user = self.get_user_by_email(endpoint, email) 

102 except Exception as e: 

103 LOG().error(f"Error fetching user for email {email}: {e}") 

104 msg = ErrorMessage.NO_ACCOUNT_FOR_EMAIL.formatted(ErrorEmailSchema(email=email)) 

105 return self.callable_request_error( 

106 endpoint=endpoint, message=msg, error_code=AppErrorCode.NOT_FOUND 

107 ) 

108 

109 code = CodeGenerator.instance().signup_code() 

110 

111 try: 

112 user.pin_code = code 

113 self.update_user( 

114 endpoint=endpoint, 

115 user=user, 

116 on_error_msg=ErrorMessage.APP_ERROR, 

117 ) 

118 except Exception as err: 

119 LOG().error(f"Error updating user with pin code for email {email}: {err}") 

120 msg = ErrorMessage.APP_ERROR 

121 return self.callable_request_error( 

122 endpoint=endpoint, 

123 message=msg, 

124 error=err, 

125 ) 

126 

127 LOG().info(f"Sending pin to {email} from endpoint {endpoint}") 

128 

129 email_content = SignupCodeEmail(to_user=user, signup_code=code) 

130 try: 

131 self.user_mailer.send( 

132 user=user, 

133 email_template=email_content, 

134 notif_check=False, 

135 ) 

136 LOG().info(f"Pin email sent to {email}") 

137 except Exception as err: 

138 LOG().error(f"Error sending pin email to {email}: {err}") 

139 msg = ErrorMessage.EMAIL_DOWN_ERROR 

140 return self.callable_request_error( 

141 endpoint=endpoint, 

142 message=msg, 

143 error=err, 

144 ) 

145 

146 return PinGenerateResponseSchema(pin_code=code, uid=uid) 

147 

148 def confirm_pin( 

149 self, 

150 request: AppRequest[https_fn.CallableRequest[JsonDict]], 

151 ) -> PinConfirmResponseSchema | ErrorSchema: 

152 """Verify a submitted pin code and mark the user's email as verified.""" 

153 endpoint = request.endpoint 

154 req = request.request_type 

155 confirm_data = PinConfirmRequestAdapter.from_callable(req) 

156 

157 try: 

158 confirm_data.validate() 

159 except Exception as err: 

160 return self.callable_validation_error( 

161 endpoint=endpoint, 

162 error=err, 

163 user_error_code=confirm_data.user_error_code, 

164 ) 

165 

166 pin_code = confirm_data.pin_code 

167 user_id = confirm_data.uid 

168 email = confirm_data.email 

169 

170 user: UserWrapper | None = None 

171 try: 

172 user = self.get_user_by_id(endpoint, user_id) 

173 except Exception as e: 

174 LOG().error(f"Error fetching user for id {user_id}: {e}") 

175 return self.callable_request_error( 

176 endpoint=endpoint, 

177 message=ErrorMessage.MISSING_USER, 

178 error_code=AppErrorCode.NOT_FOUND, 

179 ) 

180 

181 if user.pin_code is None: 

182 LOG().warning(f"Missing server pin code for user {user_id}") 

183 return self.callable_request_error( 

184 endpoint=endpoint, 

185 error_code=AppErrorCode.SERVER, 

186 message=ErrorMessage.MISSING_SERVER_PIN_CODE, 

187 ) 

188 

189 if str(user.pin_code).strip() != str(pin_code).strip(): 

190 LOG().warning(f"Invalid pin for user {user_id}: exp {user.pin_code} got {pin_code}") 

191 return self.callable_request_error( 

192 endpoint=endpoint, 

193 error_code=AppErrorCode.INVALID_INPUT, 

194 message=ErrorMessage.PIN_CODE_MISMATCH, 

195 ) 

196 

197 try: 

198 self._set_email_verified(endpoint, user) 

199 except Exception as err: 

200 LOG().error(f"Error setting email verified for user {user_id}: {err}") 

201 msg = ErrorMessage.APP_ERROR 

202 return self.callable_request_error( 

203 endpoint=endpoint, 

204 message=msg, 

205 error=err, 

206 ) 

207 

208 return PinConfirmResponseSchema(email=email, uid=user_id, matched=True) 

209 

210 def _set_email_verified(self, endpoint: str, user: UserWrapper) -> None: 

211 # NOTE: called in pin so needs to be json 

212 

213 from flipdare.services import get_auth_client 

214 

215 email = user.email 

216 uid = user.doc_id 

217 LOG().debug(f"Setting verified for {email}/{uid}") 

218 

219 auth = get_auth_client() 

220 user_record = None # note different to user model, firebase user.. 

221 try: 

222 user_record = auth.get_user_by_email(email) # type: ignore 

223 except Exception as ex: 

224 LOG().error(f"Error fetching user by email {email}: {ex}") 

225 user_record = None 

226 

227 if user_record is None: 

228 try: 

229 user_record = auth.get_user(uid) # type: ignore 

230 except Exception as error: 

231 LOG().error(f"user doesn't exist {email}/{uid}: {error}") 

232 msg = ErrorMessage.FIREBASE_AUTH_ERROR 

233 self.log_and_throw(endpoint=endpoint, message=msg, cause=error) 

234 

235 try: 

236 LOG().debug(f"Setting emailVerified for user {email}/{uid}") 

237 # update in firebase auth 

238 LOG().debug(f"Updating emailVerified in Firebase Auth for user {email}/{uid}") 

239 auth.update_user(uid, email_verified=True) # type: ignore 

240 

241 # update in firestore 

242 LOG().debug(f"Updating emailVerified in Firestore for user {email}/{uid}") 

243 user.email_verified = True 

244 self.update_user(endpoint=endpoint, user=user, on_error_msg=ErrorMessage.APP_ERROR) 

245 LOG().info(f"Email verified for user {email}/{uid}") 

246 except Exception as error: 

247 LOG().error(f"Error setting email verified for user {email}/{uid}: {error}") 

248 msg = ErrorMessage.APP_ERROR 

249 self.log_and_throw( 

250 endpoint=endpoint, 

251 message=msg, 

252 cause=error, 

253 )