rairo commited on
Commit
3a12601
·
verified ·
1 Parent(s): bc244ad

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +318 -137
main.py CHANGED
@@ -83,7 +83,49 @@ except Exception as e:
83
  api_key_error = True
84
  app.logger.error(f"Error initializing Gemini API Client: {e}")
85
 
86
- # --- Authentication Decorator ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  def token_required(f):
88
  @wraps(f)
89
  def decorated_function(*args, **kwargs):
@@ -97,20 +139,43 @@ def token_required(f):
97
  id_token = auth_header.split('Bearer ')[1]
98
  try:
99
  decoded_token = firebase_auth.verify_id_token(id_token)
100
- flask_g.user = {
101
- "uid": decoded_token.get('uid'),
102
- "phone_number": decoded_token.get('phone_number') # This is crucial
103
- }
104
- if not flask_g.user["phone_number"]:
105
- app.logger.error(f"Token for UID {flask_g.user['uid']} does not contain a phone number.")
106
- return jsonify({"error": "Authenticated user has no phone number associated."}), 403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
 
108
- # Normalize the phone number from the token immediately
109
- flask_g.user["phone_number"] = normalize_phone(flask_g.user["phone_number"])
110
- if not flask_g.user["phone_number"]:
111
- app.logger.error(f"Failed to normalize phone number from token for UID {flask_g.user['uid']}.")
112
- return jsonify({"error": "Invalid phone number format in token."}), 403
113
-
114
  except firebase_auth.ExpiredIdTokenError:
115
  return jsonify({"error": "Token has expired"}), 401
116
  except firebase_auth.InvalidIdTokenError:
@@ -123,6 +188,20 @@ def token_required(f):
123
  return decorated_function
124
 
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  # --- Helper Functions (Adapted for API context) ---
127
  def normalize_phone(phone):
128
  if not phone: return None
@@ -688,116 +767,200 @@ def generate_diff_summary(current_data, proposed_data):
688
  for mid in deleted_members:
689
  summary.append(f"- {current_members[mid].get('name', 'Unnamed')} (ID: ...{mid[-6:]})")
690
 
691
- modified_member_summary = []
 
692
  for mid in common_members:
693
- member_diff = list(diff(current_members[mid], proposed_members[mid], ignore={'last_edited_at', 'last_edited_by', 'created_at', 'created_by', 'stories'})) # Ignore stories here, handle separately
694
- if member_diff:
695
- changes = []
696
- for change_type, path, values in member_diff:
697
- field = path if isinstance(path, str) else path # Get field name
698
- if change_type == 'change': changes.append(f"{field}: '{values}' -> '{values}'")
699
- elif change_type == 'add': changes.append(f"{field}: added '{values}'") # Should be rare at top level
700
- elif change_type == 'remove': changes.append(f"{field}: removed '{values}'") # Should be rare at top level
701
- if changes:
702
- modified_member_summary.append(f"- **{current_members[mid].get('name', 'Unnamed')}**: {'; '.join(changes)}")
703
- if modified_member_summary:
704
- summary.append("**Members Modified:**")
705
- summary.extend(modified_member_summary)
706
-
707
- # Process Relationships (Simplified comparison)
708
- current_rels = {(r.get('from_id'), r.get('to_id'), r.get('type')) for r in current_data.get('relationships', []) if isinstance(r, dict)}
709
- proposed_rels = {(r.get('from_id'), r.get('to_id'), r.get('type')) for r in proposed_data.get('relationships', []) if isinstance(r, dict)}
710
- added_rels = proposed_rels - current_rels
711
- deleted_rels = current_rels - proposed_rels
712
-
713
- id_to_name_current = {m['id']: m.get('name', 'Unknown') for m in current_data.get('family_members', [])}
714
- id_to_name_proposed = {m['id']: m.get('name', 'Unknown') for m in proposed_data.get('family_members', [])}
715
- def format_rel(rel_tuple, names):
716
- f_id, t_id, r_type = rel_tuple
717
- f_name = names.get(f_id, f"...{f_id[-6:]}")
718
- t_name = names.get(t_id, f"...{t_id[-6:]}")
719
- return f"{f_name} {r_type} {t_name}"
720
 
721
  if added_rels:
722
  summary.append("**Relationships Added:**")
723
- for rel in added_rels: summary.append(f"- {format_rel(rel, id_to_name_proposed)}")
 
 
 
724
  if deleted_rels:
725
  summary.append("**Relationships Deleted:**")
726
- for rel in deleted_rels: summary.append(f"- {format_rel(rel, id_to_name_current)}")
727
-
728
- # Process Stories (Simplified: check if stories list changed for any common member)
729
- story_changes = []
730
- for mid in common_members:
731
- current_stories = current_members[mid].get('stories', [])
732
- proposed_stories = proposed_members[mid].get('stories', [])
733
- # Simple check: compare lengths or string representations (not perfect but gives an idea)
734
- if json.dumps(current_stories, sort_keys=True) != json.dumps(proposed_stories, sort_keys=True):
735
- story_changes.append(f"- Stories for **{current_members[mid].get('name', 'Unnamed')}**")
736
- if story_changes:
737
- summary.append("**Stories Modified:**")
738
- summary.extend(story_changes)
 
 
 
 
739
  except Exception as e:
