Coverage for functions \ main.py: 100%

0 statements  

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

1#!/usr/bin/env python 

2# 

3# Deploy with `firebase deploy` 

4# 

5# NOTE: this should be at the top of the file to ensure setup is done first. 

6import asyncio 

7from collections.abc import Callable 

8from typing import Any, NoReturn 

9import firebase_admin 

10import flask 

11from firebase_functions import https_fn, options, remote_config_fn 

12from firebase_functions.core import CloudEvent # Import CloudEvent 

13from firebase_functions.core import init 

14from firebase_functions.remote_config_fn import ConfigUpdateData # Import ConfigUpdateData 

15from flipdare.app_config import AppConfig 

16from flipdare.app_cron import ( 

17 fn_fifteen_minutes, 

18 fn_five_minutes, 

19 fn_four_hours, 

20 fn_one_day, 

21 fn_one_hour, 

22 fn_one_week, 

23 fn_six_hours, 

24 fn_three_days, 

25 fn_twelve_hours, 

26 fn_two_minutes, 

27) 

28from flipdare.app_log import LOG 

29from flipdare.app_service import AppService 

30from flipdare.constants import ANDROID_STORE_URL, FALLBACK_WEB_URL, IOS_STORE_URL, IS_DEBUG 

31from flipdare.core.app_deep_link_factory import AppDeepLinkFactory 

32from flipdare.services import get_account_service, get_ext_account_service, get_search_service 

33from flipdare.app_triggers import ( 

34 tr_chat_deleted, 

35 tr_content_deleted, 

36 tr_dare_deleted, 

37 tr_dare_new, 

38 tr_dare_update, 

39 tr_flag_created, 

40 tr_flag_updated, 

41 tr_friend_new, 

42 tr_friend_updated, 

43 tr_group_created, 

44 tr_group_member_created, 

45 tr_group_member_updated, 

46 tr_group_updated, 

47 tr_pledge_created, 

48 tr_pledge_deleted, 

49 tr_pledge_updated, 

50 tr_user_deleted, 

51 tr_user_new, 

52 tr_user_update, 

53) 

54from flipdare.app_types import SchemaDict 

55from flipdare.error.app_error import AppError, ErrorGuard 

56from flipdare.error.callable_error_code import CallableErrorCode 

57from flipdare.error.error_context import ErrorContext 

58from flipdare.message.error_message import ErrorMessage 

59from flipdare.request.app_request import AppRequest 

60from flipdare.request.data.search_request_adapter import SearchRequestAdapter 

61from flipdare.request.request_types import AppHttpRequestType 

62from flipdare.core.app_response import AppErrorResponse 

63from flipdare.util.debug_util import stringify_debug 

64from flipdare.util.http_util import HttpUtil 

65 

66# 

67# GLOBAL SETTINGS 

68# 

69type _R = flask.Request 

70type _C = https_fn.CallableRequest[Any] 

71 

72# For external web functions 

73_web_core_settings = options.CorsOptions( 

74 cors_origins=["https://www.flipdare.com", "http://127.0.0.1:5000", "http://localhost:5000"], 

75 cors_methods=["post"], 

76) 

77 

78# ----------------------------------------------------------------------------- 

79# ----------------------------------------------------------------------------- 

80# Internal CHECKS 

81# ----------------------------------------------------------------------------- 

82# ----------------------------------------------------------------------------- 

83 

84# fmt: off 

85assert callable(tr_user_new), "!!! Function 'tr_user_new' is not loaded !!!" 

86assert callable(tr_user_update), "!!! Function 'tr_user_update' is not loaded !!!" 

87assert callable(tr_dare_new), "!!! Function 'tr_dare_new' is not loaded !!!" 

88assert callable(tr_dare_update), "!!! Function 'tr_dare_update' is not loaded !!!" 

89assert callable(tr_friend_new), "!!! Function 'tr_friend_new' is not loaded !!!" 

90assert callable(tr_friend_updated), "!!! Function 'tr_friend_updated' is not loaded !!!" 

91assert callable(tr_flag_created), "!!! Function 'tr_flag_created' is not loaded !!!" 

