From 078686047c0690368f143cbcf45afa4c9a566a65 Mon Sep 17 00:00:00 2001 From: Wei Zang Date: Wed, 29 Apr 2026 20:27:44 +0100 Subject: [PATCH 1/4] Update __init__.py --- app/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/__init__.py b/app/__init__.py index 4da493e..bfc8517 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,4 +1,4 @@ """Python° - FastAPI, Postgres, tsvector""" # Current Version -__version__ = "3.0.8" +__version__ = "3.0.9" From 8526e8fc789dece27e20d93e044c9a5263e40ed4 Mon Sep 17 00:00:00 2001 From: Wei Zang Date: Wed, 29 Apr 2026 21:33:07 +0100 Subject: [PATCH 2/4] Add queue README (non-technical overview) Add app/api/queue/README.md providing a plain-language overview of the queue: what it is, why it's needed, how tasks are added and processed, benefits (fairness, efficiency, reliability), and a real-life example. Intended for non-technical readers and contributors to explain queue behavior without code details. --- app/api/queue/README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 app/api/queue/README.md diff --git a/app/api/queue/README.md b/app/api/queue/README.md new file mode 100644 index 0000000..99e0da9 --- /dev/null +++ b/app/api/queue/README.md @@ -0,0 +1,29 @@ +# Queue Overview + +## What is the Queue? + +The queue is like a waiting line for tasks that need to be done. Imagine you are at a store, and people are waiting in line to be helped. The queue in our system works the same way, but instead of people, it manages jobs or tasks that the computer needs to process. + +## Why Do We Need a Queue? + +Sometimes, there are too many tasks for the computer to handle all at once. The queue helps by organizing these tasks in the order they arrive, so each one gets done, one after the other. This makes sure nothing is forgotten and everything is handled fairly. + +## How Does the Queue Work? + +- **Adding Tasks:** When there is something new to do, it gets added to the end of the queue. +- **Processing Tasks:** The computer takes the first task in the queue and works on it. When it’s done, it moves to the next one. +- **Staying Organized:** The queue keeps everything in order, so tasks are not missed or done out of turn. + +## Why Is This Important? + +- **Fairness:** Every task gets its turn, just like people in a line. +- **Efficiency:** The computer doesn’t get overwhelmed by trying to do everything at once. +- **Reliability:** Tasks are not lost or forgotten, so the system works smoothly. + +## Real-Life Example + +Think of the queue as a to-do list. When you write down things you need to do, you do them one by one. The queue helps the computer do the same thing, making sure every job is completed in the right order. + +--- + +This overview is for anyone who wants to understand what the queue does, without needing to know how to code or how computers work inside. If you have questions, just ask! \ No newline at end of file From a91895b1dcf902eb45efe9beb47f2df2c8e9336b Mon Sep 17 00:00:00 2001 From: Milky Date: Sun, 10 May 2026 14:57:33 +0100 Subject: [PATCH 3/4] Update __init__.py --- app/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/__init__.py b/app/__init__.py index bfc8517..eef1d20 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,4 +1,5 @@ """Python° - FastAPI, Postgres, tsvector""" # Current Version -__version__ = "3.0.9" +__version__ = "3.1.0" +p From 6c6f94e09f220dc4be28bfa5b944eb747a87a62e Mon Sep 17 00:00:00 2001 From: Milky Date: Sun, 10 May 2026 17:22:08 +0100 Subject: [PATCH 4/4] Add /notify/email endpoint and update routes Introduce a new email notification API under app/api/notify/email.py providing GET metadata and POST to send emails via the Resend SDK (with RESEND_API_KEY checks and goldlabel_email template). Update app/api/routes.py to include the new notify router and remove the old resend import. Update README references from /resend to /notify/email. Rename and update the test to target /notify/email, and remove the large Postman collection file. Also fix a tiny stray character/whitespace in app/__init__.py. --- README.md | 4 +- app/__init__.py | 2 +- app/api/notify/email.py | 105 ++++++ app/api/routes.py | 4 +- tests/Postman.json | 812 ---------------------------------------- tests/test_resend.py | 6 +- 6 files changed, 113 insertions(+), 820 deletions(-) create mode 100644 app/api/notify/email.py delete mode 100644 tests/Postman.json diff --git a/README.md b/README.md index 24d1695..e2786e2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ This project provides a scalable API backend using FastAPI and PostgreSQL, featuring: - Automatic full-text search on all text fields (via tsvector) -- Endpoints for health checks, product management, prompt handling (via `/prompt`), resend email, and prospect management +- Endpoints for health checks, product management, prompt handling (via `/prompt`), notify email, and prospect management - Efficient ingestion and processing of large CSV files #### Features @@ -54,7 +54,7 @@ FastAPI auto-generates interactive docs: - `GET /health` — Health check - `GET /prompt` or `GET /prompts` — Prompt table metadata (`record_count`, `columns`) - `POST /prompt` — LLM prompt completion (formerly `/llm`) -- `GET/POST /resend` — Send email via Resend API (see implementation in `app/utils/notify/resend.py`) +- `GET/POST /notify/email` — Send email via Resend API (see implementation in `app/api/notify/email.py`) - `GET /prospects` — Paginated prospects - `POST /prospects/process` — Bulk CSV ingestion diff --git a/app/__init__.py b/app/__init__.py index eef1d20..e5bccf4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -2,4 +2,4 @@ # Current Version __version__ = "3.1.0" -p + diff --git a/app/api/notify/email.py b/app/api/notify/email.py new file mode 100644 index 0000000..fddd1c7 --- /dev/null +++ b/app/api/notify/email.py @@ -0,0 +1,105 @@ +"""Email notification routes.""" + +import os +import resend +from fastapi import APIRouter, status +from pydantic import BaseModel, EmailStr + +from app.utils.make_meta import make_meta +from app.utils.email_templates import goldlabel_email + +resend.api_key = os.environ.get("RESEND_API_KEY") +RESEND_API_KEY = resend.api_key + +router = APIRouter(prefix="/notify") + +class EmailRequest(BaseModel): + to: EmailStr + subject: str + html: str + cta_label: str | None = None + cta_url: str | None = None + + +def send_email_resend(to: str, subject: str, html: str) -> dict: + if not resend.api_key: + return {"error": "Missing RESEND_API_KEY"} + params: resend.Emails.SendParams = { + "from": "NX° ", + "to": [to], + "subject": subject, + "html": html, + } + try: + email: resend.Emails.SendResponse = resend.Emails.send(params) + return dict(email) + except Exception as e: + return {"error": str(e)} + + +@router.get("/email") +def root() -> dict: + """GET /notify/email endpoint.""" + if not RESEND_API_KEY: + meta = make_meta("error", "RESEND_API_KEY is missing from environment. Please set it in your .env file.") + return {"meta": meta} + meta = make_meta("success", "GET /notify/email endpoint") + return { + "meta": meta, + "data": { + "hint": "Use POST /notify/email to send an email via Resend API.", + "type": { + "to": { + "type": "string", + "format": "email", + "required": True, + "description": "Recipient email address." + }, + "subject": { + "type": "string", + "required": True, + "description": "Subject of the email." + }, + "html": { + "type": "string", + "required": True, + "description": "HTML content of the email." + }, + "cta_label": { + "type": "string", + "required": False, + "description": "Optional CTA button label. Defaults to 'Call To Action'." + }, + "cta_url": { + "type": "string", + "required": False, + "description": "Optional CTA URL. Defaults to the website base URL." + } + } + } + } + + +@router.post("/email", status_code=status.HTTP_202_ACCEPTED) +def send_email(request: EmailRequest): + """POST /notify/email endpoint to send email via Resend API.""" + if not RESEND_API_KEY: + meta = make_meta("error", "RESEND_API_KEY missing. Please set it in your .env file.") + return {"meta": meta} + + result = send_email_resend( + to=request.to, + subject=request.subject, + html=goldlabel_email( + request.subject, + request.html, + cta_label=request.cta_label or "Call To Action", + cta_url=request.cta_url or "https://goldlabel.pro", + ), + ) + if "error" in result: + meta = make_meta("error", result["error"]) + return {"meta": meta} + + meta = make_meta("success", "Email sent successfully.") + return {"meta": meta, "data": result} diff --git a/app/api/routes.py b/app/api/routes.py index 8b6a5fc..7192f77 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.api.notify.resend import router as resend_router +from app.api.notify.email import router as notify_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 @@ -20,7 +20,7 @@ from app.api.youtube import youtube_router router.include_router(root_router) -router.include_router(resend_router) +router.include_router(notify_router) router.include_router(health_router) router.include_router(prompt_router) router.include_router(prompts_empty_router) diff --git a/tests/Postman.json b/tests/Postman.json deleted file mode 100644 index bf86c72..0000000 --- a/tests/Postman.json +++ /dev/null @@ -1,812 +0,0 @@ -{ - "info": { - "_postman_id": "6354fc17-227d-4c13-aa12-2aa85d09e209", - "name": "Python°", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "47659302", - "_collection_link": "https://go.postman.co/collection/47659302-6354fc17-227d-4c13-aa12-2aa85d09e209?source=collection_link" - }, - "item": [ - { - "name": "nx-ai.onrender.com", - "item": [ - { - "name": "base_url", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "https://nx-ai.onrender.com", - "protocol": "https", - "host": [ - "nx-ai", - "onrender", - "com" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "8K°", - "item": [ - { - "name": "prompts", - "item": [ - { - "name": "8K/prompt", - "protocolProfileBehavior": { - "disableBodyPruning": true - }, - "request": { - "auth": { - "type": "noauth" - }, - "method": "GET", - "header": [], - "body": { - "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "http://localhost:8000/prompt", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "prompt" - ] - } - }, - "response": [] - }, - { - "name": "8K/prompt", - "request": { - "auth": { - "type": "noauth" - }, - "method": "POST", - "header": [], - "body": { - "mode": "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" - } - } - }, - "url": { - "raw": "http://localhost:8000/prompt", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "prompt" - ] - } - }, - "response": [] - }, - { - "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": "PATCH", - "header": [], - "body": { - "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "http://localhost:8000/prompt/empty", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "prompt", - "empty" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "prospects", - "item": [ - { - "name": "prospects", - "request": { - "auth": { - "type": "noauth" - }, - "method": "GET", - "header": [], - "url": { - "raw": "http://localhost:8000/prospects", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "prospects" - ] - } - }, - "response": [] - }, - { - "name": "prospects/read", - "request": { - "auth": { - "type": "noauth" - }, - "method": "GET", - "header": [], - "url": { - "raw": "http://localhost:8000/prospects", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "prospects" - ] - } - }, - "response": [] - }, - { - "name": "prospects/530", - "request": { - "auth": { - "type": "noauth" - }, - "method": "GET", - "header": [], - "url": { - "raw": "http://localhost:8000/prospects/530", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "prospects", - "530" - ] - } - }, - "response": [] - }, - { - "name": "?search=chris", - "request": { - "auth": { - "type": "noauth" - }, - "method": "GET", - "header": [], - "url": { - "raw": "http://localhost:8000/prospects?search=chris", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "prospects" - ], - "query": [ - { - "key": "search", - "value": "chris" - } - ] - } - }, - "response": [] - }, - { - "name": "prospects/factoryreset", - "request": { - "auth": { - "type": "noauth" - }, - "method": "PATCH", - "header": [], - "body": { - "mode": "raw", - "raw": "{}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "http://localhost:8000/prospects/factoryreset", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "prospects", - "factoryreset" - ] - } - }, - "response": [] - }, - { - "name": "prospects/50", - "request": { - "auth": { - "type": "noauth" - }, - "method": "PATCH", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"flag\": false\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "http://localhost:8000/prospects/50", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "prospects", - "50" - ] - } - }, - "response": [] - }, - { - "name": "prospects/empty", - "request": { - "auth": { - "type": "noauth" - }, - "method": "DELETE", - "header": [], - "url": { - "raw": "http://localhost:8000/prospects/empty", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "prospects", - "empty" - ] - } - }, - "response": [] - }, - { - "name": "prospects/seed", - "request": { - "auth": { - "type": "noauth" - }, - "method": "DELETE", - "header": [], - "url": { - "raw": "http://localhost:8000/prospects/empty", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "prospects", - "empty" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "resend", - "item": [ - { - "name": "resend", - "request": { - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"to\": \"listingslab@gmail.com\",\n \"subject\": \"Your Subject Here\",\n \"html\": \"