740
- summary.append(f"Error generating diff: {e}")
741
- return "\n".join(summary) if summary else "No significant changes detected."
742
 
743
- # --- API Endpoints ---
744
 
745
- # Helper to check for Firebase/API key errors (used by non-authed endpoints)
746
  def check_system_health():
747
- if firebase_error: return jsonify({"error": "Firebase system error", "detail": firebase_error}), 503
 
 
 
748
  return None
749
 
750
- @app.route("/health", methods=["GET"])
 
 
 
751
  def health_check():
752
- system_error_response = check_system_health()
753
- if system_error_response: return system_error_response
754
- if api_key_error:
755
- return jsonify({"status": "Firebase OK, Gemini API Key Error"}), 200
756
- return jsonify({"status": "Firebase OK, Gemini OK"}), 200
 
 
 
757
 
758
- # --- User/Auth related endpoints ---
759
  @app.route('/user/me', methods=['GET'])
760
  @token_required
761
- def get_my_user_info():
762
- """
763
- Returns basic info about the authenticated user and their primary tree.
764
- This also serves as a way to "register" or ensure the user's tree exists.
765
- """
766
- current_user_phone = flask_g.user["phone_number"]
767
 
768
- # Load (or initialize) the user's primary tree data
769
- tree_data, error = load_tree_data(current_user_phone)
770
- if error:
771
- # Specific check for initialization failure, which might be a server-side issue
772
- if "Failed to initialize and save new tree" in error:
773
- app.logger.error(f"Critical error initializing tree for {current_user_phone}: {error}")
774
- return jsonify({"error": "Could not initialize user tree data", "detail": error}), 500
775
- # Other load errors might be less critical or indicate no data (which is fine if new)
776
- app.logger.warning(f"Could not load tree data for {current_user_phone} during /user/me: {error}")
777
- # If tree_data is None and it's not an init error, it implies a load issue post-init.
778
- # However, load_tree_data is designed to create if None, so this path needs careful thought.
779
- # For now, assume if tree_data is None after load_tree_data, it's a significant issue.
780
- if tree_data is None:
781
- return jsonify({"error": "Could not load user tree data", "detail": error}), 404 # Or 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
782
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
783
  return jsonify({
784
- "uid": flask_g.user["uid"],
785
- "phone_number": current_user_phone,
786
- "primary_tree_metadata": tree_data.get("metadata") if tree_data else None
787
  }), 200
788
 
789
 
790
  # --- Tree Data Endpoints ---
791
- # Get a specific tree (can be own or another if part of it - auth handled by client if needed for "view")
792
- # For simplicity, this endpoint is public for GET, but write operations on trees are protected.
793
- @app.route('/tree/<string:owner_phone_of_tree_to_view>', methods=['GET'])
794
- def get_tree_data_api(owner_phone_of_tree_to_view):
795
- system_error_response = check_system_health() # Basic check
796
  if system_error_response: return system_error_response
797
-
798
- norm_phone = normalize_phone(owner_phone_of_tree_to_view)
799
- if not norm_phone:
800
- return jsonify({"error": "Invalid owner_phone format"}), 400
801
 
802
  tree_data, error = load_tree_data(norm_phone)
803
  if error:
@@ -811,7 +974,15 @@ def get_tree_data_api(owner_phone_of_tree_to_view):
811
  @token_required
812
  def get_linked_trees_api():
813
  """Gets trees the authenticated user is a member of (excluding their own primary tree)."""
814
- current_user_phone = flask_g.user["phone_number"]
 
 
 
 
 
 
 
 
815
 
816
  linked_owner_phones_map, error = find_linked_trees(current_user_phone)
817
  if error:
@@ -859,6 +1030,7 @@ def get_tree_graph_api(owner_phone_of_tree):
859
  # --- Member Management Endpoints (Protected) ---
860
  @app.route('/tree/<string:owner_phone_of_tree>/members', methods=['POST'])
861
  @token_required
 
862
  def add_member_api(owner_phone_of_tree):
863
  current_user_phone = flask_g.user["phone_number"] # Authenticated user
864
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
@@ -923,6 +1095,7 @@ def add_member_api(owner_phone_of_tree):
923
 
924
  @app.route('/tree/<string:owner_phone_of_tree>/members/<string:member_id>', methods=['PUT'])
925
  @token_required
 
926
  def edit_member_api(owner_phone_of_tree, member_id):
927
  current_user_phone = flask_g.user["phone_number"]
928
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
@@ -1003,6 +1176,7 @@ def edit_member_api(owner_phone_of_tree, member_id):
1003
 
1004
  @app.route('/tree/<string:owner_phone_of_tree>/members/<string:member_id>', methods=['DELETE'])
1005
  @token_required
 
1006
  def delete_member_api(owner_phone_of_tree, member_id):
1007
  current_user_phone = flask_g.user["phone_number"]
1008
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
@@ -1042,6 +1216,7 @@ def delete_member_api(owner_phone_of_tree, member_id):
1042
  # --- Relationship Management Endpoints (Protected) ---
1043
  @app.route('/tree/<string:owner_phone_of_tree>/relationships', methods=['POST'])
1044
  @token_required
 
1045
  def add_relationship_api(owner_phone_of_tree):
1046
  current_user_phone = flask_g.user["phone_number"]
1047
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
@@ -1092,6 +1267,7 @@ def add_relationship_api(owner_phone_of_tree):
1092
 
