diff --git a/app/__init__.py b/app/__init__.py index 08e1d23..bb3a17a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,4 +1,4 @@ """Python° - FastAPI, Postgres, tsvector""" # Current Version -__version__ = "3.0.4" +__version__ = "3.0.5" diff --git a/app/utils/notify/__init__.py b/app/api/notify/__init__.py similarity index 100% rename from app/utils/notify/__init__.py rename to app/api/notify/__init__.py diff --git a/app/utils/notify/resend.py b/app/api/notify/resend.py similarity index 92% rename from app/utils/notify/resend.py rename to app/api/notify/resend.py index 4007c35..80df88a 100644 --- a/app/utils/notify/resend.py +++ b/app/api/notify/resend.py @@ -4,7 +4,8 @@ from app.utils.make_meta import make_meta from fastapi import APIRouter, Query, Path, Body, HTTPException from app.utils.db import get_db_connection -from app.utils.notify.send_email import send_email_resend +from app.api.notify.send_email import send_email_resend +from app.utils.email_templates import goldlabel_email router = APIRouter() base_url = os.getenv("BASE_URL", "http://localhost:8000") @@ -62,7 +63,7 @@ def send_email(request: EmailRequest): result = send_email_resend( to=request.to, subject=request.subject, - html=request.html + html=goldlabel_email(request.subject, request.html), ) if "error" in result: meta = make_meta("error", result["error"]) diff --git a/app/utils/notify/send_email.py b/app/api/notify/send_email.py similarity index 100% rename from app/utils/notify/send_email.py rename to app/api/notify/send_email.py diff --git a/app/api/routes.py b/app/api/routes.py index 8af471d..02d29d3 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -7,7 +7,7 @@ from app.api.root import router as root_router from app.utils.health import router as health_router -from app.utils.notify.resend import router as resend_router +from app.api.notify.resend import router as resend_router from app.api.prompt.prompt import router as prompt_router from app.api.prompt.empty import router as prompts_empty_router from app.api.prompt.delete_id import router as prompt_delete_id_router diff --git a/app/utils/email_templates/__init__.py b/app/utils/email_templates/__init__.py new file mode 100644 index 0000000..d27101f --- /dev/null +++ b/app/utils/email_templates/__init__.py @@ -0,0 +1,3 @@ +"""Email templates""" + +from .goldlabel import goldlabel_email diff --git a/app/utils/email_templates/goldlabel.py b/app/utils/email_templates/goldlabel.py new file mode 100644 index 0000000..0f49e59 --- /dev/null +++ b/app/utils/email_templates/goldlabel.py @@ -0,0 +1,130 @@ +"""Goldlabel branded HTML email template.""" + +_LOGO_URL = "https://goldlabel.pro/goldlabelpro/png/favicon.png" + +# Palette (dark theme used for the email chrome; body text stays readable on white clients) +_DARK_BG = "#364450" +_DARK_PAPER = "#364450" +_DARK_PRIMARY = "#ffd849" +_DARK_TEXT = "#ffffff" +_LIGHT_BG = "#eaf0f5" +_LIGHT_PAPER = "#EEF7FF" +_LIGHT_TEXT = "#000000" +_LIGHT_PRIMARY = "#364450" + + +def goldlabel_email(subject: str, body_html: str) -> str: + """Return a complete HTML email string with Goldlabel branding. + + Args: + subject: Used as the visible heading inside the email. + body_html: Inner HTML content placed in the message body area. + + Returns: + A self-contained HTML string ready to pass to send_email_resend(). + """ + return f""" + + + + + + + {subject} + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +""" diff --git "a/tests/Python\302\260.json" b/tests/Postman.json similarity index 75% rename from "tests/Python\302\260.json" rename to tests/Postman.json index c7ab72a..bf86c72 100644 --- "a/tests/Python\302\260.json" +++ b/tests/Postman.json @@ -36,16 +36,19 @@ "name": "prompts", "item": [ { - "name": "8K/prompt/linkedin", + "name": "8K/prompt", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, "request": { "auth": { "type": "noauth" }, - "method": "POST", + "method": "GET", "header": [], "body": { "mode": "raw", - "raw": "{\n \"linkedin_url\": \"https://www.linkedin.com/in/chris-dorward/\",\n \"prompt\": \"You are a senior sales intelligence analyst with 20 years of experience in sales and marketing.\\n\\nYour task is to analyze a LinkedIn profile based on the linkedin_url. These URLs are my real connections. For each profile, infer actionable commercial insights about who the person is (including their full name), what they do, who they work for, and whether they are a strong prospect for NX° — a Next.js multi-tenant app developed by Goldlabel Apps for e-commerce and SaaS businesses.\\n\\nPay special attention to their current company:\\n- Identify the company they work for\\n- Find out if the company has a website (include the URL if possible)\\n\\n=== PERSON ===\\nLinkedIn: https://www.linkedin.com/in/chris-dorward/\\n\\n=== INSTRUCTIONS ===\\n\\nInstructions:\\n1. Research the LinkedIn profile and provided details. Focus on their current and past experience, role, department, and any e-commerce, SaaS, or software development-related activities.\\n\\n- Infer responsibilities based on title and seniority\\n- Estimate decision-making power (low / medium / high)\\n- Identify likely business priorities\\n- Identify pain points related to e-commerce, SaaS, or multi-tenant platforms\\n- Assess how relevant they are as a prospect for NX°\\n- Be pragmatic and commercially focused\\n\\n- If data is missing, make reasonable assumptions\\n- Do NOT mention missing data\\n- Do NOT be vague\\n\\n=== SCORING ===\\nAssign a percentage score (0-100) indicating the likelihood that this person or their company would be interested in NX°. Base this on their role, company type, experience with e-commerce, SaaS, or multi-tenant apps, and any signals of technical or business alignment with Next.js or similar platforms.\\n\\nBriefly justify the score in the recommendation.\\n\\n=== OUTPUT ===\\nReturn ONLY valid JSON in this format:\\n\\n{\\n \\\"name\\\": string,\\n \\\"summary\\\": string,\\n \\\"jobTitle\\\": string,\\n \\\"company\\\": string,\\n \\\"companyWebsite\\\": string,\\n \\\"avatarUrl\\\"?: string,\\n \\\"email\\\"?: string,\\n \\\"hasJavaScript\\\": boolean,\\n \\\"category\\\": \\\"Developer\\\" | \\\"Recruiter\\\" | \\\"Business\\\" | \\\"Other\\\",\\n \\\"tags\\\": array,\\n \\\"score\\\": number,\\n \\\"recommendation\\\": string\\n}\"\n}", + "raw": "", "options": { "raw": { "language": "json" @@ -53,15 +56,14 @@ } }, "url": { - "raw": "http://localhost:8000/prompt/linkedin", + "raw": "http://localhost:8000/prompt", "protocol": "http", "host": [ "localhost" ], "port": "8000", "path": [ - "prompt", - "linkedin" + "prompt" ] } }, @@ -69,18 +71,15 @@ }, { "name": "8K/prompt", - "protocolProfileBehavior": { - "disableBodyPruning": true - }, "request": { "auth": { "type": "noauth" }, - "method": "GET", + "method": "POST", "header": [], "body": { "mode": "raw", - "raw": "", + "raw": "{\n \"prompt\": \"This is a test prompt from Postman. Tell me why Postman is good for developing APIs\"\n}", "options": { "raw": { "language": "json" @@ -102,15 +101,50 @@ "response": [] }, { - "name": "8K/prompt/dump", - "protocolProfileBehavior": { - "disableBodyPruning": true + "name": "Delete ID", + "request": { + "auth": { + "type": "noauth" + }, + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/prompt/delete_id?id=321", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "prompt", + "delete_id" + ], + "query": [ + { + "key": "id", + "value": "321" + } + ] + } }, + "response": [] + }, + { + "name": "8K/prompt/empty", "request": { "auth": { "type": "noauth" }, - "method": "GET", + "method": "PATCH", "header": [], "body": { "mode": "raw", @@ -122,7 +156,7 @@ } }, "url": { - "raw": "http://localhost:8000/prompt/dump", + "raw": "http://localhost:8000/prompt/empty", "protocol": "http", "host": [ "localhost" @@ -130,7 +164,7 @@ "port": "8000", "path": [ "prompt", - "dump" + "empty" ] } }, @@ -397,7 +431,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\"column_name\": \"group\", \"column_type\": \"TEXT\"}", + "raw": "{\"column_name\": \"seniority\", \"column_type\": \"TEXT\"}", "options": { "raw": { "language": "json" @@ -405,7 +439,7 @@ } }, "url": { - "raw": "http://localhost:8000/queue/empty", + "raw": "http://localhost:8000/queue/alter/add-column", "protocol": "http", "host": [ "localhost" @@ -413,7 +447,8 @@ "port": "8000", "path": [ "queue", - "empty" + "alter", + "add-column" ] } }, @@ -521,11 +556,30 @@ } }, "response": [] + }, + { + "name": "queue", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8000/queue", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "queue" + ] + } + }, + "response": [] } ] }, { - "name": "import", + "name": "csv", "item": [ { "name": "/queue/csv/linkedin", @@ -545,7 +599,7 @@ } }, "url": { - "raw": "http://localhost:8000/queue/import/linkedin", + "raw": "http://localhost:8000/queue/csv/linkedin", "protocol": "http", "host": [ "localhost" @@ -553,12 +607,45 @@ "port": "8000", "path": [ "queue", - "import", + "csv", "linkedin" ] } }, "response": [] + }, + { + "name": "/queue/csv/apollo", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/queue/csv/apollo", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "queue", + "csv", + "apollo" + ] + } + }, + "response": [] } ] }, @@ -586,6 +673,97 @@ } ] }, + { + "name": "GitHub", + "item": [ + { + "name": "Postgres", + "item": [ + { + "name": "Empty Tables", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "http://localhost:8000/api/github/empty", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "api", + "github", + "empty" + ] + } + }, + "response": [] + }, + { + "name": "Create Tables", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "http://localhost:8000/github/createtable", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "github", + "createtable" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Get", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8000/github", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "github" + ] + } + }, + "response": [] + }, + { + "name": "Sync", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "http://localhost:8000/api/github/sync", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "api", + "github", + "sync" + ] + } + }, + "response": [] + } + ] + }, { "name": "base_url", "request": { diff --git a/tests/test_github.py b/tests/test_github.py new file mode 100644 index 0000000..277a4d4 --- /dev/null +++ b/tests/test_github.py @@ -0,0 +1,149 @@ +from unittest.mock import MagicMock, patch +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + +_TABLES = [ + "github_accounts", + "github_repos", + "github_gists", + "github_projects", + "github_resources", +] + + +class _MockCursor: + def __init__(self, fetchone_values, fetchall_values=None): + self._fetchone_values = list(fetchone_values) + self._fetchall_values = list(fetchall_values or []) + self.executed = [] + self.description = [("id",), ("name",)] + + def execute(self, query, params=None): + self.executed.append((query, params)) + + def fetchone(self): + if self._fetchone_values: + return self._fetchone_values.pop(0) + return None + + def fetchall(self): + if self._fetchall_values: + return self._fetchall_values.pop(0) + return [] + + def close(self): + pass + + +class _MockConnection: + def __init__(self, fetchone_values, fetchall_values=None): + self.cursor_obj = _MockCursor(fetchone_values, fetchall_values) + self.committed = False + self.rolled_back = False + + def cursor(self): + return self.cursor_obj + + def commit(self): + self.committed = True + + def rollback(self): + self.rolled_back = True + + def close(self): + pass + + +# --------------------------------------------------------------------------- +# GET /github +# --------------------------------------------------------------------------- + +def test_github_get_returns_all_tables(monkeypatch): + # One COUNT fetchone per table, one fetchall of rows per table. + conn = _MockConnection( + fetchone_values=[(3,), (7,), (0,), (1,), (2,)], + fetchall_values=[ + [(1, "Alex Milky")], # github_accounts rows + [(10, "fastapi-starter")], # github_repos rows + [], # github_gists rows (empty) + [(30, "API Roadmap 2026")], # github_projects rows + [(40, "some resource")], # github_resources rows + ], + ) + + from app.api.github import github as github_module + + monkeypatch.setattr(github_module, "get_db_connection_direct", lambda: conn) + + response = client.get("/github") + assert response.status_code == 200 + + payload = response.json() + assert payload["meta"]["severity"] == "success" + assert set(payload["data"].keys()) == set(_TABLES) + + assert payload["data"]["github_accounts"]["count"] == 3 + assert payload["data"]["github_repos"]["count"] == 7 + assert payload["data"]["github_gists"]["count"] == 0 + assert payload["data"]["github_gists"]["rows"] == [] + assert payload["data"]["github_projects"]["count"] == 1 + assert payload["data"]["github_resources"]["count"] == 2 + + +def test_github_get_returns_error_on_db_failure(monkeypatch): + from app.api.github import github as github_module + + monkeypatch.setattr( + github_module, + "get_db_connection_direct", + lambda: (_ for _ in ()).throw(Exception("connection refused")), + ) + + response = client.get("/github") + assert response.status_code == 200 + + payload = response.json() + assert payload["meta"]["severity"] == "error" + assert payload["data"] == {} + + +# --------------------------------------------------------------------------- +# POST /api/github/empty +# --------------------------------------------------------------------------- + +def test_github_empty_truncates_all_tables(monkeypatch): + conn = _MockConnection(fetchone_values=[]) + + from app.api.github.sql import empty_tables as empty_module + + monkeypatch.setattr(empty_module, "get_db_connection_direct", lambda: conn) + + response = client.post("/api/github/empty") + assert response.status_code == 200 + + payload = response.json() + assert payload["meta"]["severity"] == "success" + assert set(payload["data"]["tables"]) == set(_TABLES) + assert conn.committed is True + + +# --------------------------------------------------------------------------- +# POST /api/github/createtable +# --------------------------------------------------------------------------- + +def test_github_create_tables_succeeds(monkeypatch): + conn = _MockConnection(fetchone_values=[]) + + from app.api.github.sql import create_tables as create_module + + monkeypatch.setattr(create_module, "get_db_connection_direct", lambda: conn) + + response = client.post("/api/github/createtable") + assert response.status_code == 200 + + payload = response.json() + assert payload["meta"]["severity"] == "success" + assert conn.committed is True