92assert callable(tr_flag_updated), "!!! Function 'tr_flag_updated' is not loaded !!!" 

93assert callable(tr_group_created), "!!! Function 'tr_group_created' is not loaded !!!" 

94assert callable(tr_group_updated), "!!! Function 'tr_group_updated' is not loaded !!!" 

95assert callable(tr_group_member_created), "!!! Function 'tr_group_member_created' is not loaded !!!" 

96assert callable(tr_group_member_updated), "!!! Function 'tr_group_member_updated' is not loaded !!!" 

97assert callable(tr_pledge_created), "!!! Function 'tr_pledge_created' is not loaded !!!" 

98assert callable(tr_pledge_updated), "!!! Function 'tr_pledge_updated' is not loaded !!!" 

99# deletion 

100assert callable(tr_user_deleted), "!!! Function 'tr_user_deleted' is not loaded !!!" 

101assert callable(tr_content_deleted), "!!! Function 'tr_content_deleted' is not loaded !!!" 

102assert callable(tr_dare_deleted), "!!! Function 'tr_dare_deleted' is not loaded !!!" 

103assert callable(tr_chat_deleted), "!!! Function 'tr_chat_deleted' is not loaded !!!" 

104assert callable(tr_pledge_deleted), "!!! Function 'tr_pledge_deleted' is not loaded !!!" 

105# crons 

106assert callable(fn_two_minutes), "!!! Function 'fn_two_minutes' is not loaded !!!" 

107assert callable(fn_five_minutes), "!!! Function 'fn_five_minutes' is not loaded !!!" 

108assert callable(fn_fifteen_minutes), "!!! Function 'fn_fifteen_minutes' is not loaded !!!" 

109assert callable(fn_one_hour), "!!! Function 'fn_one_hour' is not loaded !!!" 

110assert callable(fn_four_hours), "!!! Function 'fn_four_hours' is not loaded !!!" 

111assert callable(fn_six_hours), "!!! Function 'fn_six_hours' is not loaded !!!" 

112assert callable(fn_twelve_hours), "!!! Function 'fn_twelve_hours' is not loaded !!!" 

113assert callable(fn_one_day), "!!! Function 'fn_one_day' is not loaded !!!" 

114assert callable(fn_three_days), "!!! Function 'fn_three_days' is not loaded !!!" 

115assert callable(fn_one_week), "!!! Function 'fn_one_week' is not loaded !!!" 

116# fmt: on 

117 

118# ----------------------------------------------------------------------------- 

119# ----------------------------------------------------------------------------- 

120# Internal functions 

121# ----------------------------------------------------------------------------- 

122# ----------------------------------------------------------------------------- 

123 

124try: 

125 should_init = False 

126 try: 

127 firebase_admin.get_app() 

128 except ValueError: 

129 should_init = True 

130 

131 if should_init: 

132 firebase_admin.initialize_app() 

133 LOG().info(f"App initialized. Project ID: {firebase_admin.get_app().project_id}") 

134except ValueError as e: 

135 LOG().error(f"App initialization failed or app already initialized: {e}") 

136 

137 

138@init 

139def initialize() -> None: 

140 LOG().info("Initializing Flipdare Firebase Functions...") 

141 AppConfig.instance() # ensure config is loaded 

142 AppService.instance() 

143 LOG().info("Flipdare Firebase Functions initialized.") 

144 

145 

146@remote_config_fn.on_config_updated() 

147def hello_remote_config(event: CloudEvent[ConfigUpdateData]) -> None: 

148 asyncio.run(AppConfig.instance().remote_update(event)) 

149 

150 

151# ----------------------------------------------------------------------------- 

152# Helpers 

153# ----------------------------------------------------------------------------- 

154 

155 

156def _enforce_app_check() -> bool: 

157 return True 

158 # return not get_app_config().is_dev and not is_running_emulator() 

159 

160 

161# RELEASE: reapply admin functions (h__ping and h__ping_search) after release 

162# ----------------------------------------------------------------------------- 

163# Admin functions 

164# ----------------------------------------------------------------------------- 

165# 

166# 

167# @https_fn.on_request() 