1093
  @app.route('/tree/<string:owner_phone_of_tree>/relationships/delete', methods=['POST'])
1094
  @token_required
 
1095
  def delete_relationship_api(owner_phone_of_tree):
1096
  current_user_phone = flask_g.user["phone_number"]
1097
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
@@ -1138,61 +1314,58 @@ def delete_relationship_api(owner_phone_of_tree):
1138
  # --- Story Management Endpoints (Protected) ---
1139
  @app.route('/tree/<string:owner_phone_of_tree>/members/<string:member_id>/stories', methods=['POST'])
1140
  @token_required
 
1141
  def add_story_api(owner_phone_of_tree, member_id):
1142
- current_user_phone = flask_g.user["phone_number"] # Authenticated user adding the story
1143
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
1144
  if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400
1145
 
1146
- # Anyone authenticated can add a story to any member of any tree they can see/access.
1147
- # The "added_by" field will track who added it.
1148
- # If stricter control is needed (e.g., only tree owner or proposer), add checks here.
1149
-
1150
  req_data = request.json
1151
- if not req_data or "text" not in req_data:
1152
- return jsonify({"error": "Missing story text in request body"}), 400
1153
  story_text = req_data["text"].strip()
1154
- if not story_text: return jsonify({"error": "Story text cannot be empty"}), 400
1155
 
1156
  tree_data, load_error = load_tree_data(norm_owner_phone_of_tree)
1157
  if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500
1158
-
1159
  original_tree_data_for_save = deepcopy(tree_data) # For save if current_user is owner
1160
  member = find_person_by_id(tree_data, member_id)
1161
- if not member: return jsonify({"error": "Member not found to add story to"}), 404
1162
 
1163
- if "stories" not in member or not isinstance(member["stories"], list):
1164
- member["stories"] = []
1165
-
1166
- timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1167
- new_story_entry = deepcopy(DEFAULT_STORY_STRUCTURE)
1168
- new_story_entry.update({
1169
- "timestamp": timestamp, "text": story_text, "added_by": current_user_phone
1170
- })
1171
- member["stories"].append(new_story_entry)
1172
- member["last_edited_at"] = timestamp # Also update member's last edit
1173
- member["last_edited_by"] = current_user_phone # User who added story is last editor of member for this change
1174
 
1175
- # If the authenticated user is the owner of the tree, save directly.
1176
- # Otherwise, this change should be part of a proposal.
1177
  if current_user_phone == norm_owner_phone_of_tree:
 
1178
  save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save)
1179
  if not save_success:
1180
- return jsonify({"error": "Failed to save new story", "detail": save_error}), 500
1181
- response_message = "Story added and saved successfully."
1182
  if save_error: response_message += f" Warning: {save_error}"
1183
- return jsonify({"message": response_message, "story": new_story_entry}), 201
1184
  else:
1185
- # This endpoint is now for direct save by owner. Collaborators use /proposals.
1186
- # So, if not owner, this path shouldn't be hit for adding stories directly.
1187
- # However, if we allow anyone to add stories to any tree (public edit), this logic would change.
1188
- # For now, sticking to owner-edit or proposal.
1189
- # This implies that if a collaborator wants to add a story, they modify their copy of tree_data
1190
- # and then submit the entire tree_data via the /proposals endpoint.
1191
- return jsonify({"error": "Forbidden: Use proposals to add stories to trees you do not own."}), 403
 
 
 
 
1192
 
1193
 
1194
- @app.route('/tree/<string:owner_phone_of_tree>/members/<string:member_id>/stories/<path:story_timestamp>', methods=['DELETE'])
1195
  @token_required
 
1196
  def delete_story_api(owner_phone_of_tree, member_id, story_timestamp):
1197
  current_user_phone = flask_g.user["phone_number"]
1198
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
@@ -1238,6 +1411,7 @@ def delete_story_api(owner_phone_of_tree, member_id, story_timestamp):
1238
  # --- Collaboration Endpoints (Protected) ---
1239
  @app.route('/tree/<string:owner_phone_of_tree>/proposals', methods=['POST'])
1240
  @token_required
 
1241
  def propose_changes_api(owner_phone_of_tree):
1242
  proposer_phone_from_token = flask_g.user["phone_number"] # This is the authenticated user making the proposal
1243
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
@@ -1261,6 +1435,7 @@ def propose_changes_api(owner_phone_of_tree):
1261
 
1262
  @app.route('/tree/my_proposals/pending', methods=['GET']) # Get pending proposals FOR the authenticated user's tree(s)
1263
  @token_required
 
1264
  def get_my_pending_changes_api():
1265
  owner_phone_from_token = flask_g.user["phone_number"] # Authenticated user is the owner
1266
 
@@ -1272,6 +1447,7 @@ def get_my_pending_changes_api():
1272
 
1273
  @app.route('/tree/my_proposals/<string:proposer_phone_to_manage>/accept', methods=['POST'])
1274
  @token_required
 
1275
  def accept_changes_api(proposer_phone_to_manage):
1276
  owner_phone_from_token = flask_g.user["phone_number"] # Authenticated user is the owner
1277
  norm_proposer_phone = normalize_phone(proposer_phone_to_manage)
@@ -1285,6 +1461,7 @@ def accept_changes_api(proposer_phone_to_manage):
1285
 
