Coverage for functions \ main.py: 100%
0 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#
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
66#
67# GLOBAL SETTINGS
68#
69type _R = flask.Request
70type _C = https_fn.CallableRequest[Any]
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)
78# -----------------------------------------------------------------------------
79# -----------------------------------------------------------------------------
80# Internal CHECKS
81# -----------------------------------------------------------------------------
82# -----------------------------------------------------------------------------
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
118# -----------------------------------------------------------------------------
119# -----------------------------------------------------------------------------
120# Internal functions
121# -----------------------------------------------------------------------------
122# -----------------------------------------------------------------------------
124try:
125 should_init = False
126 try:
127 firebase_admin.get_app()
128 except ValueError:
129 should_init = True
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}")
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.")
146@remote_config_fn.on_config_updated()
147def hello_remote_config(event: CloudEvent[ConfigUpdateData]) -> None:
148 asyncio.run(AppConfig.instance().remote_update(event))
151# -----------------------------------------------------------------------------
152# Helpers
153# -----------------------------------------------------------------------------
156def _enforce_app_check() -> bool:
157 return True
158 # return not get_app_config().is_dev and not is_running_emulator()
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)
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]))
199# -----------------------------------------------------------------------------
200# DEEP_LINKS
201# -----------------------------------------------------------------------------
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)
210 link_factory = AppDeepLinkFactory(req)
211 url = link_factory.app_url
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)
221 msg = f"Invalid deep link request: path={req.path}, platform={link_factory.platform}"
222 LOG().error(msg)
224 response = flask.redirect(FALLBACK_WEB_URL, code=302)
225 return flask.Response(response.get_data(), status=302, headers=response.headers)
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)
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)
240# -----------------------------------------------------------------------------
241# USER ADMIN
242# -----------------------------------------------------------------------------
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 )
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 )
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 )
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
273 return _http_request_wrapper(lambda req: get_admin_mailer().send_contact(req))(
274 req,
275 AppHttpRequestType.CONTACT,
276 )
279# -----------------------------------------------------------------------------
280# ACCOUNT
281# -----------------------------------------------------------------------------
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)
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)
296# -----------------------------------------------------------------------------
297# SEARCH
298# -----------------------------------------------------------------------------
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"
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()
319# -----------------------------------------------------------------------------
320# STRIPE (from flutter)
321# -----------------------------------------------------------------------------
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
328 return _handle_stripe_callable(
329 req,
330 stripe_api_fn=lambda req_obj: get_payment_service().create_account(req_obj),
331 )
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
338 return _handle_stripe_callable(
339 req,
340 stripe_api_fn=lambda req_obj: get_payment_service().upgrade_customer(req_obj),
341 )
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
348 return _handle_stripe_callable(
349 req,
350 stripe_api_fn=lambda req_obj: get_payment_service().create_onboard_link(req_obj),
351 )
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
358 return _handle_stripe_callable(
359 req,
360 stripe_api_fn=lambda req_obj: get_payment_service().refresh_account(req_obj),
361 )
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
368 return _handle_stripe_callable(
369 req,
370 stripe_api_fn=lambda req_obj: get_payment_service().create_charge(req_obj),
371 )
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
378 return _handle_stripe_callable(
379 req,
380 stripe_api_fn=lambda req_obj: get_payment_service().confirm_charge(req_obj),
381 )
384# -----------------------------------------------------------------------------
385# STRIPE (WEBHOOKS)
386# -----------------------------------------------------------------------------
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
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)
403 return _process_stripe_webhook(
404 request=req,
405 stripe_api_fn=lambda req_obj: get_payment_service().handle_refresh_webhook(req_obj),
406 )
409@https_fn.on_request()
410def h__stripe_return(req: flask.Request) -> flask.Response:
411 from flipdare.services import get_payment_service
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)
420 return _process_stripe_webhook(
421 request=req,
422 stripe_api_fn=lambda req_obj: get_payment_service().handle_return_webhook(req_obj),
423 )
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# )
446# -----------------------------------------------------------------------------
447# HELPERS
448# -----------------------------------------------------------------------------
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."""
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()
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()
477 return wrapper
480def _callable_request_wrapper(
481 func: Callable[[AppRequest[Any]], SchemaDict],
482) -> Callable[[_C], SchemaDict]:
483 """Authenticates and catches errors for callable endpoints."""
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
497 return wrapper
500def _wrapper_error(endpoint: str, error: Exception) -> NoReturn:
501 LOG().error(f"Error for endpoint={endpoint}\n\nError={error!s}")
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 )
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()
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()
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()