168# def h__ping(req: flask.Request) -> flask.Response: # pragma: no cover 

169# return _system_util().ping_app(req) 

170# 

171# 

172# @https_fn.on_request() 

173# def h__ping_search(req: flask.Request) -> flask.Response: # pragma: no cover 

174# return _system_util().ping_search(req) 

175 

176# ----------------------------------------------------------------------------- 

177# SUPPORT functions 

178# ----------------------------------------------------------------------------- 

179# RELEASE: this needs to be implemented for the desktop client to access charges. 

180# This is for access to restricted collections (e.g. charge_captures) 

181# that are blocked from client SDKs by security rules. 

182# 

183# @https_fn.on_request(secrets=["ADMIN_SUPPORT_KEY"]) 

184# def restricted_admin_task(req: https_fn.Request) -> https_fn.Response: 

185# # 1. Verify the Admin Key from headers 

186# client_key = req.headers.get("X-Admin-Key") 

187# secret_key = os.environ.get("ADMIN_SUPPORT_KEY") 

188# 

189# if not client_key or client_key != secret_key: 

190# return https_fn.Response("Unauthorized", status=403) 

191# 

192# # 2. Execute your privileged logic (e.g., search your model) 

193# # This Admin SDK call bypasses your 'allow read, write: if false' rules 

194# pledge_id = req.args.get("pledge_id") 

195# docs = db.collection("charge_captures").where("pledge_id", "==", pledge_id).stream() 

196# 

197# return https_fn.Response(str([doc.to_dict() for doc in docs])) 

198 

199# ----------------------------------------------------------------------------- 

200# DEEP_LINKS 

201# ----------------------------------------------------------------------------- 

202 

203 

204@https_fn.on_request(cors=_web_core_settings) 

205def w__redirect(req: flask.Request) -> flask.Response: 

206 # this is for managing deep links from external sources (e.g. email, web) 

207 if HttpUtil.is_suspicious_request(req): 

208 flask.abort(403) 

209 

210 link_factory = AppDeepLinkFactory(req) 

211 url = link_factory.app_url 

212 

213 if url is None or not link_factory.is_valid_link or not link_factory.platform.is_mobile: 

214 # actually this is a problem, since we have been redirectory from '/l/**' urls. 

215 # It means the url is not in the expected format. 

216 if link_factory.is_ios: 

217 return _build_redirect(IOS_STORE_URL, code=302) 

218 elif link_factory.is_android: 

219 return _build_redirect(ANDROID_STORE_URL, code=302) 

220 

221 msg = f"Invalid deep link request: path={req.path}, platform={link_factory.platform}" 

222 LOG().error(msg) 

223 

224 response = flask.redirect(FALLBACK_WEB_URL, code=302) 

225 return flask.Response(response.get_data(), status=302, headers=response.headers) 

226 

227 # 2. Toss it to ChottuLink. 

228 # ChottuLink will: 

229 # - Detect the device (iOS/Android/Desktop) 

230 # - Send them to the right Store 

231 # - Remember 'clicked_url' for when the app opens 

232 return _build_redirect(url, code=302) 

233 

234 

235def _build_redirect(path: str, code: int) -> flask.Response: 

236 response = flask.redirect(path, code=code) 

237 return flask.Response(response.get_data(), status=code, headers=response.headers) 

238 

239 

240# ----------------------------------------------------------------------------- 

241# USER ADMIN 

242# ----------------------------------------------------------------------------- 

243 

244 

245@https_fn.on_request(cors=_web_core_settings) 

246def h__unsubscribe(req: flask.Request) -> flask.Response: 

247 return _http_request_wrapper(lambda r: get_ext_account_service().unsubscribe(r))( 

248 req, 

249 AppHttpRequestType.UNSUBSCRIBE, 

250 ) 

251 

252 

253@https_fn.on_request(cors=_web_core_settings) 

254def h__delete(req: flask.Request) -> flask.Response: 

255 return _http_request_wrapper(lambda req: get_ext_account_service().delete(req))( 

256 req, 

257 AppHttpRequestType.DELETE, 

258 ) 

259 

260 

261@https_fn.on_request(cors=_web_core_settings) 