1286
  @app.route('/tree/my_proposals/<string:proposer_phone_to_manage>/reject', methods=['POST'])
1287
  @token_required
 
1288
  def reject_changes_api(proposer_phone_to_manage):
1289
  owner_phone_from_token = flask_g.user["phone_number"] # Authenticated user is the owner
1290
  norm_proposer_phone = normalize_phone(proposer_phone_to_manage)
@@ -1297,6 +1474,7 @@ def reject_changes_api(proposer_phone_to_manage):
1297
 
1298
  @app.route('/tree/my_proposals/<string:proposer_phone_to_review>/diff', methods=['GET'])
1299
  @token_required
 
1300
  def get_proposal_diff_api(proposer_phone_to_review):
1301
  owner_phone_from_token = flask_g.user["phone_number"] # Authenticated user is the owner
1302
  norm_proposer_phone = normalize_phone(proposer_phone_to_review)
@@ -1340,6 +1518,7 @@ def ai_build_tree_api():
1340
 
1341
  @app.route('/tree/<string:owner_phone_of_tree>/ai_merge_suggestions', methods=['POST'])
1342
  @token_required
 
1343
  def ai_merge_tree_api(owner_phone_of_tree):
1344
  current_user_phone = flask_g.user["phone_number"]
1345
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
@@ -1382,7 +1561,7 @@ def ai_merge_tree_api(owner_phone_of_tree):
1382
  ai_name = person_ai.get("name")
1383
  if not ai_name: continue
1384
  existing_people_with_name = find_person_by_name(tree_data, ai_name)
1385
- existing_person = existing_people_with_name if existing_people_with_name else None
1386
  person_updated = False
1387
  if existing_person:
1388
  id_map[ai_name] = existing_person['id']
@@ -1474,6 +1653,7 @@ def generate_quiz_api(owner_phone_of_tree):
1474
  # --- Settings & Profile Endpoints (Protected, for authenticated user's own tree) ---
1475
  @app.route('/user/me/settings', methods=['PUT'])
1476
  @token_required
 
1477
  def update_my_settings_api():
1478
  current_user_phone = flask_g.user["phone_number"] # Settings are for the authenticated user's own tree
1479
 
@@ -1508,6 +1688,7 @@ def update_my_settings_api():
1508
 
1509
  @app.route('/user/me/profile', methods=['PUT'])
1510
  @token_required
 
1511
  def update_my_profile_api():
1512
  current_user_phone = flask_g.user["phone_number"] # Profile is for the authenticated user's own tree
1513
 
@@ -1592,4 +1773,4 @@ def get_timeline_api(owner_phone_of_tree):
1592
 
1593
  # --------- Run the App ---------
1594
  if __name__ == "__main__":
1595
- app.run(host="0.0.0.0", port=7860, debug=True)
 
83
  api_key_error = True
84
  app.logger.error(f"Error initializing Gemini API Client: {e}")
85
 
86
+
87
+ # --- UID to Phone Mapping Functions (NEW for email auth) ---
88
+ def get_uid_phone_mapping_path(uid):
89
+ """Get the Firebase path for storing user's phone number by UID"""
90
+ return f'users_by_uid/{uid}'
91
+
92
+ def get_user_phone_by_uid(uid):
93
+ """Look up a user's phone number from their UID"""
94
+ if firebase_error or not firebase_db_ref:
95
+ return None, firebase_error or "Firebase not initialized"
96
+ try:
97
+ path = get_uid_phone_mapping_path(uid)
98
+ data = firebase_db_ref.child(path).get()
99
+ if data and isinstance(data, dict):
100
+ return data.get('phone_number'), None
101
+ return None, "Phone number not linked for this user"
102
+ except Exception as e:
103
+ return None, f"Error looking up phone: {e}"
104
+
105
+ def set_user_phone_by_uid(uid, phone_number, display_name=None, email=None):
106
+ """Store a user's phone number mapping by UID"""
107
+ if firebase_error or not firebase_db_ref:
108
+ return False, firebase_error or "Firebase not initialized"
109
+ norm_phone = normalize_phone(phone_number)
110
+ if not norm_phone:
111
+ return False, "Invalid phone number format"
112
+ try:
113
+ path = get_uid_phone_mapping_path(uid)
114
+ user_data = {
115
+ 'phone_number': norm_phone,
116
+ 'linked_at': datetime.now().strftime("%Y-%m-%d %H:%M:%S")
117
+ }
118
+ if display_name:
119
+ user_data['display_name'] = display_name
120
+ if email:
121
+ user_data['email'] = email
122
+ firebase_db_ref.child(path).set(user_data)
123
+ return True, None
124
+ except Exception as e:
125
+ return False, f"Error saving phone mapping: {e}"
126
+
127
+
128
+ # --- Authentication Decorator (UPDATED for email auth) ---
129
  def token_required(f):
130
  @wraps(f)
131
  def decorated_function(*args, **kwargs):
 
139
  id_token = auth_header.split('Bearer ')[1]
140
  try:
141
  decoded_token = firebase_auth.verify_id_token(id_token)
