Initial commit: Handoff Pro MCP server for Kellow Construction

This commit is contained in:
brian
2026-05-29 14:38:57 -07:00
commit 3a62428751
28 changed files with 1860 additions and 0 deletions

0
lib/__init__.py Normal file
View File

156
lib/db.py Normal file
View File

@@ -0,0 +1,156 @@
"""SQLite database initialization and access for Handoff Pro."""
import json
import os
import sqlite3
import uuid
from datetime import datetime
DB_PATH = os.environ.get("HANDOFF_DB",
os.path.join(os.path.dirname(__file__), "..", "data", "handoff.db"))
SCHEMA = """
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
user_id TEXT DEFAULT 'kellow',
jobtread_job_id TEXT UNIQUE,
client_name TEXT,
jobtread_customer_id TEXT,
address TEXT,
description TEXT,
status TEXT DEFAULT 'proposal',
created_at TEXT,
updated_at TEXT,
synced_to_jobtread_at TEXT
);
CREATE TABLE IF NOT EXISTS estimates (
id TEXT PRIMARY KEY,
job_id TEXT REFERENCES jobs(id),
jobtread_estimate_id TEXT UNIQUE,
labor_hours REAL DEFAULT 0,
labor_rate REAL DEFAULT 0,
materials_cost REAL DEFAULT 0,
markup_percent REAL DEFAULT 20,
total_cost REAL DEFAULT 0,
notes TEXT,
status TEXT DEFAULT 'draft',
created_at TEXT,
synced_to_jobtread_at TEXT
);
CREATE TABLE IF NOT EXISTS line_items (
id TEXT PRIMARY KEY,
estimate_id TEXT REFERENCES estimates(id),
description TEXT,
category TEXT,
quantity REAL DEFAULT 1,
unit TEXT DEFAULT 'ea',
unit_cost REAL DEFAULT 0,
total REAL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS pricing_catalogs (
id TEXT PRIMARY KEY,
user_id TEXT DEFAULT 'kellow',
name TEXT,
labor_rates TEXT,
material_markups TEXT,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS invoices (
id TEXT PRIMARY KEY,
estimate_id TEXT REFERENCES estimates(id),
job_id TEXT REFERENCES jobs(id),
invoice_number TEXT,
jobtread_invoice_id TEXT UNIQUE,
qbo_invoice_id TEXT,
amount_due REAL DEFAULT 0,
amount_paid REAL DEFAULT 0,
due_date TEXT,
status TEXT DEFAULT 'draft',
created_at TEXT,
synced_to_jobtread_at TEXT,
synced_to_qbo_at TEXT
);
CREATE TABLE IF NOT EXISTS daily_logs (
id TEXT PRIMARY KEY,
job_id TEXT REFERENCES jobs(id),
log_date TEXT,
crew_names TEXT,
hours_worked REAL DEFAULT 0,
notes TEXT,
photos TEXT,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS signatures (
id TEXT PRIMARY KEY,
estimate_id TEXT REFERENCES estimates(id),
client_name TEXT,
signature_data TEXT,
signed_at TEXT
);
CREATE TABLE IF NOT EXISTS client_activity (
id TEXT PRIMARY KEY,
estimate_id TEXT REFERENCES estimates(id),
job_id TEXT REFERENCES jobs(id),
action TEXT,
metadata TEXT,
timestamp TEXT
);
CREATE TABLE IF NOT EXISTS jobtread_sync_log (
id TEXT PRIMARY KEY,
local_id TEXT,
jobtread_id TEXT,
entity_type TEXT,
action TEXT,
status TEXT DEFAULT 'pending',
error_message TEXT,
synced_at TEXT
);
CREATE TABLE IF NOT EXISTS project_milestones (
id TEXT PRIMARY KEY,
job_id TEXT REFERENCES jobs(id),
phase_name TEXT,
start_date TEXT,
end_date TEXT,
status TEXT DEFAULT 'pending'
);
"""
def get_db() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=5000")
return conn
def init_db():
conn = get_db()
conn.executescript(SCHEMA)
conn.commit()
conn.close()
def new_id():
return str(uuid.uuid4())[:8]
def now():
return datetime.now().astimezone().isoformat()
def row_to_dict(row):
if row is None:
return None
return dict(row)
def rows_to_list(rows):
return [dict(r) for r in rows]
if __name__ == "__main__":
init_db()
print(json.dumps({"ok": True, "db_path": DB_PATH}))