262def h__delete_confirm(req: flask.Request) -> flask.Response: 

263 return _http_request_wrapper(lambda req: get_ext_account_service().delete_confirm(req))( 

264 req, 

265 AppHttpRequestType.DELETE_CONFIRM, 

266 ) 

267 

268 

269@https_fn.on_request(cors=_web_core_settings) 

270def h__contact(req: flask.Request) -> flask.Response: 

271 from flipdare.services import get_admin_mailer 

272 

273 return _http_request_wrapper(lambda req: get_admin_mailer().send_contact(req))( 

274 req, 

275 AppHttpRequestType.CONTACT, 

276 ) 

277 

278 

279# ----------------------------------------------------------------------------- 

280# ACCOUNT 

281# ----------------------------------------------------------------------------- 

282 

283 

284@https_fn.on_call(enforce_app_check=_enforce_app_check()) 

285def c_generate_pin(req: https_fn.CallableRequest[Any]) -> Any: 

286 """Send email verification code to user.""" 

287 return _callable_request_wrapper(lambda r: get_account_service().generate_pin(r))(req) 

288 

289 

290@https_fn.on_call(enforce_app_check=_enforce_app_check()) 

291def c_confirm_pin(req: https_fn.CallableRequest[Any]) -> Any: 

292 """Confirm email verification code for user.""" 

293 return _callable_request_wrapper(lambda r: get_account_service().confirm_pin(r))(req) 

294 

295 

296# ----------------------------------------------------------------------------- 

297# SEARCH 

298# ----------------------------------------------------------------------------- 

299 

300 

301@https_fn.on_call(enforce_app_check=_enforce_app_check()) 

302def c_search(req: https_fn.CallableRequest[Any]) -> Any: 

303 endpoint: str = req.raw_request.endpoint or "unknown" 

304 

305 try: 

306 request = AppRequest.callable(req) 

307 request.is_authenticated() 

308 search_request = SearchRequestAdapter(request) 

309 return get_search_service().search(search_request) 

310 except AppError as error: 

311 msg = f"App Error running search: {error}" 

312 LOG().error(msg, include_stack=True) 

313 return ErrorContext.server_error(endpoint, cause=msg, error=error).to_dict() 

314 except Exception as error: 

315 LOG().error(f"Error running search: {error}", include_stack=True) 

316 return ErrorContext.server_error(endpoint).to_dict() 

317 

318 

319# ----------------------------------------------------------------------------- 

320# STRIPE (from flutter) 

321# ----------------------------------------------------------------------------- 

322 

323 

324@https_fn.on_call(enforce_app_check=_enforce_app_check()) 

325def c_stripe_create_account(req: https_fn.CallableRequest[Any]) -> Any: 

326 from flipdare.services import get_payment_service 

327 

328 return _handle_stripe_callable( 

329 req, 

330 stripe_api_fn=lambda req_obj: get_payment_service().create_account(req_obj), 

331 ) 

332 

333 

334@https_fn.on_call(enforce_app_check=_enforce_app_check()) 

335def c_stripe_upgrade_customer(req: https_fn.CallableRequest[Any]) -> Any: 

336 from flipdare.services import get_payment_service 

337 

338 return _handle_stripe_callable( 

339 req, 

340 stripe_api_fn=lambda req_obj: get_payment_service().upgrade_customer(req_obj), 

341 ) 

342 

343 

344@https_fn.on_call(enforce_app_check=_enforce_app_check()) 

345def c_stripe_create_onboard_link(req: https_fn.CallableRequest[Any]) -> Any: 

346 from flipdare.services import get_payment_service 

347 

348 return _handle_stripe_callable( 

349 req, 

350 stripe_api_fn=lambda req_obj: get_payment_service().create_onboard_link(req_obj), 

351 ) 

352 

353 

354@https_fn.on_call(enforce_app_check=_enforce_app_check()) 

355def c_stripe_refresh_account(req: https_fn.CallableRequest[Any]) -> Any: 

356 from flipdare.services import get_payment_service 

357 

358 return _handle_stripe_callable( 

359 req, 

360 stripe_api_fn=lambda req_obj: get_payment_service().refresh_account(req_obj), 

361 ) 