142
+ uid = decoded_token.get('uid')
143
+ email = decoded_token.get('email')
144
+
145
+ # First check if phone is in the token (legacy phone auth)
146
+ phone_from_token = decoded_token.get('phone_number')
147
+
148
+ if phone_from_token:
149
+ # Legacy phone authentication
150
+ phone_number = normalize_phone(phone_from_token)
151
+ if not phone_number:
152
+ app.logger.error(f"Failed to normalize phone number from token for UID {uid}.")
153
+ return jsonify({"error": "Invalid phone number format in token."}), 403
154
+ flask_g.user = {
155
+ "uid": uid,
156
+ "email": email,
157
+ "phone_number": phone_number,
158
+ "phone_linked": True
159
+ }
160
+ else:
161
+ # Email authentication - look up phone from database
162
+ phone_number, lookup_error = get_user_phone_by_uid(uid)
163
+ if not phone_number:
164
+ # Phone not yet linked - allow access but mark as unlinked
165
+ flask_g.user = {
166
+ "uid": uid,
167
+ "email": email,
168
+ "phone_number": None,
169
+ "phone_linked": False
170
+ }
171
+ else:
172
+ flask_g.user = {
173
+ "uid": uid,
174
+ "email": email,
175
+ "phone_number": phone_number,
176
+ "phone_linked": True
177
+ }
178
 
 
 
 
 
 
 
179
  except firebase_auth.ExpiredIdTokenError:
180
  return jsonify({"error": "Token has expired"}), 401
181
  except firebase_auth.InvalidIdTokenError:
 
188
  return decorated_function
189
 
190
 
191
+ def phone_required(f):
192
+ """Decorator for endpoints that require a linked phone number"""
193
+ @wraps(f)
194
+ def decorated_function(*args, **kwargs):
195
+ if not flask_g.user.get("phone_linked") or not flask_g.user.get("phone_number"):
196
+ return jsonify({
197
+ "error": "Phone number not linked",
198
+ "message": "Please link your phone number to access this feature.",
199
+ "action_required": "link_phone"
200
+ }), 403
201
+ return f(*args, **kwargs)
202
+ return decorated_function
203
+
204
+
205
  # --- Helper Functions (Adapted for API context) ---
206
  def normalize_phone(phone):
207
  if not phone: return None
 
767
  for mid in deleted_members:
768
  summary.append(f"- {current_members[mid].get('name', 'Unnamed')} (ID: ...{mid[-6:]})")
769
 
770
+ # Check for edited members
771
+ edited_summary = []
772
  for mid in common_members:
773
+ curr_m, prop_m = current_members[mid], proposed_members[mid]
774
+ changes = []
775
+ for key in ['name', 'dob', 'dod', 'gender', 'totem', 'phone']:
776
+ if curr_m.get(key) != prop_m.get(key):
777
+ changes.append(f"{key}: '{curr_m.get(key)}' -> '{prop_m.get(key)}'")
778
+ # Story changes
779
+ curr_stories = set(s.get('timestamp') for s in curr_m.get('stories', []) if isinstance(s, dict))
780
+ prop_stories = set(s.get('timestamp') for s in prop_m.get('stories', []) if isinstance(s, dict))
781
+ if curr_stories != prop_stories:
782
+ added_s = len(prop_stories - curr_stories)
783
+ removed_s = len(curr_stories - prop_stories)
784
+ if added_s > 0: changes.append(f"{added_s} storie(s) added")
785
+ if removed_s > 0: changes.append(f"{removed_s} storie(s) removed")
786
+ if changes:
787
+ edited_summary.append(f"- {curr_m.get('name', 'Unnamed')} (ID: ...{mid[-6:]}): {', '.join(changes)}")
788
+ if edited_summary:
789
+ summary.append("**Members Edited:**")
790
+ summary.extend(edited_summary)
791
+
792
+ # Process relationships
793
+ curr_rels = set(tuple(sorted([r['from_id'],r['to_id']])) + (r['type'],) for r in current_data.get('relationships', []) if isinstance(r, dict) and all(k in r for k in ['from_id', 'to_id', 'type']))
794
+ prop_rels = set(tuple(sorted([r['from_id'],r['to_id']])) + (r['type'],) for r in proposed_data.get('relationships', []) if isinstance(r, dict) and all(k in r for k in ['from_id', 'to_id', 'type']))
795
+ added_rels = prop_rels - curr_rels
796
+ deleted_rels = curr_rels - prop_rels
 
 
 
797
 
798
  if added_rels:
799
  summary.append("**Relationships Added:**")
800
+ for rel_tuple in added_rels:
801
+ name1 = proposed_members.get(rel_tuple[0], {}).get('name', f'...{rel_tuple[0][-6:]}')
802
+ name2 = proposed_members.get(rel_tuple[1], {}).get('name', f'...{rel_tuple[1][-6:]}')
803
+ summary.append(f"- {name1} <--> {name2} ({rel_tuple[2]})")
804
  if deleted_rels:
805
  summary.append("**Relationships Deleted:**")
