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
« 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#
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
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
43class AccountService(UserMixin, ErrorMixin, ServiceProvider):
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 )
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.
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
68 endpoint = request.endpoint
69 req = request.request_type
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 )
82 email = pin_request.email
83 uid = pin_request.uid
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 )
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 )
109 code = CodeGenerator.instance().signup_code()
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 )
127 LOG().info(f"Sending pin to {email} from endpoint {endpoint}")
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 )
146 return PinGenerateResponseSchema(pin_code=code, uid=uid)
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)
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 )
166 pin_code = confirm_data.pin_code
167 user_id = confirm_data.uid
168 email = confirm_data.email
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 )
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 )
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 )
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 )
208 return PinConfirmResponseSchema(email=email, uid=user_id, matched=True)
210 def _set_email_verified(self, endpoint: str, user: UserWrapper) -> None:
211 # NOTE: called in pin so needs to be json
213 from flipdare.services import get_auth_client
215 email = user.email
216 uid = user.doc_id
217 LOG().debug(f"Setting verified for {email}/{uid}")
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
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)
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
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 )