362 

363 

364@https_fn.on_call(enforce_app_check=_enforce_app_check()) 

365def c_stripe_create_charge(req: https_fn.CallableRequest[Any]) -> Any: 

366 from flipdare.services import get_payment_service 

367 

368 return _handle_stripe_callable( 

369 req, 

370 stripe_api_fn=lambda req_obj: get_payment_service().create_charge(req_obj), 

371 ) 

372 

373 

374@https_fn.on_call(enforce_app_check=_enforce_app_check()) 

375def c_stripe_confirm_charge(req: https_fn.CallableRequest[Any]) -> Any: 

376 from flipdare.services import get_payment_service 

377 

378 return _handle_stripe_callable( 

379 req, 

380 stripe_api_fn=lambda req_obj: get_payment_service().confirm_charge(req_obj), 

381 ) 

382 

383 

384# ----------------------------------------------------------------------------- 

385# STRIPE (WEBHOOKS) 

386# ----------------------------------------------------------------------------- 

387 

388 

389# 

390# these are WEBHOOKS from onboarding urls' 

391# 

392@https_fn.on_request() 

393def h__stripe_refresh(req: flask.Request) -> flask.Response: 

394 from flipdare.services import get_payment_service 

395 

396 if IS_DEBUG: 

397 msg = ( 

398 f"Stripe refresh called with url: {req.url} and method: {req.method}\n" 

399 f"Headers: {stringify_debug(dict(req.headers))}" 

400 ) 

401 LOG().debug(msg) 

402 

403 return _process_stripe_webhook( 

404 request=req, 

405 stripe_api_fn=lambda req_obj: get_payment_service().handle_refresh_webhook(req_obj), 

406 ) 

407 

408 

409@https_fn.on_request() 

410def h__stripe_return(req: flask.Request) -> flask.Response: 

411 from flipdare.services import get_payment_service 

412 

413 if IS_DEBUG: 

414 msg = ( 

415 f"Stripe return called with url: {req.url} and method: {req.method}\n" 

416 f"Headers: {stringify_debug(dict(req.headers))}" 

417 ) 

418 LOG().debug(msg) 

419 

420 return _process_stripe_webhook( 

421 request=req, 

422 stripe_api_fn=lambda req_obj: get_payment_service().handle_return_webhook(req_obj), 

423 ) 

424 

425 

426# 

427# this is a WEBHOOK from a payment that requires additional authentication (e.g. 3DSecure) 

428# 

429# NOTE: webhooks are currently not required since the stripe flutter payment form handles 3ds auth. 

430# @https_fn.on_request() 

431# def h__stripe_payment(req: flask.Request) -> flask.Response: 

432# from flipdare.services import get_payment_service 

433# 

434# if IS_DEBUG: 

435# msg = ( 

436# f"Stripe payment redirect called with url: {req.url} and method: {req.method}\n" 

437# f"Headers: {stringify_debug(dict(req.headers))}" 

438# ) 

439# LOG().debug(msg) 

440# 

441# return _process_stripe_webhook( 

442# request=req, 

443# stripe_api_fn=lambda req_obj: get_payment_service().handle_charge_webhook(req_obj), 

444# ) 

445 

446# ----------------------------------------------------------------------------- 

447# HELPERS 

448# ----------------------------------------------------------------------------- 

449 

450 

451def _http_request_wrapper( 

452 func: Callable[[AppRequest[Any]], flask.Response], 

453) -> Callable[[_R, AppHttpRequestType], flask.Response]: 

454 """Validates HTTP method and routes errors to consistent JSON responses.""" 

455 

456 def wrapper(req: _R, req_type: AppHttpRequestType) -> flask.Response: 

457 endpoint = req.url or "unknown" 

458 try: 

459 request = AppRequest.http(req, req_type) 

460 request.is_valid_http_method() 

461 request.is_authenticated() 

462 except AppError as error: 

463 LOG().error(f"Authentication or HTTP method error for {endpoint}: {error}") 

464 return AppErrorResponse.from_context(error.context).raw_response() 

465 

466 try: 