806
+ for rel_tuple in deleted_rels:
807
+ name1 = current_members.get(rel_tuple[0], {}).get('name', f'...{rel_tuple[0][-6:]}')
808
+ name2 = current_members.get(rel_tuple[1], {}).get('name', f'...{rel_tuple[1][-6:]}')
809
+ summary.append(f"- {name1} <--> {name2} ({rel_tuple[2]})")
810
+
811
+ # Settings/Metadata changes
812
+ settings_changes = []
813
+ if current_data.get('settings', {}).get('theme') != proposed_data.get('settings', {}).get('theme'):
814
+ settings_changes.append(f"Theme: '{current_data.get('settings', {}).get('theme')}' -> '{proposed_data.get('settings', {}).get('theme')}'")
815
+ if current_data.get('metadata', {}).get('tree_name') != proposed_data.get('metadata', {}).get('tree_name'):
816
+ settings_changes.append(f"Tree Name: '{current_data.get('metadata', {}).get('tree_name')}' -> '{proposed_data.get('metadata', {}).get('tree_name')}'")
817
+ if settings_changes:
818
+ summary.append("**Settings/Metadata Changed:**")
819
+ summary.extend([f"- {c}" for c in settings_changes])
820
+
821
+ if not summary: return "No significant changes detected."
822
+ return "\n".join(summary)
823
  except Exception as e:
824
+ app.logger.error(f"Error generating diff summary: {e}")
825
+ return f"Error generating diff summary: {e}"
826
 
 
827
 
828
+ # --- System Health Check Helper ---
829
  def check_system_health():
830
+ if firebase_error:
831
+ return jsonify({"error": "Firebase not available", "detail": firebase_error}), 503
832
+ if not firebase_db_ref:
833
+ return jsonify({"error": "Firebase database reference not set."}), 503
834
  return None
835
 
836
+
837
+ # ======================= API Endpoints =======================
838
+
839
+ @app.route('/health', methods=['GET'])
840
  def health_check():
841
+ status = {
842
+ "status": "ok",
843
+ "firebase": "ok" if not firebase_error and firebase_db_ref else f"error: {firebase_error or 'DB ref not set'}",
844
+ "gemini_api": "ok" if not api_key_error and genai_client else "error or not configured"
845
+ }
846
+ overall_ok = status["firebase"] == "ok"
847
+ return jsonify(status), 200 if overall_ok else 503
848
+
849
 
850
+ # --- NEW: User Management Endpoints for Email Auth ---
851
  @app.route('/user/me', methods=['GET'])
852
  @token_required
853
+ def get_current_user_api():
854
+ """Get the authenticated user's information"""
855
+ uid = flask_g.user["uid"]
856
+ email = flask_g.user.get("email")
857
+ phone_number = flask_g.user.get("phone_number")
858
+ phone_linked = flask_g.user.get("phone_linked", False)
859
 
860
+ response = {
861
+ "uid": uid,
862
+ "email": email,
863
+ "phone_number": phone_number,
864
+ "phone_linked": phone_linked
865
+ }
866
+
867
+ # If phone is linked, get additional user info and primary tree
868
+ if phone_linked and phone_number:
869
+ tree_data, _ = load_tree_data(phone_number)
870
+ if tree_data:
871
+ response["display_name"] = tree_data.get("profile", {}).get("name")
872
+ response["primary_tree"] = {
873
+ "owner_phone": phone_number,
874
+ "tree_name": tree_data.get("metadata", {}).get("tree_name", "My Family Tree"),
875
+ "created_at": tree_data.get("metadata", {}).get("created_at")
876
+ }
877
+ else:
878
+ # Get display name from uid mapping if available
879
+ try:
880
+ path = get_uid_phone_mapping_path(uid)
881
+ user_data = firebase_db_ref.child(path).get()
882
+ if user_data and isinstance(user_data, dict):
883
+ response["display_name"] = user_data.get("display_name")
884
+ except Exception:
885
+ pass
886
+
887
+ return jsonify(response), 200
888
 
