From 2d9f1576f575b5f94c8fa20c245cc9d714c18161 Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Mon, 29 Jun 2026 16:48:03 +0530 Subject: [PATCH 01/16] Fixing import error --- src/lpi/config.py | 7 + src/lpi/notifications.py | 120 ++++++++++++++++++ src/lpi/routers/signals.py | 18 ++- .../20260627000000_create_notifications.sql | 17 +++ 4 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 src/lpi/notifications.py create mode 100644 supabase/migrations/20260627000000_create_notifications.sql diff --git a/src/lpi/config.py b/src/lpi/config.py index 46fce64..961d8a4 100644 --- a/src/lpi/config.py +++ b/src/lpi/config.py @@ -19,6 +19,10 @@ class Settings(BaseSettings): github_client_id: str = "" github_client_secret: str = "" admin_user_ids: str = "" + smtp_server: str = "smtp.gmail.com" + smtp_port: int = 587 + smtp_user: str = "" + smtp_pass: str = "" @property def admin_ids_list(self) -> list[str]: @@ -33,6 +37,9 @@ def admin_ids_list(self) -> list[str]: "supabase_jwt_secret", "anthropic_api_key", "groq_api_key", + "smtp_server", + "smtp_user", + "smtp_pass", mode="before", ) @classmethod diff --git a/src/lpi/notifications.py b/src/lpi/notifications.py new file mode 100644 index 0000000..48ee19e --- /dev/null +++ b/src/lpi/notifications.py @@ -0,0 +1,120 @@ +import logging +import os +import smtplib +from email.message import EmailMessage + +from lpi.store import _get_client + +logger = logging.getLogger(__name__) + +# Define what your email subjects and bodies should look like +_NOTIF_TEMPLATES = { + "pr_merged": ("PR Merged πŸŽ‰", "You merged a PR in {repo}."), + "commit_pushed": ("New Commits πŸ“¦", "{commit_count} commits pushed to {branch}."), + "phase_advanced": ("SMILE Phase Advanced ✨", "Goal '{title}' moved to {phase}."), + "inactivity_alert": ("Inactivity Detected ⚠️", "No activity detected for {days} days."), + "PullRequestEvent": ("New GitHub PR πŸš€", "A PR was created/updated in {repo}."), + "PushEvent": ("New GitHub Push πŸ“€", "New commits pushed to {repo}."), +} + +def _dispatch_email(user_id: str, title: str, body: str) -> None: + """ + Fetches the user's email from Supabase and sends a real email via SMTP. + """ + client = _get_client() + + # 1. Fetch the user's email from the Supabase 'users' table + # (If your database uses a 'profiles' table instead, update the table name below) + try: + user_data = client.table("users").select("email").eq("id", user_id).execute() + if not user_data.data: + logger.error(f"Email dispatch failed: No email found for user {user_id}") + return + + target_email = user_data.data[0]["email"] + except Exception as e: + logger.error(f"Failed to fetch user email from Supabase: {e}") + return + + # 2. Configure SMTP Credentials + SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.gmail.com") + SMTP_PORT = int(os.getenv("SMTP_PORT", 587)) + SMTP_USER = os.getenv("SMTP_USER") + SMTP_PASS = os.getenv("SMTP_PASS") + + if not SMTP_USER or not SMTP_PASS: + logger.warning("Email blocked: SMTP credentials are not set in the environment variables.") + return + + # 3. Build the Email + msg = EmailMessage() + msg.set_content(body) + msg["Subject"] = title + msg["From"] = SMTP_USER + msg["To"] = target_email + + # 4. Send the Email + try: + with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server: + server.starttls() # Secure the connection + server.login(SMTP_USER, SMTP_PASS) + server.send_message(msg) + + logger.info(f"πŸ“§ SUCCESS: Email sent to {target_email} for user {user_id}") + + except Exception as e: + logger.exception(f"Failed to send email to {target_email}: {e}") + + +def create_notification_if_new( + user_id: str, + signal_id: str, + event_type: str, + payload: dict, +) -> bool: + """ + Attempts to log a notification. Returns True if successful (new), + or False if it was a duplicate. + """ + template = _NOTIF_TEMPLATES.get(event_type) + if not template: + return False + + title_tmpl, body_tmpl = template + + # Safely format the body string with data from the payload + try: + body = body_tmpl.format(**{ + k: payload.get(k, k) + for k in ["repo", "commit_count", "branch", "title", "phase", "days"] + }) + except Exception: + body = body_tmpl + + try: + # Use the existing Supabase client setup + client = _get_client() + client.table("notifications").insert({ + "user_id": user_id, + "signal_id": signal_id, + "type": event_type, + "title": title_tmpl, + "body": body, + }).execute() + + # --- TRIGGER EMAIL HERE --- + # Because we got past the DB insert, we know definitively that this is NOT a duplicate. + _dispatch_email(user_id=user_id, title=title_tmpl, body=body) + + return True + + except Exception as e: + error_str = str(e).lower() + + # If Postgres blocks it due to the UNIQUE constraint, catch it quietly + if "duplicate" in error_str or "unique" in error_str or "23505" in error_str: + logger.debug(f"Duplicate signal {signal_id} detected. Email blocked.") + return False + + logger.exception(f"Failed to create notification for signal {signal_id}") + return False \ No newline at end of file diff --git a/src/lpi/routers/signals.py b/src/lpi/routers/signals.py index 786d76f..83509aa 100644 --- a/src/lpi/routers/signals.py +++ b/src/lpi/routers/signals.py @@ -67,6 +67,7 @@ from lpi.middleware.auth import UserContext, get_current_user, get_current_user_context from lpi.models import Signal, SignalCreate from lpi.utils.logging import log_user_activity +from lpi.notifications import create_notification_if_new router = APIRouter() @@ -368,8 +369,13 @@ async def sync_github_events( # Build the full Signal object (mirroring the logic in ingest_signal) now = datetime.now(UTC) + + # --- FIX 1: Deterministic UUID for Deduplication --- + github_event_id = str(event.get("id")) + consistent_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, github_event_id)) + new_signal = Signal( - id=str(uuid.uuid4()), + id=consistent_id, # <- The database will now recognize duplicates! user_id=user_id, timestamp=now, **signal_create.model_dump() @@ -378,6 +384,14 @@ async def sync_github_events( # 3. Ingest into the Database store.insert_signal(new_signal) + # --- FIX 2: Trigger the notification service --- + create_notification_if_new( + user_id=user_id, + signal_id=new_signal.id, + event_type=event_type, + payload=new_signal.payload or {}, + ) + # Log the activity log_user_activity( user_id=user_id, @@ -398,4 +412,4 @@ async def sync_github_events( "ingested_high_value": ingested_count, "repo": repo_name, "goal_id": goal_id - } + } \ No newline at end of file diff --git a/supabase/migrations/20260627000000_create_notifications.sql b/supabase/migrations/20260627000000_create_notifications.sql new file mode 100644 index 0000000..e89de42 --- /dev/null +++ b/supabase/migrations/20260627000000_create_notifications.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id TEXT NOT NULL, + signal_id TEXT UNIQUE, + type TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + read BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_notif_user_id ON notifications (user_id); +CREATE INDEX IF NOT EXISTS idx_notif_signal_id ON notifications (signal_id); + +ALTER TABLE notifications ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Users read own notifications" + ON notifications FOR SELECT USING (auth.uid()::text = user_id); \ No newline at end of file From 2353b6c4b9ee58f15d0cd04001159c1b957aaa31 Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Mon, 29 Jun 2026 21:23:56 +0530 Subject: [PATCH 02/16] test --- src/lpi/notifications.py | 73 +++++++++++++--------------------------- 1 file changed, 23 insertions(+), 50 deletions(-) diff --git a/src/lpi/notifications.py b/src/lpi/notifications.py index 48ee19e..45a79cc 100644 --- a/src/lpi/notifications.py +++ b/src/lpi/notifications.py @@ -2,12 +2,14 @@ import os import smtplib from email.message import EmailMessage - +from dotenv import load_dotenv from lpi.store import _get_client +# Load environment variables +load_dotenv() logger = logging.getLogger(__name__) -# Define what your email subjects and bodies should look like +# Define notification templates _NOTIF_TEMPLATES = { "pr_merged": ("PR Merged πŸŽ‰", "You merged a PR in {repo}."), "commit_pushed": ("New Commits πŸ“¦", "{commit_count} commits pushed to {branch}."), @@ -18,30 +20,21 @@ } def _dispatch_email(user_id: str, title: str, body: str) -> None: - """ - Fetches the user's email from Supabase and sends a real email via SMTP. - """ - client = _get_client() + print(f"DEBUG: Starting _dispatch_email for {user_id}") + + # --- HARDCODE THE EMAIL FOR TESTING --- + target_email = "your-actual-email@gmail.com" # REPLACE THIS + print(f"DEBUG: Using hardcoded email: {target_email}") - # 1. Fetch the user's email from the Supabase 'users' table - # (If your database uses a 'profiles' table instead, update the table name below) - try: - user_data = client.table("users").select("email").eq("id", user_id).execute() - if not user_data.data: - logger.error(f"Email dispatch failed: No email found for user {user_id}") - return - - target_email = user_data.data[0]["email"] - except Exception as e: - logger.error(f"Failed to fetch user email from Supabase: {e}") - return - # 2. Configure SMTP Credentials SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.gmail.com") SMTP_PORT = int(os.getenv("SMTP_PORT", 587)) - SMTP_USER = os.getenv("SMTP_USER") - SMTP_PASS = os.getenv("SMTP_PASS") + SMTP_USER = os.getenv("SMTP_USER") + SMTP_PASS = os.getenv("SMTP_PASS") + print(f"DEBUG: SMTP_USER is {SMTP_USER}") + print(f"DEBUG: SMTP_PASS is {bool(SMTP_PASS)} (True if loaded)") + if not SMTP_USER or not SMTP_PASS: logger.warning("Email blocked: SMTP credentials are not set in the environment variables.") return @@ -56,33 +49,22 @@ def _dispatch_email(user_id: str, title: str, body: str) -> None: # 4. Send the Email try: with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server: - server.starttls() # Secure the connection + server.set_debuglevel(1) # Prints SMTP conversation to terminal + server.starttls() server.login(SMTP_USER, SMTP_PASS) server.send_message(msg) - - logger.info(f"πŸ“§ SUCCESS: Email sent to {target_email} for user {user_id}") - + logger.info(f"πŸ“§ SUCCESS: Email sent to {target_email}") except Exception as e: + print(f"DEBUG: CRITICAL SMTP ERROR: {e}") logger.exception(f"Failed to send email to {target_email}: {e}") - -def create_notification_if_new( - user_id: str, - signal_id: str, - event_type: str, - payload: dict, -) -> bool: - """ - Attempts to log a notification. Returns True if successful (new), - or False if it was a duplicate. - """ +def create_notification_if_new(user_id: str, signal_id: str, event_type: str, payload: dict) -> bool: template = _NOTIF_TEMPLATES.get(event_type) if not template: return False title_tmpl, body_tmpl = template - # Safely format the body string with data from the payload try: body = body_tmpl.format(**{ k: payload.get(k, k) @@ -92,7 +74,6 @@ def create_notification_if_new( body = body_tmpl try: - # Use the existing Supabase client setup client = _get_client() client.table("notifications").insert({ "user_id": user_id, @@ -102,19 +83,11 @@ def create_notification_if_new( "body": body, }).execute() - # --- TRIGGER EMAIL HERE --- - # Because we got past the DB insert, we know definitively that this is NOT a duplicate. _dispatch_email(user_id=user_id, title=title_tmpl, body=body) - return True - except Exception as e: - error_str = str(e).lower() - - # If Postgres blocks it due to the UNIQUE constraint, catch it quietly - if "duplicate" in error_str or "unique" in error_str or "23505" in error_str: - logger.debug(f"Duplicate signal {signal_id} detected. Email blocked.") - return False - logger.exception(f"Failed to create notification for signal {signal_id}") - return False \ No newline at end of file + return False + +if __name__ == "__main__": + _dispatch_email("a6cabc79-6ef8-46eb-9092-ed104f9fd349", "Test Subject", "Test Body") \ No newline at end of file From 0117afb30f84d0189c867077ed5153bd6382d0c3 Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 16:09:48 +0530 Subject: [PATCH 03/16] push --- src/lpi/notifications.py | 64 +++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/src/lpi/notifications.py b/src/lpi/notifications.py index 45a79cc..e21e128 100644 --- a/src/lpi/notifications.py +++ b/src/lpi/notifications.py @@ -1,78 +1,71 @@ import logging import os import smtplib +from collections import defaultdict from email.message import EmailMessage from dotenv import load_dotenv -from lpi.store import _get_client + +from lpi.store import _get_client, get_user_email # Load environment variables load_dotenv() logger = logging.getLogger(__name__) -# Define notification templates +# Updated templates to include rich data (commit messages, titles) _NOTIF_TEMPLATES = { - "pr_merged": ("PR Merged πŸŽ‰", "You merged a PR in {repo}."), - "commit_pushed": ("New Commits πŸ“¦", "{commit_count} commits pushed to {branch}."), + "pr_merged": ("PR Merged πŸŽ‰", "You merged PR #{pr_number}: '{title}' in {repo}."), + "commit_pushed": ("New Commits πŸ“¦", "{commit_count} commit(s) pushed to {branch} in {repo}.\nLatest: {last_commit_message}"), "phase_advanced": ("SMILE Phase Advanced ✨", "Goal '{title}' moved to {phase}."), "inactivity_alert": ("Inactivity Detected ⚠️", "No activity detected for {days} days."), "PullRequestEvent": ("New GitHub PR πŸš€", "A PR was created/updated in {repo}."), "PushEvent": ("New GitHub Push πŸ“€", "New commits pushed to {repo}."), } -def _dispatch_email(user_id: str, title: str, body: str) -> None: - print(f"DEBUG: Starting _dispatch_email for {user_id}") - - # --- HARDCODE THE EMAIL FOR TESTING --- - target_email = "your-actual-email@gmail.com" # REPLACE THIS - print(f"DEBUG: Using hardcoded email: {target_email}") - - # 2. Configure SMTP Credentials +def _dispatch_email(target_email: str, title: str, body: str) -> None: + """Sends the actual email via SMTP.""" SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.gmail.com") SMTP_PORT = int(os.getenv("SMTP_PORT", 587)) SMTP_USER = os.getenv("SMTP_USER") SMTP_PASS = os.getenv("SMTP_PASS") - print(f"DEBUG: SMTP_USER is {SMTP_USER}") - print(f"DEBUG: SMTP_PASS is {bool(SMTP_PASS)} (True if loaded)") - if not SMTP_USER or not SMTP_PASS: logger.warning("Email blocked: SMTP credentials are not set in the environment variables.") return - # 3. Build the Email msg = EmailMessage() msg.set_content(body) msg["Subject"] = title msg["From"] = SMTP_USER msg["To"] = target_email - # 4. Send the Email try: with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server: - server.set_debuglevel(1) # Prints SMTP conversation to terminal + # server.set_debuglevel(1) # Uncomment to debug SMTP connection server.starttls() server.login(SMTP_USER, SMTP_PASS) server.send_message(msg) logger.info(f"πŸ“§ SUCCESS: Email sent to {target_email}") except Exception as e: - print(f"DEBUG: CRITICAL SMTP ERROR: {e}") - logger.exception(f"Failed to send email to {target_email}: {e}") + logger.exception(f"CRITICAL SMTP ERROR for {target_email}: {e}") def create_notification_if_new(user_id: str, signal_id: str, event_type: str, payload: dict) -> bool: + """Records the notification in Supabase and fires off an email.""" template = _NOTIF_TEMPLATES.get(event_type) if not template: return False title_tmpl, body_tmpl = template + # 1. Safely format the body. + # Using defaultdict ensures missing keys in the payload just render as empty strings or defaults, preventing crashes. + safe_payload = defaultdict(lambda: "[N/A]", payload) try: - body = body_tmpl.format(**{ - k: payload.get(k, k) - for k in ["repo", "commit_count", "branch", "title", "phase", "days"] - }) - except Exception: - body = body_tmpl + body = body_tmpl.format_map(safe_payload) + except Exception as e: + logger.warning(f"Error formatting payload: {e}") + body = body_tmpl # Fallback to raw template if formatting totally fails + # 2. Insert into Database (Dedup logic) try: client = _get_client() client.table("notifications").insert({ @@ -82,12 +75,23 @@ def create_notification_if_new(user_id: str, signal_id: str, event_type: str, pa "title": title_tmpl, "body": body, }).execute() - - _dispatch_email(user_id=user_id, title=title_tmpl, body=body) - return True except Exception as e: + if "duplicate" in str(e).lower() or "unique" in str(e).lower(): + # DB constraint caught it - exactly what we want for dedup + return False logger.exception(f"Failed to create notification for signal {signal_id}") return False + # 3. Fetch User Email and Dispatch + target_email = get_user_email(user_id) + + if target_email: + _dispatch_email(target_email=target_email, title=title_tmpl, body=body) + else: + logger.warning(f"No email found in 'users' table for user_id: {user_id}. DB log created, but email skipped.") + + return True + if __name__ == "__main__": - _dispatch_email("a6cabc79-6ef8-46eb-9092-ed104f9fd349", "Test Subject", "Test Body") \ No newline at end of file + # Test block + _dispatch_email("test@example.com", "System Test", "Checking SMTP configuration.") \ No newline at end of file From 39e5711b071d98d0ab6db54976c357ce5fa17034 Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 16:20:22 +0530 Subject: [PATCH 04/16] push --- src/lpi/routers/webhooks.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/lpi/routers/webhooks.py b/src/lpi/routers/webhooks.py index a264ece..69b2986 100644 --- a/src/lpi/routers/webhooks.py +++ b/src/lpi/routers/webhooks.py @@ -7,6 +7,7 @@ from lpi import store from lpi.models import Signal from lpi.routers.github_auth import repo_db +from lpi.notifications import create_notification_if_new router = APIRouter() @@ -89,4 +90,11 @@ async def github_webhook_receiver(request: Request): store.insert_signal(signal) print(f"βœ… AUTOMATIC DETECTION: Saved {signal_data['event_type']} for user {user_id} and goal {target_goal_id}!") + create_notification_if_new( + user_id=user_id, + signal_id=signal.id, + event_type=signal.event_type, + payload=signal.payload or {}, + ) + return {"status": "success"} From d477930fb1a5f741b0e2a34b110a65265b4a46a4 Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 17:20:15 +0530 Subject: [PATCH 05/16] push --- src/lpi/notifications.py | 54 ++++++++++++++-------------------------- 1 file changed, 18 insertions(+), 36 deletions(-) diff --git a/src/lpi/notifications.py b/src/lpi/notifications.py index e21e128..a6a22f3 100644 --- a/src/lpi/notifications.py +++ b/src/lpi/notifications.py @@ -4,32 +4,28 @@ from collections import defaultdict from email.message import EmailMessage from dotenv import load_dotenv - from lpi.store import _get_client, get_user_email -# Load environment variables load_dotenv() logger = logging.getLogger(__name__) -# Updated templates to include rich data (commit messages, titles) _NOTIF_TEMPLATES = { - "pr_merged": ("PR Merged πŸŽ‰", "You merged PR #{pr_number}: '{title}' in {repo}."), - "commit_pushed": ("New Commits πŸ“¦", "{commit_count} commit(s) pushed to {branch} in {repo}.\nLatest: {last_commit_message}"), + "pr_merged": ("PR Merged πŸŽ‰", "You merged PR #{pr_number}: '{title}' in {repo}.\n\nπŸ’‘ Insight: {explanation}"), + "commit_pushed": ("New Commits πŸ“¦", "{commit_count} commit(s) pushed to {branch} in {repo}.\nLatest: {last_commit_message}\n\nπŸ’‘ Insight: {explanation}"), "phase_advanced": ("SMILE Phase Advanced ✨", "Goal '{title}' moved to {phase}."), "inactivity_alert": ("Inactivity Detected ⚠️", "No activity detected for {days} days."), - "PullRequestEvent": ("New GitHub PR πŸš€", "A PR was created/updated in {repo}."), - "PushEvent": ("New GitHub Push πŸ“€", "New commits pushed to {repo}."), + "PullRequestEvent": ("New GitHub PR πŸš€", "A PR was created/updated in {repo}.\n\nπŸ’‘ Insight: {explanation}"), + "PushEvent": ("New GitHub Push πŸ“€", "New commits pushed to {repo}.\n\nπŸ’‘ Insight: {explanation}"), } def _dispatch_email(target_email: str, title: str, body: str) -> None: - """Sends the actual email via SMTP.""" SMTP_SERVER = os.getenv("SMTP_SERVER", "smtp.gmail.com") SMTP_PORT = int(os.getenv("SMTP_PORT", 587)) SMTP_USER = os.getenv("SMTP_USER") SMTP_PASS = os.getenv("SMTP_PASS") if not SMTP_USER or not SMTP_PASS: - logger.warning("Email blocked: SMTP credentials are not set in the environment variables.") + logger.warning("Email blocked: SMTP credentials missing.") return msg = EmailMessage() @@ -40,32 +36,29 @@ def _dispatch_email(target_email: str, title: str, body: str) -> None: try: with smtplib.SMTP(SMTP_SERVER, SMTP_PORT) as server: - # server.set_debuglevel(1) # Uncomment to debug SMTP connection server.starttls() server.login(SMTP_USER, SMTP_PASS) server.send_message(msg) logger.info(f"πŸ“§ SUCCESS: Email sent to {target_email}") except Exception as e: - logger.exception(f"CRITICAL SMTP ERROR for {target_email}: {e}") + logger.exception(f"SMTP ERROR for {target_email}: {e}") def create_notification_if_new(user_id: str, signal_id: str, event_type: str, payload: dict) -> bool: - """Records the notification in Supabase and fires off an email.""" template = _NOTIF_TEMPLATES.get(event_type) - if not template: - return False + if not template: return False title_tmpl, body_tmpl = template - - # 1. Safely format the body. - # Using defaultdict ensures missing keys in the payload just render as empty strings or defaults, preventing crashes. safe_payload = defaultdict(lambda: "[N/A]", payload) + + # Fallback explanation + if "explanation" not in safe_payload: + safe_payload["explanation"] = "Every contribution helps move your project forward." + try: body = body_tmpl.format_map(safe_payload) - except Exception as e: - logger.warning(f"Error formatting payload: {e}") - body = body_tmpl # Fallback to raw template if formatting totally fails + except Exception: + body = body_tmpl - # 2. Insert into Database (Dedup logic) try: client = _get_client() client.table("notifications").insert({ @@ -76,22 +69,11 @@ def create_notification_if_new(user_id: str, signal_id: str, event_type: str, pa "body": body, }).execute() except Exception as e: - if "duplicate" in str(e).lower() or "unique" in str(e).lower(): - # DB constraint caught it - exactly what we want for dedup - return False - logger.exception(f"Failed to create notification for signal {signal_id}") + if "unique" in str(e).lower(): return False + logger.exception(f"Failed to record notification: {signal_id}") return False - # 3. Fetch User Email and Dispatch target_email = get_user_email(user_id) - if target_email: - _dispatch_email(target_email=target_email, title=title_tmpl, body=body) - else: - logger.warning(f"No email found in 'users' table for user_id: {user_id}. DB log created, but email skipped.") - - return True - -if __name__ == "__main__": - # Test block - _dispatch_email("test@example.com", "System Test", "Checking SMTP configuration.") \ No newline at end of file + _dispatch_email(target_email, title_tmpl, body) + return True \ No newline at end of file From e68ec58d98abbbe6af17f36cc6d2e3269a598c2f Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 17:36:43 +0530 Subject: [PATCH 06/16] push --- src/lpi/notifications.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lpi/notifications.py b/src/lpi/notifications.py index a6a22f3..15a6edb 100644 --- a/src/lpi/notifications.py +++ b/src/lpi/notifications.py @@ -13,9 +13,9 @@ "pr_merged": ("PR Merged πŸŽ‰", "You merged PR #{pr_number}: '{title}' in {repo}.\n\nπŸ’‘ Insight: {explanation}"), "commit_pushed": ("New Commits πŸ“¦", "{commit_count} commit(s) pushed to {branch} in {repo}.\nLatest: {last_commit_message}\n\nπŸ’‘ Insight: {explanation}"), "phase_advanced": ("SMILE Phase Advanced ✨", "Goal '{title}' moved to {phase}."), - "inactivity_alert": ("Inactivity Detected ⚠️", "No activity detected for {days} days."), + "inactivity_alert": ("Inactivity Detected ⚠️", "No activity detected in {repo} for {days} days."), "PullRequestEvent": ("New GitHub PR πŸš€", "A PR was created/updated in {repo}.\n\nπŸ’‘ Insight: {explanation}"), - "PushEvent": ("New GitHub Push πŸ“€", "New commits pushed to {repo}.\n\nπŸ’‘ Insight: {explanation}"), + "PushEvent": ("New GitHub Push πŸ“€", "{commit_count} commit(s) pushed to {branch} in {repo}.\nLatest: {last_commit_message}\n\nπŸ’‘ Insight: {explanation}"), } def _dispatch_email(target_email: str, title: str, body: str) -> None: From e2a9fae0be74e9971f180927c46c8218ab83be3b Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 17:49:01 +0530 Subject: [PATCH 07/16] push --- src/lpi/routers/me.py | 41 +++++++++- src/lpi/routers/signals.py | 79 ++++++++++++++++--- src/lpi/store.py | 22 ++++++ .../20260630000002_create_users.sql | 21 +++++ .../20260630000003_user_sync_trigger.sql | 20 +++++ 5 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 supabase/migrations/20260630000002_create_users.sql create mode 100644 supabase/migrations/20260630000003_user_sync_trigger.sql diff --git a/src/lpi/routers/me.py b/src/lpi/routers/me.py index 2eebf73..3181aa8 100644 --- a/src/lpi/routers/me.py +++ b/src/lpi/routers/me.py @@ -1,11 +1,50 @@ -from fastapi import APIRouter, Depends +from datetime import date +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel + +from lpi import store from lpi.middleware.auth import UserContext, get_current_user_context router = APIRouter() +# 1. Define the expected payload from the frontend +class ProfileUpdate(BaseModel): + name: Optional[str] = None + gender: Optional[str] = None + dob: Optional[date] = None + bio: Optional[str] = None +# 2. Existing GET route @router.get("/", response_model=UserContext) def get_me(user_context: UserContext = Depends(get_current_user_context)) -> UserContext: """Return the authenticated user's context (including admin status).""" return user_context + +# 3. New PATCH route for profile updates +@router.patch("/profile", summary="Update user profile") +def update_profile( + profile_data: ProfileUpdate, + user_context: UserContext = Depends(get_current_user_context), +): + """Updates the authenticated user's profile details.""" + # exclude_unset ensures we only update fields the user actually submitted + updates = profile_data.model_dump(exclude_unset=True) + + if "dob" in updates and updates["dob"]: + updates["dob"] = updates["dob"].isoformat() + + if not updates: + return {"status": "no changes provided"} + + # Use the user_id from the verified JWT context + updated_user = store.update_user_profile(user_id=user_context.user_id, updates=updates) + + if not updated_user: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update profile in database." + ) + + return {"status": "success", "profile": updated_user} \ No newline at end of file diff --git a/src/lpi/routers/signals.py b/src/lpi/routers/signals.py index 83509aa..02f0c5c 100644 --- a/src/lpi/routers/signals.py +++ b/src/lpi/routers/signals.py @@ -66,11 +66,18 @@ from lpi import store from lpi.middleware.auth import UserContext, get_current_user, get_current_user_context from lpi.models import Signal, SignalCreate -from lpi.utils.logging import log_user_activity +from lpi.utils.logging import log_user_activity, logger from lpi.notifications import create_notification_if_new router = APIRouter() +def _generate_explanation(event_type: str, payload: dict) -> str: + """Generates a rule-based explanation for signals.""" + if event_type == "commit_pushed": + return "You're actively pushing code, which is the core of the reality-emulation phase. Keep iterating!" + if event_type == "pr_merged": + return "Merging a PR is a significant milestone that moves your goal forward toward concurrent-engineering." + return "Your project is showing activityβ€”every small update contributes to your long-term goals." # ── Wave 2: POST /api/v1/signals/ ──────────────────────────────────────────── @@ -140,6 +147,16 @@ def ingest_signal( # utils/logging.py). Wrapping it here again would be redundant and, # worse, gives a false impression that THIS is where a logging failure # gets caught β€” it isn't; this call simply cannot raise. + create_notification_if_new( + user_id=user_id, + signal_id=new_signal.id, + event_type=new_signal.event_type, + payload={ + ** (new_signal.payload or {}), + "explanation": _generate_explanation(new_signal.event_type, new_signal.payload or {}) + }, + ) + log_user_activity( user_id=user_id, action="signal_ingested", @@ -384,14 +401,58 @@ async def sync_github_events( # 3. Ingest into the Database store.insert_signal(new_signal) - # --- FIX 2: Trigger the notification service --- - create_notification_if_new( - user_id=user_id, - signal_id=new_signal.id, - event_type=event_type, - payload=new_signal.payload or {}, - ) - + # --- FIX 2: Trigger the notification service --- + notif_payload = {"repo": repo_name} + notif_type = event_type # Default fallback + + if event_type == "PushEvent": + notif_type = "commit_pushed" + + # 1. Branch: Check multiple potential locations for the ref + ref = event.get("ref") or event.get("payload", {}).get("ref", "") + branch_name = ref.replace("refs/heads/", "") if ref else "main" + + # 2. Commits: Handle the case where count is 0 or list is missing + gh_payload = event.get("payload", {}) + commits = gh_payload.get("commits", []) + commit_count = event.get("commit_count") or len(commits) or 0 + + # 3. Message: Check if it exists in multiple possible fields + latest_msg = ( + commits[-1].get("message") if commits + else event.get("last_commit_message") or "No message provided" + ) + + notif_payload = { + "repo": repo_name, + "branch": branch_name, + "commit_count": commit_count, + "last_commit_message": latest_msg, + "explanation": "You're actively pushing code, which is the core of the reality-emulation phase. Keep iterating!" + } + elif event_type == "PullRequestEvent": + # Use 'pr_merged' to match the rich template key + notif_type = "pr_merged" + gh_payload = event.get("payload", {}) + pr_data = gh_payload.get("pull_request", {}) + + notif_payload = { + "repo": repo_name, + "pr_number": pr_data.get("number", "Unknown"), + "title": pr_data.get("title", "Pull Request Updated"), + "explanation": _generate_explanation("pr_merged", {}) + } + + try: + create_notification_if_new( + user_id=user_id, + signal_id=new_signal.id, + event_type=notif_type, # Now correctly mapping to 'commit_pushed' or 'pr_merged' + payload=notif_payload, + ) + except Exception as e: + logger.error(f"Notification background task failed: {e}") + # Log the activity log_user_activity( user_id=user_id, diff --git a/src/lpi/store.py b/src/lpi/store.py index 1f46de1..f509c95 100644 --- a/src/lpi/store.py +++ b/src/lpi/store.py @@ -327,6 +327,28 @@ def get_signal(signal_id: str) -> Signal | None: return None return Signal(**cast(dict, result.data[0])) +def get_user_email(user_id: str) -> str | None: + """Fetch a user's email address for notifications.""" + try: + result = _get_client().table("users").select("email").eq("id", user_id).execute() + if result.data and len(result.data) > 0: + return result.data[0].get("email") + return None + except Exception as e: + print(f"Error fetching email for user {user_id}: {e}") + return None + +def update_user_profile(user_id: str, updates: dict) -> dict | None: + """Updates a user's profile information in the public.users table.""" + try: + result = _get_client().table("users").update(updates).eq("id", user_id).execute() + if result.data: + return result.data[0] + return None + except Exception as e: + print(f"Error updating profile for user {user_id}: {e}") + return None + # ── Audit log verification (new β€” used by tests, also useful for admin tooling) ─ diff --git a/supabase/migrations/20260630000002_create_users.sql b/supabase/migrations/20260630000002_create_users.sql new file mode 100644 index 0000000..501d805 --- /dev/null +++ b/supabase/migrations/20260630000002_create_users.sql @@ -0,0 +1,21 @@ +-- supabase/migrations/20260630000002_create_users.sql +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, -- Maps to auth.users + name TEXT, + email TEXT UNIQUE NOT NULL, + dob DATE, + gender TEXT, + bio TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); + +-- Basic RLS so users can only read/update their own profile +ALTER TABLE users ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view own profile" + ON users FOR SELECT USING (auth.uid()::text = id::text); + +CREATE POLICY "Users can update own profile" + ON users FOR UPDATE USING (auth.uid()::text = id::text); \ No newline at end of file diff --git a/supabase/migrations/20260630000003_user_sync_trigger.sql b/supabase/migrations/20260630000003_user_sync_trigger.sql new file mode 100644 index 0000000..ef1d1e7 --- /dev/null +++ b/supabase/migrations/20260630000003_user_sync_trigger.sql @@ -0,0 +1,20 @@ +-- 1. Create the function that copies the data +CREATE OR REPLACE FUNCTION public.handle_new_user() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO public.users (id, email, name) + VALUES ( + new.id, + new.email, + -- If your frontend sends a name in the signup metadata, this grabs it. + -- Otherwise, it defaults to NULL. + new.raw_user_meta_data->>'full_name' + ); + RETURN new; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- 2. Create the trigger that fires the function every time a user signs up +CREATE OR REPLACE TRIGGER on_auth_user_created + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user(); \ No newline at end of file From f399b80983f17bcb31a28d4fbe946c73c0f80aeb Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 17:57:56 +0530 Subject: [PATCH 08/16] push --- src/lpi/routers/signals.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/lpi/routers/signals.py b/src/lpi/routers/signals.py index 02f0c5c..d5df71e 100644 --- a/src/lpi/routers/signals.py +++ b/src/lpi/routers/signals.py @@ -408,19 +408,19 @@ async def sync_github_events( if event_type == "PushEvent": notif_type = "commit_pushed" - # 1. Branch: Check multiple potential locations for the ref - ref = event.get("ref") or event.get("payload", {}).get("ref", "") - branch_name = ref.replace("refs/heads/", "") if ref else "main" + # 1. Branch: Check 'ref' directly first (for your flat payload), + # then check inside 'payload' as a fallback. + ref = event.get("ref") or event.get("payload", {}).get("ref", "refs/heads/main") + branch_name = ref.replace("refs/heads/", "") - # 2. Commits: Handle the case where count is 0 or list is missing - gh_payload = event.get("payload", {}) - commits = gh_payload.get("commits", []) - commit_count = event.get("commit_count") or len(commits) or 0 + # 2. Commits: Check 'commit_count' at the top level first + commit_count = event.get("commit_count") or len(event.get("payload", {}).get("commits", [])) or 0 - # 3. Message: Check if it exists in multiple possible fields + # 3. Message: Check 'last_commit_message' at the top level first latest_msg = ( - commits[-1].get("message") if commits - else event.get("last_commit_message") or "No message provided" + event.get("last_commit_message") + or (event.get("payload", {}).get("commits", [{}])[-1].get("message")) + or "No message provided" ) notif_payload = { From 3ba5faaf1c2f739d39652a7fafabbe9d4bdd0b7a Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 18:02:49 +0530 Subject: [PATCH 09/16] push --- src/lpi/routers/signals.py | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/lpi/routers/signals.py b/src/lpi/routers/signals.py index d5df71e..db6f0a8 100644 --- a/src/lpi/routers/signals.py +++ b/src/lpi/routers/signals.py @@ -408,21 +408,19 @@ async def sync_github_events( if event_type == "PushEvent": notif_type = "commit_pushed" - # 1. Branch: Check 'ref' directly first (for your flat payload), - # then check inside 'payload' as a fallback. - ref = event.get("ref") or event.get("payload", {}).get("ref", "refs/heads/main") - branch_name = ref.replace("refs/heads/", "") + # Extracting from the 'flat' structure shown in your logs + payload_data = event.get("payload", {}) - # 2. Commits: Check 'commit_count' at the top level first - commit_count = event.get("commit_count") or len(event.get("payload", {}).get("commits", [])) or 0 + # 1. Branch: Look in 'ref', fallback to payload ref + branch_name = event.get("ref") or payload_data.get("ref", "main") + branch_name = bsranch_name.replace("refs/heads/", "") - # 3. Message: Check 'last_commit_message' at the top level first - latest_msg = ( - event.get("last_commit_message") - or (event.get("payload", {}).get("commits", [{}])[-1].get("message")) - or "No message provided" - ) + # 2. Commit Count: Use the field that exists + commit_count = payload_data.get("commit_count", 0) + # 3. Message: Check if it's there, else provide a placeholder + latest_msg = payload_data.get("last_commit_message", "New code pushed") + notif_payload = { "repo": repo_name, "branch": branch_name, From f0042a26f1444bc00c96aa1a71d0f92254a081e2 Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 18:18:12 +0530 Subject: [PATCH 10/16] push --- src/lpi/routers/signals.py | 2 +- src/lpi/routers/webhooks.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/lpi/routers/signals.py b/src/lpi/routers/signals.py index db6f0a8..5f1dbcd 100644 --- a/src/lpi/routers/signals.py +++ b/src/lpi/routers/signals.py @@ -413,7 +413,7 @@ async def sync_github_events( # 1. Branch: Look in 'ref', fallback to payload ref branch_name = event.get("ref") or payload_data.get("ref", "main") - branch_name = bsranch_name.replace("refs/heads/", "") + branch_name = branch_name.replace("refs/heads/", "") # 2. Commit Count: Use the field that exists commit_count = payload_data.get("commit_count", 0) diff --git a/src/lpi/routers/webhooks.py b/src/lpi/routers/webhooks.py index 69b2986..a684d37 100644 --- a/src/lpi/routers/webhooks.py +++ b/src/lpi/routers/webhooks.py @@ -47,14 +47,19 @@ async def github_webhook_receiver(request: Request): } # 3. Catch Pushed Commits - elif event_type == "push" and payload.get("commits"): + elif event_type == "push": + # Webhook 'push' event has 'commits' and 'ref' at the top level + commits = payload.get("commits", []) + ref = payload.get("ref", "") + signal_data = { "event_type": "commit_pushed", "payload": { - "repo": payload["repository"]["name"], - "branch": payload.get("ref", "").replace("refs/heads/", ""), - "commit_count": len(payload["commits"]), - "last_commit_message": payload["commits"][-1]["message"], + "repo": payload.get("repository", {}).get("name"), + "branch": ref.replace("refs/heads/", ""), + "commit_count": len(commits), + "last_commit_message": commits[-1].get("message") if commits else "New code pushed", + "explanation": "You're actively pushing code. Keep iterating!" }, } From 1ee317c328f1d306cb42fc0c34dfe9b1b7006474 Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 18:42:48 +0530 Subject: [PATCH 11/16] push --- src/lpi/notifications.py | 3 +-- src/lpi/routers/signals.py | 26 ++++++++++++++------------ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/lpi/notifications.py b/src/lpi/notifications.py index 15a6edb..d04d440 100644 --- a/src/lpi/notifications.py +++ b/src/lpi/notifications.py @@ -14,8 +14,7 @@ "commit_pushed": ("New Commits πŸ“¦", "{commit_count} commit(s) pushed to {branch} in {repo}.\nLatest: {last_commit_message}\n\nπŸ’‘ Insight: {explanation}"), "phase_advanced": ("SMILE Phase Advanced ✨", "Goal '{title}' moved to {phase}."), "inactivity_alert": ("Inactivity Detected ⚠️", "No activity detected in {repo} for {days} days."), - "PullRequestEvent": ("New GitHub PR πŸš€", "A PR was created/updated in {repo}.\n\nπŸ’‘ Insight: {explanation}"), - "PushEvent": ("New GitHub Push πŸ“€", "{commit_count} commit(s) pushed to {branch} in {repo}.\nLatest: {last_commit_message}\n\nπŸ’‘ Insight: {explanation}"), + "pr_opened": ("PR Opened πŸš€", "{actor} opened PR #{pr_number}: '{title}' in {repo}.\n\nπŸ’‘ Insight: {explanation}") } def _dispatch_email(target_email: str, title: str, body: str) -> None: diff --git a/src/lpi/routers/signals.py b/src/lpi/routers/signals.py index 5f1dbcd..0936b0a 100644 --- a/src/lpi/routers/signals.py +++ b/src/lpi/routers/signals.py @@ -408,26 +408,28 @@ async def sync_github_events( if event_type == "PushEvent": notif_type = "commit_pushed" - # Extracting from the 'flat' structure shown in your logs - payload_data = event.get("payload", {}) + # FIX: Access the flat structure directly as shown in your screenshot + # Use .get() to avoid KeyErrors + branch_name = event.get("ref", "main").replace("refs/heads/", "") + commit_count = event.get("commit_count", 0) - # 1. Branch: Look in 'ref', fallback to payload ref - branch_name = event.get("ref") or payload_data.get("ref", "main") - branch_name = branch_name.replace("refs/heads/", "") - - # 2. Commit Count: Use the field that exists - commit_count = payload_data.get("commit_count", 0) - - # 3. Message: Check if it's there, else provide a placeholder - latest_msg = payload_data.get("last_commit_message", "New code pushed") + # Since the payload doesn't have a message here, provide a clear fallback + latest_msg = "New code pushed by " + event.get("actor", "a contributor") notif_payload = { "repo": repo_name, "branch": branch_name, "commit_count": commit_count, "last_commit_message": latest_msg, - "explanation": "You're actively pushing code, which is the core of the reality-emulation phase. Keep iterating!" + "explanation": "You're actively pushing code. Keep iterating!" } + + create_notification_if_new( + user_id=user_id, + signal_id=new_signal.id, + event_type=notif_type, + payload=notif_payload, + ) elif event_type == "PullRequestEvent": # Use 'pr_merged' to match the rich template key notif_type = "pr_merged" From e9aabff136e0656a90cd7b03605ece6107e9d861 Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 19:01:19 +0530 Subject: [PATCH 12/16] push --- src/lpi/notifications.py | 1 + src/lpi/routers/signals.py | 25 +++++++++++-------------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/lpi/notifications.py b/src/lpi/notifications.py index d04d440..3187cec 100644 --- a/src/lpi/notifications.py +++ b/src/lpi/notifications.py @@ -10,6 +10,7 @@ logger = logging.getLogger(__name__) _NOTIF_TEMPLATES = { + "PushEvent": ("New GitHub Push πŸ“€", "Activity detected in {repo}.\n\nπŸ’‘ Insight: {explanation}"), "pr_merged": ("PR Merged πŸŽ‰", "You merged PR #{pr_number}: '{title}' in {repo}.\n\nπŸ’‘ Insight: {explanation}"), "commit_pushed": ("New Commits πŸ“¦", "{commit_count} commit(s) pushed to {branch} in {repo}.\nLatest: {last_commit_message}\n\nπŸ’‘ Insight: {explanation}"), "phase_advanced": ("SMILE Phase Advanced ✨", "Goal '{title}' moved to {phase}."), diff --git a/src/lpi/routers/signals.py b/src/lpi/routers/signals.py index 0936b0a..db53ccd 100644 --- a/src/lpi/routers/signals.py +++ b/src/lpi/routers/signals.py @@ -147,16 +147,22 @@ def ingest_signal( # utils/logging.py). Wrapping it here again would be redundant and, # worse, gives a false impression that THIS is where a logging failure # gets caught β€” it isn't; this call simply cannot raise. + # MAP THE TYPE FOR THE NOTIFICATION TEMPLATE + mapped_type = new_signal.event_type + if new_signal.event_type == "PushEvent": + mapped_type = "commit_pushed" + elif new_signal.event_type == "PullRequestEvent": + mapped_type = "pr_merged" + create_notification_if_new( user_id=user_id, signal_id=new_signal.id, - event_type=new_signal.event_type, + event_type=mapped_type, # Use the mapped semantic key payload={ ** (new_signal.payload or {}), "explanation": _generate_explanation(new_signal.event_type, new_signal.payload or {}) }, ) - log_user_activity( user_id=user_id, action="signal_ingested", @@ -408,12 +414,9 @@ async def sync_github_events( if event_type == "PushEvent": notif_type = "commit_pushed" - # FIX: Access the flat structure directly as shown in your screenshot - # Use .get() to avoid KeyErrors + # Access the flat structure branch_name = event.get("ref", "main").replace("refs/heads/", "") commit_count = event.get("commit_count", 0) - - # Since the payload doesn't have a message here, provide a clear fallback latest_msg = "New code pushed by " + event.get("actor", "a contributor") notif_payload = { @@ -424,14 +427,7 @@ async def sync_github_events( "explanation": "You're actively pushing code. Keep iterating!" } - create_notification_if_new( - user_id=user_id, - signal_id=new_signal.id, - event_type=notif_type, - payload=notif_payload, - ) elif event_type == "PullRequestEvent": - # Use 'pr_merged' to match the rich template key notif_type = "pr_merged" gh_payload = event.get("payload", {}) pr_data = gh_payload.get("pull_request", {}) @@ -443,11 +439,12 @@ async def sync_github_events( "explanation": _generate_explanation("pr_merged", {}) } + # TRIGGER NOTIFICATION ONCE HERE try: create_notification_if_new( user_id=user_id, signal_id=new_signal.id, - event_type=notif_type, # Now correctly mapping to 'commit_pushed' or 'pr_merged' + event_type=notif_type, payload=notif_payload, ) except Exception as e: From f5d564158baca73b5e629c686e1059db651bec74 Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 19:04:05 +0530 Subject: [PATCH 13/16] push --- src/lpi/routers/signals.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/lpi/routers/signals.py b/src/lpi/routers/signals.py index db53ccd..8b5c62c 100644 --- a/src/lpi/routers/signals.py +++ b/src/lpi/routers/signals.py @@ -408,22 +408,18 @@ async def sync_github_events( store.insert_signal(new_signal) # --- FIX 2: Trigger the notification service --- - notif_payload = {"repo": repo_name} - notif_type = event_type # Default fallback - if event_type == "PushEvent": notif_type = "commit_pushed" - # Access the flat structure - branch_name = event.get("ref", "main").replace("refs/heads/", "") - commit_count = event.get("commit_count", 0) - latest_msg = "New code pushed by " + event.get("actor", "a contributor") - + # We extract specifically from the flat event object + raw_branch = event.get("ref", "main") + clean_branch = raw_branch.replace("refs/heads/", "") + notif_payload = { "repo": repo_name, - "branch": branch_name, - "commit_count": commit_count, - "last_commit_message": latest_msg, + "branch": clean_branch, + "commit_count": str(event.get("commit_count", "0")), + "last_commit_message": event.get("actor", "a contributor") + " pushed new changes", "explanation": "You're actively pushing code. Keep iterating!" } From 72c8b0690d597cd732f70bb57569d5c524bbeac7 Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 19:11:49 +0530 Subject: [PATCH 14/16] push --- src/lpi/notifications.py | 2 +- src/lpi/routers/signals.py | 15 +++------------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/src/lpi/notifications.py b/src/lpi/notifications.py index 3187cec..8a1cb95 100644 --- a/src/lpi/notifications.py +++ b/src/lpi/notifications.py @@ -12,7 +12,7 @@ _NOTIF_TEMPLATES = { "PushEvent": ("New GitHub Push πŸ“€", "Activity detected in {repo}.\n\nπŸ’‘ Insight: {explanation}"), "pr_merged": ("PR Merged πŸŽ‰", "You merged PR #{pr_number}: '{title}' in {repo}.\n\nπŸ’‘ Insight: {explanation}"), - "commit_pushed": ("New Commits πŸ“¦", "{commit_count} commit(s) pushed to {branch} in {repo}.\nLatest: {last_commit_message}\n\nπŸ’‘ Insight: {explanation}"), + "commit_pushed": ("New Commits πŸ“¦", "A new push was detected in {repo}.\n\nπŸ’‘ Insight: {explanation}"), "phase_advanced": ("SMILE Phase Advanced ✨", "Goal '{title}' moved to {phase}."), "inactivity_alert": ("Inactivity Detected ⚠️", "No activity detected in {repo} for {days} days."), "pr_opened": ("PR Opened πŸš€", "{actor} opened PR #{pr_number}: '{title}' in {repo}.\n\nπŸ’‘ Insight: {explanation}") diff --git a/src/lpi/routers/signals.py b/src/lpi/routers/signals.py index 8b5c62c..4a7a595 100644 --- a/src/lpi/routers/signals.py +++ b/src/lpi/routers/signals.py @@ -74,9 +74,9 @@ def _generate_explanation(event_type: str, payload: dict) -> str: """Generates a rule-based explanation for signals.""" if event_type == "commit_pushed": - return "You're actively pushing code, which is the core of the reality-emulation phase. Keep iterating!" + return "You're actively pushing code. Keep iterating!" if event_type == "pr_merged": - return "Merging a PR is a significant milestone that moves your goal forward toward concurrent-engineering." + return "Merging a PR is a significant milestone that moves your goal forward toward next phase." return "Your project is showing activityβ€”every small update contributes to your long-term goals." # ── Wave 2: POST /api/v1/signals/ ──────────────────────────────────────────── @@ -410,19 +410,10 @@ async def sync_github_events( # --- FIX 2: Trigger the notification service --- if event_type == "PushEvent": notif_type = "commit_pushed" - - # We extract specifically from the flat event object - raw_branch = event.get("ref", "main") - clean_branch = raw_branch.replace("refs/heads/", "") - notif_payload = { "repo": repo_name, - "branch": clean_branch, - "commit_count": str(event.get("commit_count", "0")), - "last_commit_message": event.get("actor", "a contributor") + " pushed new changes", - "explanation": "You're actively pushing code. Keep iterating!" + "explanation": "Your project is showing activityβ€”every small update contributes to your long-term goals." } - elif event_type == "PullRequestEvent": notif_type = "pr_merged" gh_payload = event.get("payload", {}) From ba20eb352862ef29356fa26221cf925b2bafdf40 Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 19:36:43 +0530 Subject: [PATCH 15/16] final notifications implementation --- .env.example | 8 +++++++- README.md | 9 +++++++++ src/lpi/notifications.py | 8 ++++++-- src/lpi/routers/me.py | 9 ++++----- src/lpi/routers/signals.py | 2 +- src/lpi/routers/webhooks.py | 2 +- 6 files changed, 28 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index d284fad..fe3e82d 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,10 @@ LLM_PROVIDER=anthropic LLM_MODEL=claude-sonnet-4-20250514 ANTHROPIC_API_KEY=sk-ant-... DAILY_COST_CAP_USD=10.0 -ADMIN_USER_IDS=your-admin-user-id \ No newline at end of file +ADMIN_USER_IDS=your-admin-user-id +# --- Notification Service Settings --- +# SMTP configuration for LPI Platform notifications +SMTP_SERVER=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASS=your-app-specific-password \ No newline at end of file diff --git a/README.md b/README.md index 47618c8..4de139c 100644 --- a/README.md +++ b/README.md @@ -417,6 +417,10 @@ cp .env.example .env | `SUPABASE_SERVICE_ROLE_KEY` | βœ… | Same as above; used by logging utilities | | `SUPABASE_JWT_SECRET` | βœ… | JWT signing secret β€” from `supabase status` or dashboard β†’ Project Settings β†’ API | | `GROQ_API_KEY` | βœ… | Groq API key for the LLM layer (free tier) β€” [console.groq.com](https://console.groq.com) | +|`SMTP_SERVER` | βœ… | SMTP server for notifications (e.g., smtp.gmail.com) | +|`SMTP_PORT` | βœ… | Port for SMTP (e.g., 587) | +|`SMTP_USER` | βœ… | Email address for notification dispatch | +|`SMTP_PASS` | βœ… | App-specific password for email account | | `ANTHROPIC_API_KEY` | Optional | Claude API key β€” alternate LLM provider | | `LLM_PROVIDER` | Optional | `groq` (default) or `anthropic` | | `LLM_MODEL` | Optional | Default: `llama-3.3-70b-versatile` (Groq) | @@ -814,6 +818,9 @@ All migrations live in `supabase/migrations/`. Run `supabase db push` to apply t | `20260615000000_signals_rls_and_log_action.sql` | RLS on `activity_signals` + CHECK fix | Adil | | `20260621000000_create_recommendation_feedback.sql` | `recommendation_feedback` | Aryan | | `20260625000000_activity_signals_goal_fk.sql` | `goal_id` FK on `activity_signals` + strict goal-scoped RLS | Jaivardhan | +| `20260627000000_create_notifications.sql` | notifications | Aditi | +| `20260630000002_create_users.sql` | users profile table | Aditi | +| `20260630000003_user_sync_trigger.sql` | Auth-to-Profile Sync Trigger | Aditi | ### Table overview @@ -825,6 +832,8 @@ All migrations live in `supabase/migrations/`. Run `supabase db push` to apply t | `system_logs` | Platform-level events (`info`, `warning`, `error`) | | `activity_signals` | All ingested activity events from all streams | | `recommendation_feedback` | User accept/dismiss decisions on recommendation cards | +| `notifications` | Audit trail of sent notifications (prevents duplicates) | +| `users` | User authentication data + profile metadata (name, email, dob, gender, bio) | ### Useful SQL queries diff --git a/src/lpi/notifications.py b/src/lpi/notifications.py index 8a1cb95..527d0e9 100644 --- a/src/lpi/notifications.py +++ b/src/lpi/notifications.py @@ -3,7 +3,9 @@ import smtplib from collections import defaultdict from email.message import EmailMessage + from dotenv import load_dotenv + from lpi.store import _get_client, get_user_email load_dotenv() @@ -45,7 +47,8 @@ def _dispatch_email(target_email: str, title: str, body: str) -> None: def create_notification_if_new(user_id: str, signal_id: str, event_type: str, payload: dict) -> bool: template = _NOTIF_TEMPLATES.get(event_type) - if not template: return False + if not template: + return False title_tmpl, body_tmpl = template safe_payload = defaultdict(lambda: "[N/A]", payload) @@ -69,7 +72,8 @@ def create_notification_if_new(user_id: str, signal_id: str, event_type: str, pa "body": body, }).execute() except Exception as e: - if "unique" in str(e).lower(): return False + if "unique" in str(e).lower(): + return False logger.exception(f"Failed to record notification: {signal_id}") return False diff --git a/src/lpi/routers/me.py b/src/lpi/routers/me.py index 3181aa8..e2f7370 100644 --- a/src/lpi/routers/me.py +++ b/src/lpi/routers/me.py @@ -1,5 +1,4 @@ from datetime import date -from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel @@ -11,10 +10,10 @@ # 1. Define the expected payload from the frontend class ProfileUpdate(BaseModel): - name: Optional[str] = None - gender: Optional[str] = None - dob: Optional[date] = None - bio: Optional[str] = None + name: str | None = None + gender: str | None = None + dob: date | None = None + bio: str | None = None # 2. Existing GET route @router.get("/", response_model=UserContext) diff --git a/src/lpi/routers/signals.py b/src/lpi/routers/signals.py index 4a7a595..0723998 100644 --- a/src/lpi/routers/signals.py +++ b/src/lpi/routers/signals.py @@ -66,8 +66,8 @@ from lpi import store from lpi.middleware.auth import UserContext, get_current_user, get_current_user_context from lpi.models import Signal, SignalCreate -from lpi.utils.logging import log_user_activity, logger from lpi.notifications import create_notification_if_new +from lpi.utils.logging import log_user_activity, logger router = APIRouter() diff --git a/src/lpi/routers/webhooks.py b/src/lpi/routers/webhooks.py index a684d37..b1a9a26 100644 --- a/src/lpi/routers/webhooks.py +++ b/src/lpi/routers/webhooks.py @@ -6,8 +6,8 @@ from lpi import store from lpi.models import Signal -from lpi.routers.github_auth import repo_db from lpi.notifications import create_notification_if_new +from lpi.routers.github_auth import repo_db router = APIRouter() From 4234dbd4e43accd7924a0b11628eb6a857cdce60 Mon Sep 17 00:00:00 2001 From: Aditi Mehta Date: Tue, 30 Jun 2026 19:53:16 +0530 Subject: [PATCH 16/16] final notifications implementation --- src/lpi/store.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/lpi/store.py b/src/lpi/store.py index f509c95..7052366 100644 --- a/src/lpi/store.py +++ b/src/lpi/store.py @@ -331,8 +331,13 @@ def get_user_email(user_id: str) -> str | None: """Fetch a user's email address for notifications.""" try: result = _get_client().table("users").select("email").eq("id", user_id).execute() - if result.data and len(result.data) > 0: - return result.data[0].get("email") + # Cast result.data to a list to check length safely + if result.data and len(cast(list, result.data)) > 0: + # Cast the first row to a dict before calling .get() + row = cast(dict, result.data[0]) + email = row.get("email") + # Ensure the return type strictly matches str | None + return str(email) if email else None return None except Exception as e: print(f"Error fetching email for user {user_id}: {e}") @@ -342,14 +347,14 @@ def update_user_profile(user_id: str, updates: dict) -> dict | None: """Updates a user's profile information in the public.users table.""" try: result = _get_client().table("users").update(updates).eq("id", user_id).execute() - if result.data: - return result.data[0] + if result.data and len(cast(list, result.data)) > 0: + # Explicitly return a dict to satisfy the function signature + return cast(dict, result.data[0]) return None except Exception as e: print(f"Error updating profile for user {user_id}: {e}") return None - # ── Audit log verification (new β€” used by tests, also useful for admin tooling) ─