41
lib/pave.py Normal file
View File

@@ -0,0 +1,41 @@
"""JobTread Pave API helper. Reads creds from .jobtread-api.json."""
import json
import os
import urllib.request
PAVE_URL = "https://api.jobtread.com/pave"
_creds = None
def _get_creds():
global _creds
if _creds:
return _creds
paths = [
os.environ.get("JOBTREAD_CREDS"),
os.path.join(os.path.dirname(__file__), "..", "data", ".jobtread-api.json"),
"/home/node/.openclaw/workspace/agents/tom/.jobtread-api.json",
]
for p in paths:
if p and os.path.exists(p):
with open(p) as f:
_creds = json.load(f)
return _creds
raise RuntimeError("No .jobtread-api.json found")
def get_grant_key():
return _get_creds()["grantKey"]
def get_org_id():
return _get_creds()["organizationId"]
def pave_query(query: dict) -> dict:
"""Execute a Pave API query. Injects grantKey automatically."""
payload = {"query": {"$": {"grantKey": get_grant_key()}, **query}}
data = json.dumps(payload).encode()
req = urllib.request.Request(PAVE_URL, data=data,
headers={"Content-Type": "application/json"}, method="POST")
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read())
if "errors" in result:
raise RuntimeError(f"Pave API error: {result['errors']}")
return result

54
lib/qbo.py Normal file
View File

@@ -0,0 +1,54 @@
"""QuickBooks Online API helper. Tokens from http://192.168.86.11:18801."""
import json
import time
import urllib.request
TOKEN_URL = "http://192.168.86.11:18801/kellow-tokens.json"
REFRESH_URL = "http://192.168.86.11:18803/qbo/refresh?tenant=kellow"
QBO_BASE = "https://quickbooks.api.intuit.com/v3/company"
def get_token():
"""Fetch valid QBO access token + realm_id, auto-refresh if expired."""
with urllib.request.urlopen(TOKEN_URL, timeout=10) as resp:
tokens = json.loads(resp.read())
if time.time() >= tokens.get("expires_at_epoch", 0):
req = urllib.request.Request(REFRESH_URL, method="POST")
urllib.request.urlopen(req, timeout=15)
with urllib.request.urlopen(TOKEN_URL, timeout=10) as resp:
tokens = json.loads(resp.read())
return tokens["access_token"], tokens["realm_id"]
def qbo_request(method, endpoint, body=None):
"""Make authenticated QBO API request. Returns parsed JSON."""
access_token, realm_id = get_token()
url = f"{QBO_BASE}/{realm_id}/{endpoint}"
data = json.dumps(body).encode() if body else None
req = urllib.request.Request(url, data=data, method=method, headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
"Content-Type": "application/json",
})
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
def create_invoice(customer_id, line_items, due_date=None):
"""Create invoice in QBO. line_items: [{description, amount, qty}]"""
lines = []
for i, item in enumerate(line_items, 1):
lines.append({
"LineNum": i,
"Amount": item["amount"],
"DetailType": "SalesItemLineDetail",
"Description": item.get("description", ""),
"SalesItemLineDetail": {
"Qty": item.get("qty", 1),
"UnitPrice": item["amount"] / item.get("qty", 1),
}
})
invoice = {
"CustomerRef": {"value": customer_id},
"Line": lines,
}
if due_date:
invoice["DueDate"] = due_date
return qbo_request("POST", "invoice", invoice)