889
+
890
+ @app.route('/user/link_phone', methods=['POST'])
891
+ @token_required
892
+ def link_phone_api():
893
+ """Link a phone number to the authenticated user's account"""
894
+ uid = flask_g.user["uid"]
895
+ email = flask_g.user.get("email")
896
+
897
+ # Check if phone is already linked
898
+ if flask_g.user.get("phone_linked"):
899
+ return jsonify({
900
+ "message": "Phone number already linked",
901
+ "phone_number": flask_g.user.get("phone_number")
902
+ }), 200
903
+
904
+ req_data = request.json
905
+ if not req_data or "phone_number" not in req_data:
906
+ return jsonify({"error": "Missing phone_number in request body"}), 400
907
+
908
+ phone_number = req_data["phone_number"]
909
+ display_name = req_data.get("display_name")
910
+
911
+ # Validate phone format
912
+ norm_phone = normalize_phone(phone_number)
913
+ if not norm_phone:
914
+ return jsonify({"error": "Invalid phone number format. Use +263... format."}), 400
915
+
916
+ # Check if this phone is already linked to another user
917
+ try:
918
+ all_users = firebase_db_ref.child('users_by_uid').get()
919
+ if all_users:
920
+ for existing_uid_key, user_data in all_users.items():
921
+ if isinstance(user_data, dict) and user_data.get('phone_number') == norm_phone:
922
+ if existing_uid_key != uid:
923
+ return jsonify({
924
+ "error": "Phone number already linked",
925
+ "message": "This phone number is already linked to another account."
926
+ }), 409
927
+ except Exception as e:
928
+ app.logger.warning(f"Could not check for existing phone links: {e}")
929
+
930
+ # Store the phone mapping
931
+ success, error = set_user_phone_by_uid(uid, norm_phone, display_name, email)
932
+ if not success:
933
+ return jsonify({"error": "Failed to link phone number", "detail": error}), 500
934
+
935
+ # Initialize user's tree if it doesn't exist
936
+ tree_data, load_error = load_tree_data(norm_phone)
937
+ if load_error and "Failed to initialize" in load_error:
938
+ return jsonify({"error": "Phone linked but tree initialization failed", "detail": load_error}), 500
939
+
940
+ # Update profile name if display_name provided
941
+ if display_name and tree_data:
942
+ tree_data["profile"]["name"] = display_name
943
+ me_node = find_person_by_id(tree_data, "Me")
944
+ if me_node:
945
+ me_node["name"] = display_name
946
+ me_node["last_edited_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
947
+ me_node["last_edited_by"] = norm_phone
948
+ save_tree_data(norm_phone, tree_data, tree_data)
949
+
950
  return jsonify({
951
+ "message": "Phone number linked successfully",
952
+ "phone_number": norm_phone,
953
+ "tree_initialized": tree_data is not None
954
  }), 200
955
 
956
 
957
  # --- Tree Data Endpoints ---
958
+ @app.route('/tree/<string:owner_phone>', methods=['GET'])
959
+ def get_tree_data_api(owner_phone):
960
+ system_error_response = check_system_health()
 
 
961
  if system_error_response: return system_error_response
962
+ norm_phone = normalize_phone(owner_phone)
963
+ if not norm_phone: return jsonify({"error": "Invalid owner_phone format"}), 400
 
 
964
 
965
  tree_data, error = load_tree_data(norm_phone)
966
  if error:
 
974
  @token_required
975
  def get_linked_trees_api():
976
  """Gets trees the authenticated user is a member of (excluding their own primary tree)."""
977
+ current_user_phone = flask_g.user.get("phone_number")
978
+
979
+ # Handle unlinked phone users gracefully
980
+ if not current_user_phone:
981
+ return jsonify({
982
+ "message": "Link your phone number to see linked trees",
983
+ "trees": [],
984
+ "action_required": "link_phone"
985
+ }), 200
986
 
987
  linked_owner_phones_map, error = find_linked_trees(current_user_phone)
988
  if error:
 
1030
  # --- Member Management Endpoints (Protected) ---
1031
  @app.route('/tree/<string:owner_phone_of_tree>/members', methods=['POST'])
1032
  @token_required
1033
+ @phone_required
1034
  def add_member_api(owner_phone_of_tree):
1035
  current_user_phone = flask_g.user["phone_number"] # Authenticated user
1036
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
 
1095
 
1096
  @app.route('/tree/<string:owner_phone_of_tree>/members/<string:member_id>', methods=['PUT'])
1097
  @token_required
1098
+ @phone_required
1099
  def edit_member_api(owner_phone_of_tree, member_id):
1100
  current_user_phone = flask_g.user["phone_number"]
1101
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
 
1176
 
1177
  @app.route('/tree/<string:owner_phone_of_tree>/members/<string:member_id>', methods=['DELETE'])
1178
  @token_required
1179
+ @phone_required
1180
  def delete_member_api(owner_phone_of_tree, member_id):
1181
  current_user_phone = flask_g.user["phone_number"]
1182
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
 
1216
  # --- Relationship Management Endpoints (Protected) ---
1217
  @app.route('/tree/<string:owner_phone_of_tree>/relationships', methods=['POST'])
1218
  @token_required
1219
+ @phone_required
1220
  def add_relationship_api(owner_phone_of_tree):
1221
  current_user_phone = flask_g.user["phone_number"]
1222
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
 
1267
 
1268
  @app.route('/tree/<string:owner_phone_of_tree>/relationships/delete', methods=['POST'])
1269
  @token_required
1270
+ @phone_required
1271
  def delete_relationship_api(owner_phone_of_tree):
1272
  current_user_phone = flask_g.user["phone_number"]
1273
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
 
1314
  # --- Story Management Endpoints (Protected) ---
1315
  @app.route('/tree/<string:owner_phone_of_tree>/members/<string:member_id>/stories', methods=['POST'])
1316
  @token_required
1317
+ @phone_required
1318
  def add_story_api(owner_phone_of_tree, member_id):
1319
+ current_user_phone = flask_g.user["phone_number"]
1320
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
1321
  if not norm_owner_phone_of_tree: return jsonify({"error": "Invalid owner_phone_of_tree format"}), 400
1322
 
 
 
 
 
1323
  req_data = request.json
1324
+ if not req_data or "text" not in req_data or not req_data["text"].strip():
1325
+ return jsonify({"error": "Missing or empty 'text' for story"}), 400
1326
  story_text = req_data["text"].strip()
 
1327
 
1328
  tree_data, load_error = load_tree_data(norm_owner_phone_of_tree)
1329
  if load_error: return jsonify({"error": "Could not load tree data", "detail": load_error}), 500
1330
+
1331
  original_tree_data_for_save = deepcopy(tree_data) # For save if current_user is owner
1332
  member = find_person_by_id(tree_data, member_id)
1333
+ if not member: return jsonify({"error": "Member not found"}), 404
1334
 
1335
+ new_story = {
1336
+ "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
1337
+ "text": story_text,
1338
+ "added_by": current_user_phone
1339
+ }
1340
+ member.setdefault("stories", []).append(new_story)
1341
+ member["last_edited_at"] = new_story["timestamp"]
1342
+ member["last_edited_by"] = current_user_phone
 
 
 
1343
 
 
 
1344
  if current_user_phone == norm_owner_phone_of_tree:
1345
+ # Owner adds story directly
1346
  save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save)