Your HTML content here

\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "http://localhost:8000/resend", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "resend" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "queue", - "item": [ - { - "name": "alter_table", - "item": [ - { - "name": "/queue/alter/add-column", - "request": { - "auth": { - "type": "noauth" - }, - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\"column_name\": \"seniority\", \"column_type\": \"TEXT\"}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "http://localhost:8000/queue/alter/add-column", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "queue", - "alter", - "add-column" - ] - } - }, - "response": [] - }, - { - "name": "/queue/alter/rename_column", - "request": { - "auth": { - "type": "noauth" - }, - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "{\n \"old_name\": \"url\",\n \"new_name\": \"linkedin\",\n \"column_type\": \"TEXT\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "http://localhost:8000/queue/alter/rename_column", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "queue", - "alter", - "rename_column" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "data", - "item": [ - { - "name": "queue/delete?id=YOUR_ID", - "request": { - "auth": { - "type": "noauth" - }, - "method": "DELETE", - "header": [], - "body": { - "mode": "raw", - "raw": "" - }, - "url": { - "raw": "http://localhost:8000/queue/delete?id=YOUR_ID", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "queue", - "delete" - ], - "query": [ - { - "key": "id", - "value": "YOUR_ID" - } - ] - } - }, - "response": [] - }, - { - "name": "/queue/empty", - "request": { - "auth": { - "type": "noauth" - }, - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "http://localhost:8000/queue/empty", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "queue", - "empty" - ] - } - }, - "response": [] - }, - { - "name": "queue", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "http://localhost:8000/queue", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "queue" - ] - } - }, - "response": [] - } - ] - }, - { - "name": "csv", - "item": [ - { - "name": "/queue/csv/linkedin", - "request": { - "auth": { - "type": "noauth" - }, - "method": "POST", - "header": [], - "body": { - "mode": "raw", - "raw": "", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "http://localhost:8000/queue/csv/linkedin", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "queue", - "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": [] - } - ] - }, - { - "name": "/queue", - "request": { - "auth": { - "type": "noauth" - }, - "method": "GET", - "header": [], - "url": { - "raw": "http://localhost:8000/queue", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "queue" - ] - } - }, - "response": [] - } - ] - }, - { - "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": { - "method": "GET", - "header": [], - "url": { - "raw": "http://localhost:8000/", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "" - ] - } - }, - "response": [] - }, - { - "name": "8000/health", - "request": { - "auth": { - "type": "noauth" - }, - "method": "GET", - "header": [], - "url": { - "raw": "http://localhost:8000/health", - "protocol": "http", - "host": [ - "localhost" - ], - "port": "8000", - "path": [ - "health" - ] - } - }, - "response": [] - } - ], - "description": "This folder contains requests targeting a local API running on **port 8000**. It covers the following areas:\n\n**Base & Health**\n\n- `GET /` — Verifies the base URL is reachable and the server is responding.\n \n- `GET /health` — Health check endpoint to confirm the API is running and operational.\n \n\n**Prospect Management**\n\n- `GET /prospects` — Retrieves the current list of prospects.\n \n- `GET /prospects/alter` — Fetches or triggers an alteration on prospect data.\n \n- `DELETE /prospects/empty` — Removes all prospects, emptying the prospects store.\n \n- `DELETE /prospects/empty` _(seed)_ — Used to reset and seed the prospects data, preparing the store with initial data for testing." - } - ] -} \ No newline at end of file diff --git a/tests/test_resend.py b/tests/test_resend.py index 4891ff4..10a7438 100644 --- a/tests/test_resend.py +++ b/tests/test_resend.py @@ -5,8 +5,8 @@ client = TestClient(app) -def test_resend_post_email(monkeypatch): - """Test POST /resend actually sends an email if RESEND_API_KEY is set.""" +def test_notify_email_post(monkeypatch): + """Test POST /notify/email actually sends an email if RESEND_API_KEY is set.""" resend_api_key = os.getenv("RESEND_API_KEY") if not resend_api_key: pytest.skip("RESEND_API_KEY not set; skipping real email test.") @@ -19,7 +19,7 @@ def test_resend_post_email(monkeypatch): "cta_url": "https://nx-admin.goldlabel.pro", } - response = client.post("/resend", json=payload) + response = client.post("/notify/email", json=payload) assert response.status_code == 202 data = response.json() assert "meta" in data