467 return func(request) 

468 except AppError as error: 

469 LOG().error(f"AppError in HTTP handler {endpoint}: {error}") 

470 return AppErrorResponse.from_context(error.context).raw_response() 

471 except Exception as error: 

472 LOG().error(f"Unexpected error in HTTP handler {endpoint}: {error}") 

473 return AppErrorResponse.from_context( 

474 ErrorContext.server_error(endpoint, cause=str(error)), 

475 ).raw_response() 

476 

477 return wrapper 

478 

479 

480def _callable_request_wrapper( 

481 func: Callable[[AppRequest[Any]], SchemaDict], 

482) -> Callable[[_C], SchemaDict]: 

483 """Authenticates and catches errors for callable endpoints.""" 

484 

485 def wrapper(req: _C) -> SchemaDict: 

486 endpoint = req.raw_request.endpoint or "unknown" 

487 request = AppRequest.callable(req) 

488 try: 

489 request.is_authenticated() 

490 except Exception as error: 

491 raise _wrapper_error(endpoint=endpoint, error=error) from error 

492 try: 

493 return func(request) 

494 except Exception as error: 

495 raise _wrapper_error(endpoint=endpoint, error=error) from error 

496 

497 return wrapper 

498 

499 

500def _wrapper_error(endpoint: str, error: Exception) -> NoReturn: 

501 LOG().error(f"Error for endpoint={endpoint}\n\nError={error!s}") 

502 

503 if isinstance(error, AppError): 

504 raise https_fn.HttpsError( 

505 code=CallableErrorCode.to_firebase(error.http_code), 

506 message=error.message, 

507 details=error.context.to_dict(), 

508 ) 

509 ctx = ErrorContext.from_exception( 

510 endpoint, 

511 message=ErrorMessage.INTERNAL_ERROR, 

512 error=error, 

513 ) 

514 data = ctx.to_dict() 

515 raise https_fn.HttpsError( 

516 code=CallableErrorCode.generic_error_code, 

517 message=data["message"], 

518 details=data, 

519 ) 

520 

521 

522def _handle_stripe_callable( 

523 req: https_fn.CallableRequest[Any], 

524 stripe_api_fn: Callable[[https_fn.CallableRequest[Any]], SchemaDict], 

525) -> SchemaDict: 

526 request = AppRequest.callable(req) 

527 endpoint = request.endpoint 

528 try: 

529 request.is_authenticated() 

530 except AppError as error: 

531 LOG().error(f"Stripe auth failed for {endpoint}: {error}") 

532 return error.to_dict() 

533 

534 LOG().info(f"Stripe callable: endpoint={endpoint}") 

535 try: 

536 response_data = stripe_api_fn(req) 

537 if ErrorGuard.is_error(response_data): 

538 LOG().error(f"Stripe error response: {response_data}") 

539 return AppErrorResponse(data=response_data).to_dict() 

540 return response_data 

541 except AppError as error: 

542 LOG().error(f"Stripe AppError for {endpoint}: {error}") 

543 return error.to_dict() 

544 except Exception as error: 

545 LOG().error(f"Stripe unexpected error for {endpoint}: {error}", include_stack=True) 

546 return ErrorContext.server_error(endpoint).to_dict() 

547 

548 

549def _process_stripe_webhook( 

550 request: flask.Request, 

551 stripe_api_fn: Callable[[flask.Request], flask.Response], 

552) -> flask.Response: 

553 # No auth check — this is a Stripe-initiated callback, not a user request. 

554 endpoint = request.url or "stripe_webhook" 

555 LOG().info(f"Stripe webhook callback: endpoint={endpoint}") 

556 try: 

557 return stripe_api_fn(request) 

558 except AppError as error: 

559 LOG().error(f"Stripe webhook AppError for {endpoint}: {error}") 

560 return AppErrorResponse.from_context(error.context).raw_response() 

561 except Exception as error: 

562 LOG().error(f"Stripe webhook unexpected error for {endpoint}: {error}", include_stack=True) 

563 return AppErrorResponse.from_context( 

564 ErrorContext.server_error(endpoint, cause=str(error)), 

565 ).raw_response()