1347
  if not save_success:
1348
+ return jsonify({"error": "Failed to save story", "detail": save_error}), 500
1349
+ response_message = "Story added successfully."
1350
  if save_error: response_message += f" Warning: {save_error}"
1351
+ return jsonify({"message": response_message, "story": new_story}), 201
1352
  else:
1353
+ # Collaborator adds story: For simplicity, let's allow collaborators to add stories directly for now.
1354
+ # A more strict approach would be to use proposals for all changes.
1355
+ # Here, we allow stories to be added by non-owners as a contribution.
1356
+ # The story will have `added_by` set to the collaborator's phone.
1357
+ # Tree owner can delete it later if needed.
1358
+ save_success, save_error = save_tree_data(norm_owner_phone_of_tree, tree_data, original_tree_data_for_save)
1359
+ if not save_success:
1360
+ return jsonify({"error": "Failed to save story (as collaborator)", "detail": save_error}), 500
1361
+ response_message = "Story added successfully (as collaborator)."
1362
+ if save_error: response_message += f" Warning: {save_error}"
1363
+ return jsonify({"message": response_message, "story": new_story}), 201
1364
 
1365
 
1366
+ @app.route('/tree/<string:owner_phone_of_tree>/members/<string:member_id>/stories/<string:story_timestamp>', methods=['DELETE'])
1367
  @token_required
1368
+ @phone_required
1369
  def delete_story_api(owner_phone_of_tree, member_id, story_timestamp):
1370
  current_user_phone = flask_g.user["phone_number"]
1371
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
 
1411
  # --- Collaboration Endpoints (Protected) ---
1412
  @app.route('/tree/<string:owner_phone_of_tree>/proposals', methods=['POST'])
1413
  @token_required
1414
+ @phone_required
1415
  def propose_changes_api(owner_phone_of_tree):
1416
  proposer_phone_from_token = flask_g.user["phone_number"] # This is the authenticated user making the proposal
1417
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
 
1435
 
1436
  @app.route('/tree/my_proposals/pending', methods=['GET']) # Get pending proposals FOR the authenticated user's tree(s)
1437
  @token_required
1438
+ @phone_required
1439
  def get_my_pending_changes_api():
1440
  owner_phone_from_token = flask_g.user["phone_number"] # Authenticated user is the owner
1441
 
 
1447
 
1448
  @app.route('/tree/my_proposals/<string:proposer_phone_to_manage>/accept', methods=['POST'])
1449
  @token_required
1450
+ @phone_required
1451
  def accept_changes_api(proposer_phone_to_manage):
1452
  owner_phone_from_token = flask_g.user["phone_number"] # Authenticated user is the owner
1453
  norm_proposer_phone = normalize_phone(proposer_phone_to_manage)
 
1461
 
1462
  @app.route('/tree/my_proposals/<string:proposer_phone_to_manage>/reject', methods=['POST'])
1463
  @token_required
1464
+ @phone_required
1465
  def reject_changes_api(proposer_phone_to_manage):
1466
  owner_phone_from_token = flask_g.user["phone_number"] # Authenticated user is the owner
1467
  norm_proposer_phone = normalize_phone(proposer_phone_to_manage)
 
1474
 
1475
  @app.route('/tree/my_proposals/<string:proposer_phone_to_review>/diff', methods=['GET'])
1476
  @token_required
1477
+ @phone_required
1478
  def get_proposal_diff_api(proposer_phone_to_review):
1479
  owner_phone_from_token = flask_g.user["phone_number"] # Authenticated user is the owner
1480
  norm_proposer_phone = normalize_phone(proposer_phone_to_review)
 
1518
 
1519
  @app.route('/tree/<string:owner_phone_of_tree>/ai_merge_suggestions', methods=['POST'])
1520
  @token_required
1521
+ @phone_required
1522
  def ai_merge_tree_api(owner_phone_of_tree):
1523
  current_user_phone = flask_g.user["phone_number"]
1524
  norm_owner_phone_of_tree = normalize_phone(owner_phone_of_tree)
 
1561
  ai_name = person_ai.get("name")
1562
  if not ai_name: continue
1563
  existing_people_with_name = find_person_by_name(tree_data, ai_name)
1564
+ existing_person = existing_people_with_name[0] if existing_people_with_name else None
1565
  person_updated = False
1566
  if existing_person:
1567
  id_map[ai_name] = existing_person['id']
 
1653
  # --- Settings & Profile Endpoints (Protected, for authenticated user's own tree) ---
1654
  @app.route('/user/me/settings', methods=['PUT'])
1655
  @token_required
1656
+ @phone_required
1657
  def update_my_settings_api():
1658
  current_user_phone = flask_g.user["phone_number"] # Settings are for the authenticated user's own tree
1659
 
 
1688
 
1689
  @app.route('/user/me/profile', methods=['PUT'])
1690
  @token_required
1691
+ @phone_required
1692
  def update_my_profile_api():
1693
  current_user_phone = flask_g.user["phone_number"] # Profile is for the authenticated user's own tree
1694
 
 
1773
 
1774
  # --------- Run the App ---------
1775
  if __name__ == "__main__":
1776
+ app.run(host="0.0.0.0", port=7860, debug=True)