diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 2ff9b28..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.github/workflows/deploy-security-silverbackai.yml b/.github/workflows/deploy-security-silverbackai.yml new file mode 100644 index 0000000..b123be7 --- /dev/null +++ b/.github/workflows/deploy-security-silverbackai.yml @@ -0,0 +1,23 @@ +name: Deploy Security Silverback AI to Cloudflare + +on: + push: + branches: [main, master] + paths: + - 'workers/security-silverbackai/**' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy security-silverbackai + steps: + - uses: actions/checkout@v4 + + - name: Deploy to Cloudflare Workers + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: bafa242dd95d3fdce72540d20accd0a2 + workingDirectory: workers/security-silverbackai + command: deploy diff --git a/.github/workflows/deploy-silverbackai-toolkit.yml b/.github/workflows/deploy-silverbackai-toolkit.yml new file mode 100644 index 0000000..0d43064 --- /dev/null +++ b/.github/workflows/deploy-silverbackai-toolkit.yml @@ -0,0 +1,23 @@ +name: Deploy Silverback AI Toolkit to Cloudflare + +on: + push: + branches: [main, master] + paths: + - 'workers/silverbackai-toolkit/**' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy silverbackai-toolkit + steps: + - uses: actions/checkout@v4 + + - name: Deploy to Cloudflare Workers + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: bafa242dd95d3fdce72540d20accd0a2 + workingDirectory: workers/silverbackai-toolkit + command: deploy diff --git a/.github/workflows/deploy-silverbackai.yml b/.github/workflows/deploy-silverbackai.yml new file mode 100644 index 0000000..dfb0615 --- /dev/null +++ b/.github/workflows/deploy-silverbackai.yml @@ -0,0 +1,23 @@ +name: Deploy Silverback AI Homepage to Cloudflare + +on: + push: + branches: [main, master] + paths: + - 'workers/silverbackai/**' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy silverbackai + steps: + - uses: actions/checkout@v4 + + - name: Deploy to Cloudflare Workers + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: bafa242dd95d3fdce72540d20accd0a2 + workingDirectory: workers/silverbackai + command: deploy diff --git a/.github/workflows/deploy-stockton-worker.yml b/.github/workflows/deploy-stockton-worker.yml new file mode 100644 index 0000000..9828215 --- /dev/null +++ b/.github/workflows/deploy-stockton-worker.yml @@ -0,0 +1,24 @@ +# .github/workflows/deploy-stockton-worker.yml +name: Deploy Stockton Worker to Cloudflare + +on: + push: + branches: [main, master] + paths: + - 'workers/cleantruckcheckstockton/**' + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + name: Deploy cleantruckcheckstockton + steps: + - uses: actions/checkout@v4 + + - name: Deploy to Cloudflare Workers + uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: bafa242dd95d3fdce72540d20accd0a2 + workingDirectory: workers/cleantruckcheckstockton + command: deploy diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1 @@ + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63c69c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +node_modules/ +.wrangler/ +*.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c242434 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Multi-property web platform deployed on **Cloudflare Workers**, plus a standalone React security app. All workers share Cloudflare account ID `bafa242dd95d3fdce72540d20accd0a2`. + +## Architecture + +``` +workers/ +├── silverbackai/ # Marketing homepage (worker.js → HTML) +├── silverbackai-toolkit/ # AI tools catalog (worker.js → HTML) +├── security-silverbackai/ # Security dashboard (worker.js → HTML + JSON API) +├── cleantruckcheckstockton/ # CARB emissions testing service (worker.js → HTML) +├── silverback-ai-studio/ # Full React + Express security app (NOT a CF Worker) +└── dmc-properties/ # Property management dashboard (static HTML) +``` + +**Cloudflare Workers** (`silverbackai`, `silverbackai-toolkit`, `security-silverbackai`, `cleantruckcheckstockton`): Single `worker.js` files that return complete HTML inline. Each has a `wrangler.toml` for config and a GitHub Actions workflow for auto-deploy on push to `main`. + +**silverback-ai-studio**: React 19 + Express + WebSocket app deployed to Google Cloud Run via AI Studio. Uses Firebase Firestore for event storage, Google Gemini API for AI analysis, and Google Sign-In for auth. Monitors 3875 Ruby St, Oakland. + +## Deployment + +All Cloudflare Worker deploys use GitHub Actions with `cloudflare/wrangler-action@v3`. Workflows are in `.github/workflows/` and trigger on pushes to `main`/`master` scoped by path (e.g., `workers/silverbackai/**`). Requires `CLOUDFLARE_API_TOKEN` secret. + +Custom domain routing is configured in each worker's `wrangler.toml` via `routes` (e.g., `security.silverbackai.agency/*`). DNS records must be configured in Cloudflare dashboard separately. + +To manually deploy a worker: +```bash +cd workers/ +npx wrangler deploy +``` + +## Silverback AI Studio Commands + +```bash +cd workers/silverback-ai-studio +npm install +npm run dev # Start dev server (Express + Vite, port 3000) +npm run build # Production build via Vite +npm run lint # TypeScript type check (tsc --noEmit) +``` + +## Key Patterns + +- Workers return full HTML as template literals inside `fetch()` handler — all CSS is inlined +- Dark theme with purple accents (`#8b5cf6`) is the brand standard across all sites +- The security-silverbackai worker has `/api/health` and `/api/status` JSON endpoints +- Firebase admin is hardcoded to `bryan@norcalcarbmobile.com` in studio app +- Firestore rules enforce auth and role-based access (admin vs viewer) diff --git a/norcal-toolkit/01_lead_scraper.py b/norcal-toolkit/01_lead_scraper.py new file mode 100644 index 0000000..cfbfd0f --- /dev/null +++ b/norcal-toolkit/01_lead_scraper.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Lead Scraper Agent +Scrapes Google Places API for fleet/trucking/dealer leads, +scores them, deduplicates, and exports to CSV. + +Usage: + python3 01_lead_scraper.py # scrape all areas, all queries + python3 01_lead_scraper.py --area sacramento # single area + python3 01_lead_scraper.py --query "trucking" # single query + python3 01_lead_scraper.py --export squarespace # squarespace-ready CSV + +Requires: GOOGLE_PLACES_API_KEY env var +""" +import argparse +import csv +import json +import os +import sys +import time +from datetime import datetime + +import requests + +from config import ( + GOOGLE_PLACES_API_KEY, + SERVICE_AREAS, + DEFAULT_SEARCH_RADIUS_METERS, + SCRAPE_QUERIES, + SCORING, + DATA_DIR, + LEADS_CSV, +) + +# ─── Google Places API ─────────────────────────────────────────── + +PLACES_SEARCH_URL = "https://maps.googleapis.com/maps/api/place/textsearch/json" +PLACES_DETAIL_URL = "https://maps.googleapis.com/maps/api/place/details/json" + +FLEET_KEYWORDS = [ + "fleet", "truck", "trucking", "vehicle", "diesel", "haul", + "freight", "CDL", "DOT", "CARB", "emissions", "smog", + "heavy duty", "semi", "trailer", "logistics", "transport", +] + +COMPLIANCE_KEYWORDS = [ + "CARB", "smog", "emissions", "compliance", "clean truck", + "inspection", "opacity", "OBD", "HD I/M", +] + + +def search_places(query, lat, lng, radius, api_key, next_page_token=None): + """Search Google Places API. Returns results list + next_page_token.""" + params = { + "query": query, + "location": f"{lat},{lng}", + "radius": radius, + "key": api_key, + } + if next_page_token: + params = {"pagetoken": next_page_token, "key": api_key} + + resp = requests.get(PLACES_SEARCH_URL, params=params, timeout=15) + resp.raise_for_status() + data = resp.json() + + if data.get("status") not in ("OK", "ZERO_RESULTS"): + print(f" [WARN] API status: {data.get('status')} — {data.get('error_message', '')}") + + return data.get("results", []), data.get("next_page_token") + + +def get_place_details(place_id, api_key): + """Get phone, website, and extra details for a place.""" + params = { + "place_id": place_id, + "fields": "formatted_phone_number,website,opening_hours,business_status,reviews", + "key": api_key, + } + resp = requests.get(PLACES_DETAIL_URL, params=params, timeout=15) + resp.raise_for_status() + return resp.json().get("result", {}) + + +def score_lead(place, details): + """Score a lead 0-20 based on signals. Higher = hotter.""" + score = 0 + name_lower = place.get("name", "").lower() + types = place.get("types", []) + + # Review count + review_count = place.get("user_ratings_total", 0) + if review_count >= 10: + score += SCORING["high_review_count"] + + # In service area (already filtered by radius, give points) + score += SCORING["in_service_area"] + + # Has website + if details.get("website"): + score += SCORING["has_website"] + + # Fleet/truck keywords in name + if any(kw in name_lower for kw in FLEET_KEYWORDS): + score += SCORING["fleet_keywords"] + + # Compliance keywords in name or reviews + review_text = " ".join( + r.get("text", "") for r in details.get("reviews", []) + ).lower() + if any(kw.lower() in name_lower or kw.lower() in review_text for kw in COMPLIANCE_KEYWORDS): + score += SCORING["compliance_keywords"] + + # Phone available + if details.get("formatted_phone_number"): + score += SCORING["phone_available"] + + return score + + +def tier_label(score): + if score >= 8: + return "TIER 1 — CALL THIS WEEK" + elif score >= 5: + return "TIER 2 — CALL THIS MONTH" + else: + return "TIER 3 — MONTHLY LIST" + + +def load_existing_leads(): + """Load existing leads to deduplicate.""" + existing = set() + if os.path.exists(LEADS_CSV): + with open(LEADS_CSV, "r") as f: + reader = csv.DictReader(f) + for row in reader: + existing.add(row.get("place_id", "")) + return existing + + +def scrape_leads(areas=None, queries=None, api_key=None): + """Main scraper. Returns list of lead dicts.""" + if not api_key: + print("[ERROR] GOOGLE_PLACES_API_KEY not set.") + print(" Set it: export GOOGLE_PLACES_API_KEY='your-key-here'") + print(" Get one: https://console.cloud.google.com/apis/credentials") + print("") + print(" Running in DEMO MODE with sample data...") + return _demo_leads() + + areas = areas or SERVICE_AREAS + queries = queries or SCRAPE_QUERIES + existing_ids = load_existing_leads() + leads = [] + seen_ids = set() + + total_queries = len(areas) * len(queries) + query_num = 0 + + for area_name, coords in areas.items(): + for query in queries: + query_num += 1 + search_term = f"{query} near {area_name.replace('_', ' ')}, CA" + print(f" [{query_num}/{total_queries}] Searching: {search_term}") + + try: + results, next_token = search_places( + search_term, coords["lat"], coords["lng"], + DEFAULT_SEARCH_RADIUS_METERS, api_key, + ) + + # Fetch up to 2 pages (60 results per query) + all_results = results + if next_token: + time.sleep(2) # Google requires delay before next_page_token + page2, _ = search_places(None, None, None, None, api_key, next_token) + all_results += page2 + + for place in all_results: + pid = place.get("place_id", "") + if pid in seen_ids or pid in existing_ids: + continue + seen_ids.add(pid) + + # Get details (phone, website) + details = get_place_details(pid, api_key) + time.sleep(0.1) # rate limit courtesy + + score = score_lead(place, details) + tier = tier_label(score) + + lead = { + "place_id": pid, + "business_name": place.get("name", ""), + "address": place.get("formatted_address", ""), + "phone": details.get("formatted_phone_number", ""), + "website": details.get("website", ""), + "rating": place.get("rating", ""), + "review_count": place.get("user_ratings_total", 0), + "category": query, + "area": area_name, + "score": score, + "tier": tier, + "status": "NEW", + "scraped_date": datetime.now().strftime("%Y-%m-%d"), + "notes": "", + } + leads.append(lead) + + time.sleep(0.5) # rate limit between queries + + except requests.RequestException as e: + print(f" [ERROR] {search_term}: {e}") + continue + + return leads + + +def _demo_leads(): + """Demo data when no API key is set — shows the format.""" + return [ + { + "place_id": "DEMO_001", + "business_name": "Valley Fleet Services", + "address": "4521 Stockton Blvd, Sacramento, CA 95820", + "phone": "(916) 555-0101", + "website": "https://valleyfleet.example.com", + "rating": 4.2, + "review_count": 47, + "category": "fleet management", + "area": "sacramento", + "score": 11, + "tier": "TIER 1 — CALL THIS WEEK", + "status": "NEW", + "scraped_date": datetime.now().strftime("%Y-%m-%d"), + "notes": "Demo lead — replace with real API key", + }, + { + "place_id": "DEMO_002", + "business_name": "Delta Trucking Inc", + "address": "890 Navy Dr, Stockton, CA 95206", + "phone": "(209) 555-0202", + "website": "https://deltatrucking.example.com", + "rating": 3.8, + "review_count": 23, + "category": "trucking company", + "area": "stockton", + "score": 9, + "tier": "TIER 1 — CALL THIS WEEK", + "status": "NEW", + "scraped_date": datetime.now().strftime("%Y-%m-%d"), + "notes": "Demo lead", + }, + { + "place_id": "DEMO_003", + "business_name": "Roseville Auto Group", + "address": "300 Automall Dr, Roseville, CA 95661", + "phone": "(916) 555-0303", + "website": "https://rosevilleauto.example.com", + "rating": 4.5, + "review_count": 156, + "category": "auto dealer", + "area": "roseville", + "score": 7, + "tier": "TIER 2 — CALL THIS MONTH", + "status": "NEW", + "scraped_date": datetime.now().strftime("%Y-%m-%d"), + "notes": "Demo lead", + }, + { + "place_id": "DEMO_004", + "business_name": "Mike's Tow & Recovery", + "address": "1100 E Main St, Stockton, CA 95205", + "phone": "(209) 555-0404", + "website": "", + "rating": 3.2, + "review_count": 8, + "category": "tow yard", + "area": "stockton", + "score": 4, + "tier": "TIER 3 — MONTHLY LIST", + "status": "NEW", + "scraped_date": datetime.now().strftime("%Y-%m-%d"), + "notes": "Demo lead", + }, + { + "place_id": "DEMO_005", + "business_name": "Garcia Landscaping & Tree Service", + "address": "2200 Fruitridge Rd, Sacramento, CA 95822", + "phone": "(916) 555-0505", + "website": "", + "rating": 4.7, + "review_count": 31, + "category": "landscaping company", + "area": "sacramento", + "score": 6, + "tier": "TIER 2 — CALL THIS MONTH", + "status": "NEW", + "scraped_date": datetime.now().strftime("%Y-%m-%d"), + "notes": "Demo lead — likely has truck fleet", + }, + ] + + +def export_csv(leads, filepath, fmt="standard"): + """Export leads to CSV. fmt='squarespace' for email campaign import.""" + os.makedirs(os.path.dirname(filepath), exist_ok=True) + + if fmt == "squarespace": + # Squarespace email campaign CSV format + fieldnames = ["Email", "First Name", "Last Name", "Company", "Phone", "Tags"] + rows = [] + for lead in leads: + name_parts = lead["business_name"].split() + rows.append({ + "Email": "", # must be filled manually or enriched + "First Name": name_parts[0] if name_parts else "", + "Last Name": " ".join(name_parts[1:]) if len(name_parts) > 1 else "", + "Company": lead["business_name"], + "Phone": lead["phone"], + "Tags": lead["tier"].split("—")[0].strip(), + }) + else: + fieldnames = [ + "place_id", "business_name", "address", "phone", "website", + "rating", "review_count", "category", "area", "score", "tier", + "status", "scraped_date", "notes", + ] + rows = leads + + with open(filepath, "a" if os.path.exists(filepath) else "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + if f.tell() == 0: + writer.writeheader() + writer.writerows(rows) + + return filepath + + +def print_summary(leads): + """Print a quick summary of scraped leads.""" + tier1 = [l for l in leads if l["score"] >= 8] + tier2 = [l for l in leads if 5 <= l["score"] < 8] + tier3 = [l for l in leads if l["score"] < 5] + + print(f"\n{'='*60}") + print(f" SCRAPE COMPLETE — {datetime.now().strftime('%Y-%m-%d %H:%M')}") + print(f"{'='*60}") + print(f" Total leads: {len(leads)}") + print(f" TIER 1 (8+): {len(tier1)} — CALL THIS WEEK") + print(f" TIER 2 (5-7): {len(tier2)} — CALL THIS MONTH") + print(f" TIER 3 (<5): {len(tier3)} — MONTHLY LIST") + print(f"{'='*60}") + + if tier1: + print(f"\n 🔥 HOT LEADS (Tier 1):") + for l in sorted(tier1, key=lambda x: x["score"], reverse=True): + print(f" [{l['score']}] {l['business_name']} — {l['phone']} — {l['area']}") + + print() + + +def main(): + parser = argparse.ArgumentParser(description="NorCal Lead Scraper") + parser.add_argument("--area", help="Single area to scrape (e.g., sacramento)") + parser.add_argument("--query", help="Single search query (e.g., 'trucking company')") + parser.add_argument("--export", choices=["standard", "squarespace"], default="standard", + help="CSV export format") + parser.add_argument("--output", help="Custom output file path") + args = parser.parse_args() + + areas = {args.area: SERVICE_AREAS[args.area]} if args.area else None + queries = [args.query] if args.query else None + + print(f"\n NorCal Carb Mobile — Lead Scraper") + print(f" {'='*40}") + + leads = scrape_leads(areas=areas, queries=queries, api_key=GOOGLE_PLACES_API_KEY) + + if not leads: + print(" No new leads found.") + return + + # Export + output_path = args.output or LEADS_CSV + if args.export == "squarespace": + output_path = args.output or os.path.join(DATA_DIR, "leads_squarespace.csv") + + export_csv(leads, output_path, fmt=args.export) + print_summary(leads) + print(f" Saved to: {output_path}\n") + + +if __name__ == "__main__": + main() diff --git a/norcal-toolkit/02_cold_emailer.py b/norcal-toolkit/02_cold_emailer.py new file mode 100644 index 0000000..4c57db6 --- /dev/null +++ b/norcal-toolkit/02_cold_emailer.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Cold Email Personalization & Sender Agent +Reads leads CSV, generates personalized cold email sequences, +and optionally sends via SMTP (secondary domain). + +Usage: + python3 02_cold_emailer.py --preview # preview emails, don't send + python3 02_cold_emailer.py --send --tier 1 # send to Tier 1 only + python3 02_cold_emailer.py --send --limit 10 # send to first 10 unsent + python3 02_cold_emailer.py --sequence 2 # send follow-up #2 + +IMPORTANT: Use a secondary domain for cold email (not your main domain). +""" +import argparse +import csv +import os +import smtplib +import sys +from datetime import datetime +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + +from config import ( + BUSINESS_NAME, BUSINESS_PHONE, BUSINESS_WEBSITE, OWNER_NAME, + BAR_LICENSE, SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, + COLD_EMAIL_FROM, DATA_DIR, LEADS_CSV, EMAILS_LOG, PRICING, +) + +# ─── Email Templates (3-touch sequence) ───────────────────────── + +TEMPLATES = { + 1: { + "subject": "Quick question about {business_name}", + "body": """Hey {contact_name}, + +Do you handle your fleet smog and emissions testing in-house, or send vehicles out? + +I run {our_business} — we're a BAR-licensed mobile testing unit that comes on-site. No vehicle downtime, no trips to a station. We handle OBD, OVI, and smoke opacity testing right in your lot. + +Wanted to see if that's useful for {business_name}. We're in the {area} area this week. + +{owner_name} +{our_business} +{phone} +{website}""", + }, + 2: { + "subject": "Re: Quick question about {business_name}", + "body": """Hey {contact_name}, + +Bumping this up — I'm in {area} this week and have openings. + +Happy to swing by and knock out a test so you can see how the mobile setup works. No commitment, no pressure. Most shops save 2-3 hours per vehicle compared to driving to a station. + +Worth 15 minutes? + +{owner_name} +{phone}""", + }, + 3: { + "subject": "Re: Quick question about {business_name}", + "body": """Hey {contact_name}, + +Last note from me on this. If mobile smog testing ever makes sense for your operation, I'm a call away. + +Quick numbers: we test on-site for ${obd_price}/vehicle (OBD) and ${ovi_price}/vehicle (OVI). Fleets of 5+ get {fleet_discount}% off. California requires testing 2x/year now, going to 4x/year in October 2027 — the compliance load is about to double. + +If that deadline sneaks up, give me a ring. + +{owner_name} +{our_business} +{phone}""", + }, +} + +# ─── Category-specific openers ────────────────────────────────── + +CATEGORY_HOOKS = { + "trucking company": "With CARB's HD I/M program, every truck over 14,000 lbs needs testing twice a year — and that doubles to 4x in 2027.", + "fleet management": "Managing compliance across a fleet is a headache. We take that off your plate with on-site testing and deadline tracking.", + "diesel repair shop": "A lot of shops refer mobile testing to us — your customers get tested on your lot, you keep the relationship.", + "construction company": "Construction fleets are high on CARB's radar. We come to your yard so your trucks don't sit idle at a station.", + "auto dealer": "Dealers moving used heavy-duty inventory need clean test results. We come to you — no transport needed.", + "tow yard": "Tow trucks over 14,000 lbs are in the HD I/M program. Most tow operators don't know that yet.", + "body shop": "Shops that refer us for post-repair smog testing keep their bays free and their customers happy.", + "landscaping company": "Landscaping trucks and trailers over 14K lbs fall under CARB's Clean Truck Check. We test on-site.", + "school bus service": "School bus fleets need compliance testing. We come to your depot — zero disruption to routes.", + "freight broker": "Your carriers need clean compliance. We offer fleet testing they can schedule in 60 seconds.", + "logistics company": "Logistics fleets get hit hard by CARB deadlines. We test at your warehouse — no downtime.", + "property management": "If your maintenance fleet includes diesel trucks over 14K lbs, they need CARB testing.", + "waste management": "Every garbage truck and hauler over 14K lbs needs bi-annual testing. We come to your yard.", + "delivery service": "Delivery fleets with heavy-duty vehicles are in CARB's crosshairs. $10K/day fines are no joke.", +} + + +def personalize_email(template_num, lead): + """Generate a personalized email from template + lead data.""" + template = TEMPLATES[template_num] + contact = lead.get("business_name", "there").split()[0] # first word as contact name + + category = lead.get("category", "") + hook = CATEGORY_HOOKS.get(category, "") + + vars = { + "business_name": lead.get("business_name", "your business"), + "contact_name": contact, + "area": lead.get("area", "your area").replace("_", " ").title(), + "our_business": BUSINESS_NAME, + "owner_name": OWNER_NAME, + "phone": BUSINESS_PHONE, + "website": BUSINESS_WEBSITE, + "obd_price": f"{PRICING['obd_test']:.0f}", + "ovi_price": f"{PRICING['ovi_test']:.0f}", + "fleet_discount": PRICING["fleet_discount_pct"], + "category_hook": hook, + } + + subject = template["subject"].format(**vars) + body = template["body"].format(**vars) + + # Inject category hook after first paragraph in email 1 + if template_num == 1 and hook: + lines = body.split("\n\n") + if len(lines) >= 2: + lines.insert(1, hook) + body = "\n\n".join(lines) + + return subject, body + + +def load_leads(tier_filter=None, limit=None): + """Load leads from CSV, optionally filtered.""" + if not os.path.exists(LEADS_CSV): + print(f"[ERROR] No leads file found at {LEADS_CSV}") + print(" Run 01_lead_scraper.py first.") + sys.exit(1) + + leads = [] + with open(LEADS_CSV, "r") as f: + reader = csv.DictReader(f) + for row in reader: + if tier_filter: + score = int(row.get("score", 0)) + if tier_filter == 1 and score < 8: + continue + elif tier_filter == 2 and not (5 <= score < 8): + continue + elif tier_filter == 3 and score >= 5: + continue + leads.append(row) + + if limit: + leads = leads[:limit] + + return leads + + +def load_sent_log(): + """Load email send log to track what's been sent.""" + sent = {} # {place_id: {1: date, 2: date, ...}} + if os.path.exists(EMAILS_LOG): + with open(EMAILS_LOG, "r") as f: + reader = csv.DictReader(f) + for row in reader: + pid = row.get("place_id", "") + seq = int(row.get("sequence_num", 0)) + if pid not in sent: + sent[pid] = {} + sent[pid][seq] = row.get("sent_date", "") + return sent + + +def log_sent(place_id, business_name, email_to, sequence_num, subject): + """Log a sent email.""" + os.makedirs(DATA_DIR, exist_ok=True) + file_exists = os.path.exists(EMAILS_LOG) + + with open(EMAILS_LOG, "a", newline="") as f: + writer = csv.DictWriter(f, fieldnames=[ + "place_id", "business_name", "email_to", "sequence_num", + "subject", "sent_date", "status", + ]) + if not file_exists: + writer.writeheader() + writer.writerow({ + "place_id": place_id, + "business_name": business_name, + "email_to": email_to, + "sequence_num": sequence_num, + "subject": subject, + "sent_date": datetime.now().strftime("%Y-%m-%d %H:%M"), + "status": "sent", + }) + + +def send_email(to_email, subject, body, from_email=None): + """Send email via SMTP. Returns True on success.""" + if not SMTP_USER or not SMTP_PASS: + print(" [SKIP] SMTP not configured — set SMTP_USER and SMTP_PASS env vars") + return False + + from_addr = from_email or COLD_EMAIL_FROM or SMTP_USER + + msg = MIMEMultipart() + msg["From"] = f"{OWNER_NAME} <{from_addr}>" + msg["To"] = to_email + msg["Subject"] = subject + msg.attach(MIMEText(body, "plain")) + + try: + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as server: + server.starttls() + server.login(SMTP_USER, SMTP_PASS) + server.send_message(msg) + return True + except Exception as e: + print(f" [ERROR] Send failed: {e}") + return False + + +def preview_emails(leads, sequence_num): + """Print email previews without sending.""" + sent_log = load_sent_log() + + print(f"\n{'='*60}") + print(f" EMAIL PREVIEW — Sequence #{sequence_num}") + print(f"{'='*60}\n") + + count = 0 + for lead in leads: + pid = lead.get("place_id", "") + already_sent = sent_log.get(pid, {}) + + if sequence_num in already_sent: + continue + + subject, body = personalize_email(sequence_num, lead) + count += 1 + + print(f" ─── To: {lead['business_name']} ({lead.get('phone', 'no phone')}) ───") + print(f" Score: {lead.get('score', '?')} | {lead.get('tier', '?')}") + print(f" Subject: {subject}") + print(f" ---") + for line in body.split("\n"): + print(f" {line}") + print(f" {'─'*50}\n") + + print(f" Total emails to send: {count}\n") + + +def send_sequence(leads, sequence_num, dry_run=False): + """Send email sequence to leads.""" + sent_log = load_sent_log() + sent_count = 0 + skip_count = 0 + + for lead in leads: + pid = lead.get("place_id", "") + already_sent = sent_log.get(pid, {}) + + # Skip if this sequence already sent + if sequence_num in already_sent: + skip_count += 1 + continue + + # Skip if previous sequence not sent yet (must go in order) + if sequence_num > 1 and (sequence_num - 1) not in already_sent: + continue + + # Need an email address to send to + # In practice, you'd enrich this from scraping or manual entry + email_to = lead.get("email", "") + if not email_to: + print(f" [SKIP] {lead['business_name']} — no email address") + continue + + subject, body = personalize_email(sequence_num, lead) + + if dry_run: + print(f" [DRY RUN] Would send to {email_to}: {subject}") + else: + success = send_email(email_to, subject, body) + if success: + log_sent(pid, lead["business_name"], email_to, sequence_num, subject) + sent_count += 1 + print(f" [SENT] {lead['business_name']} → {email_to}") + + print(f"\n Sent: {sent_count} | Skipped (already sent): {skip_count}\n") + + +def main(): + parser = argparse.ArgumentParser(description="NorCal Cold Emailer") + parser.add_argument("--preview", action="store_true", help="Preview emails without sending") + parser.add_argument("--send", action="store_true", help="Actually send emails") + parser.add_argument("--sequence", type=int, default=1, choices=[1, 2, 3], + help="Which email in the sequence (1=initial, 2=bump, 3=breakup)") + parser.add_argument("--tier", type=int, choices=[1, 2, 3], help="Filter by tier") + parser.add_argument("--limit", type=int, help="Max leads to process") + parser.add_argument("--dry-run", action="store_true", help="Log but don't actually send") + args = parser.parse_args() + + leads = load_leads(tier_filter=args.tier, limit=args.limit) + print(f"\n Loaded {len(leads)} leads" + (f" (Tier {args.tier})" if args.tier else "")) + + if args.preview or (not args.send): + preview_emails(leads, args.sequence) + elif args.send: + send_sequence(leads, args.sequence, dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/norcal-toolkit/03_crm_tracker.py b/norcal-toolkit/03_crm_tracker.py new file mode 100644 index 0000000..7d1378e --- /dev/null +++ b/norcal-toolkit/03_crm_tracker.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Follow-Up Tracker / Mini CRM +Tracks lead status, schedules follow-ups, manages the pipeline. + +Usage: + python3 03_crm_tracker.py status # pipeline overview + python3 03_crm_tracker.py due # today's follow-ups + python3 03_crm_tracker.py add "Company" "phone" "email" "notes" + python3 03_crm_tracker.py update PLACE_ID --status contacted + python3 03_crm_tracker.py schedule PLACE_ID --date 2026-03-20 --note "call back" + python3 03_crm_tracker.py import leads.csv # import from scraper + python3 03_crm_tracker.py export google # export for Google Sheets +""" +import argparse +import csv +import os +import sys +from datetime import datetime, timedelta + +from config import DATA_DIR, LEADS_CSV, CRM_CSV, FOLLOWUP_SCHEDULE + +# ─── CRM Fields ────────────────────────────────────────────────── + +CRM_FIELDS = [ + "place_id", "business_name", "contact_name", "phone", "email", + "address", "website", "category", "score", "tier", + "status", # NEW, CONTACTED, RESPONDED, SCHEDULED, TESTED, INVOICED, PAID, LOST + "last_contact", # date of last outreach + "next_followup", # date of next scheduled follow-up + "contact_count", # number of times contacted + "notes", # running notes + "created_date", + "updated_date", +] + +VALID_STATUSES = [ + "NEW", "CONTACTED", "RESPONDED", "SCHEDULED", + "TESTED", "INVOICED", "PAID", "LOST", +] + + +def load_crm(): + """Load CRM data from CSV.""" + records = [] + if os.path.exists(CRM_CSV): + with open(CRM_CSV, "r") as f: + reader = csv.DictReader(f) + for row in reader: + records.append(row) + return records + + +def save_crm(records): + """Save CRM data to CSV.""" + os.makedirs(DATA_DIR, exist_ok=True) + with open(CRM_CSV, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=CRM_FIELDS, extrasaction="ignore") + writer.writeheader() + writer.writerows(records) + + +def find_record(records, identifier): + """Find a record by place_id or business name (partial match).""" + for r in records: + if r.get("place_id") == identifier: + return r + if identifier.lower() in r.get("business_name", "").lower(): + return r + return None + + +def import_from_leads(leads_file=None): + """Import leads from scraper CSV into CRM.""" + src = leads_file or LEADS_CSV + if not os.path.exists(src): + print(f"[ERROR] File not found: {src}") + sys.exit(1) + + crm = load_crm() + existing_ids = {r["place_id"] for r in crm} + imported = 0 + + with open(src, "r") as f: + reader = csv.DictReader(f) + for row in reader: + pid = row.get("place_id", "") + if pid in existing_ids: + continue + + now = datetime.now().strftime("%Y-%m-%d") + crm.append({ + "place_id": pid, + "business_name": row.get("business_name", ""), + "contact_name": "", + "phone": row.get("phone", ""), + "email": "", + "address": row.get("address", ""), + "website": row.get("website", ""), + "category": row.get("category", ""), + "score": row.get("score", "0"), + "tier": row.get("tier", ""), + "status": "NEW", + "last_contact": "", + "next_followup": now, # follow up today + "contact_count": "0", + "notes": row.get("notes", ""), + "created_date": now, + "updated_date": now, + }) + imported += 1 + existing_ids.add(pid) + + save_crm(crm) + print(f" Imported {imported} new leads into CRM ({len(crm)} total)") + + +def show_status(): + """Show pipeline status overview.""" + crm = load_crm() + if not crm: + print(" CRM is empty. Run: python3 03_crm_tracker.py import") + return + + # Count by status + status_counts = {} + for r in crm: + s = r.get("status", "NEW") + status_counts[s] = status_counts.get(s, 0) + 1 + + # Count by tier + tier_counts = {"TIER 1": 0, "TIER 2": 0, "TIER 3": 0} + for r in crm: + tier = r.get("tier", "") + for t in tier_counts: + if t in tier: + tier_counts[t] += 1 + + print(f"\n{'='*60}") + print(f" PIPELINE STATUS — {datetime.now().strftime('%Y-%m-%d')}") + print(f"{'='*60}") + print(f" Total leads: {len(crm)}") + print() + + # Status pipeline + pipeline_order = ["NEW", "CONTACTED", "RESPONDED", "SCHEDULED", "TESTED", "INVOICED", "PAID", "LOST"] + for s in pipeline_order: + count = status_counts.get(s, 0) + if count > 0: + bar = "#" * min(count, 40) + print(f" {s:12s} [{count:3d}] {bar}") + + print(f"\n By Tier:") + for t, c in tier_counts.items(): + print(f" {t}: {c}") + + # Revenue + tested = status_counts.get("TESTED", 0) + status_counts.get("INVOICED", 0) + status_counts.get("PAID", 0) + paid = status_counts.get("PAID", 0) + print(f"\n Jobs completed: {tested}") + print(f" Paid: {paid}") + print(f"{'='*60}\n") + + +def show_due(): + """Show follow-ups due today or overdue.""" + crm = load_crm() + today = datetime.now().strftime("%Y-%m-%d") + + due = [] + overdue = [] + for r in crm: + next_fu = r.get("next_followup", "") + if not next_fu or r.get("status") in ("PAID", "LOST"): + continue + if next_fu <= today: + if next_fu < today: + overdue.append(r) + else: + due.append(r) + + print(f"\n{'='*60}") + print(f" FOLLOW-UPS DUE — {today}") + print(f"{'='*60}") + + if overdue: + print(f"\n OVERDUE ({len(overdue)}):") + for r in sorted(overdue, key=lambda x: x.get("score", "0"), reverse=True): + print(f" [{r.get('score', '?')}] {r['business_name']:30s} {r.get('phone', ''):15s} " + f"status={r['status']} due={r['next_followup']} contacts={r.get('contact_count', 0)}") + + if due: + print(f"\n DUE TODAY ({len(due)}):") + for r in sorted(due, key=lambda x: x.get("score", "0"), reverse=True): + print(f" [{r.get('score', '?')}] {r['business_name']:30s} {r.get('phone', ''):15s} " + f"status={r['status']} contacts={r.get('contact_count', 0)}") + + if not due and not overdue: + print(" Nothing due today. Go make some calls anyway.") + + print(f"{'='*60}\n") + + +def add_lead(name, phone="", email="", notes=""): + """Manually add a lead to CRM.""" + crm = load_crm() + now = datetime.now().strftime("%Y-%m-%d") + + new_id = f"MANUAL_{datetime.now().strftime('%Y%m%d%H%M%S')}" + crm.append({ + "place_id": new_id, + "business_name": name, + "contact_name": "", + "phone": phone, + "email": email, + "address": "", + "website": "", + "category": "manual", + "score": "5", + "tier": "TIER 2 — CALL THIS MONTH", + "status": "NEW", + "last_contact": "", + "next_followup": now, + "contact_count": "0", + "notes": notes, + "created_date": now, + "updated_date": now, + }) + + save_crm(crm) + print(f" Added: {name} ({phone})") + + +def update_lead(identifier, status=None, note=None, contact_name=None, email=None, schedule_date=None): + """Update a lead's status, notes, or schedule next follow-up.""" + crm = load_crm() + record = find_record(crm, identifier) + + if not record: + print(f" [ERROR] Lead not found: {identifier}") + return + + now = datetime.now().strftime("%Y-%m-%d") + + if status: + if status.upper() not in VALID_STATUSES: + print(f" [ERROR] Invalid status: {status}. Valid: {', '.join(VALID_STATUSES)}") + return + record["status"] = status.upper() + + # Auto-set next follow-up based on contact count + if status.upper() == "CONTACTED": + count = int(record.get("contact_count", 0)) + 1 + record["contact_count"] = str(count) + record["last_contact"] = now + + # Schedule next follow-up from cadence + if count <= len(FOLLOWUP_SCHEDULE): + days = FOLLOWUP_SCHEDULE[count - 1] + record["next_followup"] = (datetime.now() + timedelta(days=days)).strftime("%Y-%m-%d") + else: + record["next_followup"] = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d") + + if contact_name: + record["contact_name"] = contact_name + + if email: + record["email"] = email + + if note: + existing = record.get("notes", "") + record["notes"] = f"{existing} | [{now}] {note}" if existing else f"[{now}] {note}" + + if schedule_date: + record["next_followup"] = schedule_date + + record["updated_date"] = now + save_crm(crm) + print(f" Updated: {record['business_name']} → status={record['status']}, next={record.get('next_followup', 'none')}") + + +def export_google(): + """Export CRM in a format ready for Google Sheets import.""" + crm = load_crm() + output = os.path.join(DATA_DIR, "crm_google_sheets.csv") + + with open(output, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=CRM_FIELDS, extrasaction="ignore") + writer.writeheader() + writer.writerows(crm) + + print(f" Exported {len(crm)} records to {output}") + print(f" Upload to Google Sheets → File → Import → Upload") + + +def main(): + parser = argparse.ArgumentParser(description="NorCal CRM Tracker") + sub = parser.add_subparsers(dest="command") + + sub.add_parser("status", help="Pipeline overview") + sub.add_parser("due", help="Today's follow-ups") + + add_p = sub.add_parser("add", help="Add a lead manually") + add_p.add_argument("name", help="Business name") + add_p.add_argument("phone", nargs="?", default="", help="Phone number") + add_p.add_argument("email", nargs="?", default="", help="Email address") + add_p.add_argument("notes", nargs="?", default="", help="Notes") + + update_p = sub.add_parser("update", help="Update a lead") + update_p.add_argument("identifier", help="Place ID or business name") + update_p.add_argument("--status", help="New status") + update_p.add_argument("--note", help="Add a note") + update_p.add_argument("--contact", help="Contact person name") + update_p.add_argument("--email", help="Email address") + update_p.add_argument("--date", help="Schedule follow-up date (YYYY-MM-DD)") + + sched_p = sub.add_parser("schedule", help="Schedule follow-up") + sched_p.add_argument("identifier", help="Place ID or business name") + sched_p.add_argument("--date", required=True, help="Follow-up date (YYYY-MM-DD)") + sched_p.add_argument("--note", default="", help="Note for the follow-up") + + import_p = sub.add_parser("import", help="Import from scraper CSV") + import_p.add_argument("file", nargs="?", help="CSV file path (default: leads.csv)") + + sub.add_parser("export", help="Export for Google Sheets") + + args = parser.parse_args() + + if args.command == "status": + show_status() + elif args.command == "due": + show_due() + elif args.command == "add": + add_lead(args.name, args.phone, args.email, args.notes) + elif args.command == "update": + update_lead(args.identifier, status=args.status, note=args.note, + contact_name=args.contact, email=args.email, schedule_date=args.date) + elif args.command == "schedule": + update_lead(args.identifier, schedule_date=args.date, note=args.note) + elif args.command == "import": + import_from_leads(args.file) + elif args.command == "export": + export_google() + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/norcal-toolkit/04_invoice_generator.py b/norcal-toolkit/04_invoice_generator.py new file mode 100644 index 0000000..9c30064 --- /dev/null +++ b/norcal-toolkit/04_invoice_generator.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Invoice Generator +Creates professional invoices as HTML (printable/PDF-able) and tracks A/R. + +Usage: + python3 04_invoice_generator.py create "Valley Fleet" --obd 3 --ovi 1 + python3 04_invoice_generator.py create "Delta Trucking" --obd 5 --fleet-discount + python3 04_invoice_generator.py list # all invoices + python3 04_invoice_generator.py overdue # unpaid past due + python3 04_invoice_generator.py paid INV-20260317-001 # mark as paid + python3 04_invoice_generator.py remind # send payment reminders +""" +import argparse +import csv +import os +import sys +from datetime import datetime, timedelta + +from jinja2 import Template + +from config import ( + BUSINESS_NAME, BUSINESS_PHONE, BUSINESS_EMAIL, BUSINESS_WEBSITE, + BUSINESS_ADDRESS, BAR_LICENSE, OWNER_NAME, PRICING, + DATA_DIR, INVOICES_DIR, CRM_CSV, INVOICE_TERMS_DAYS, +) + +# ─── Invoice Tracking ──────────────────────────────────────────── + +INVOICE_LOG = os.path.join(DATA_DIR, "invoices.csv") + +INVOICE_FIELDS = [ + "invoice_id", "customer_name", "customer_phone", "customer_email", + "customer_address", "date_issued", "date_due", "date_paid", + "obd_count", "obd_rate", "ovi_count", "ovi_rate", + "smoke_count", "smoke_rate", "discount_pct", "subtotal", + "discount_amount", "total", "status", "notes", "file_path", +] + + +def next_invoice_id(): + """Generate sequential invoice ID.""" + today = datetime.now().strftime("%Y%m%d") + seq = 1 + + if os.path.exists(INVOICE_LOG): + with open(INVOICE_LOG, "r") as f: + reader = csv.DictReader(f) + for row in reader: + iid = row.get("invoice_id", "") + if today in iid: + try: + existing_seq = int(iid.split("-")[-1]) + seq = max(seq, existing_seq + 1) + except ValueError: + pass + + return f"INV-{today}-{seq:03d}" + + +def calculate_totals(obd_count, ovi_count, smoke_count, fleet_discount=False): + """Calculate line items and totals.""" + obd_rate = PRICING["obd_test"] + ovi_rate = PRICING["ovi_test"] + smoke_rate = PRICING["smoke_opacity_test"] + discount_pct = 0 + + obd_total = obd_count * obd_rate + ovi_total = ovi_count * ovi_rate + smoke_total = smoke_count * smoke_rate + subtotal = obd_total + ovi_total + smoke_total + + total_vehicles = obd_count + ovi_count + smoke_count + if fleet_discount or total_vehicles >= PRICING["fleet_discount_threshold"]: + discount_pct = PRICING["fleet_discount_pct"] + + discount_amount = subtotal * (discount_pct / 100) + total = subtotal - discount_amount + + return { + "obd_rate": obd_rate, "ovi_rate": ovi_rate, "smoke_rate": smoke_rate, + "obd_total": obd_total, "ovi_total": ovi_total, "smoke_total": smoke_total, + "subtotal": subtotal, "discount_pct": discount_pct, + "discount_amount": discount_amount, "total": total, + } + + +# ─── HTML Invoice Template ────────────────────────────────────── + +INVOICE_HTML = Template(""" + + + +Invoice {{ invoice_id }} + + + +
+
+

{{ business_name }}

+

{{ business_address }}

+

{{ business_phone }} | {{ business_email }}

+

BAR License: {{ bar_license }}

+
+
+

INVOICE

+

{{ invoice_id }}

+

Date: {{ date_issued }}

+

Due: {{ date_due }}

+

{{ status }}

+
+
+ +
+
+

Bill To

+

{{ customer_name }}

+ {% if customer_address %}

{{ customer_address }}

{% endif %} + {% if customer_phone %}

{{ customer_phone }}

{% endif %} + {% if customer_email %}

{{ customer_email }}

{% endif %} +
+
+

Service Location

+

On-site mobile testing

+ {% if customer_address %}

{{ customer_address }}

{% endif %} +
+
+ + + + + + + + + + + + + {% if obd_count > 0 %} + + + + + + + + {% endif %} + {% if ovi_count > 0 %} + + + + + + + + {% endif %} + {% if smoke_count > 0 %} + + + + + + + + {% endif %} + +
ServiceDescriptionQtyRateAmount
OBD TestOn-Board Diagnostics emissions test{{ obd_count }}${{ "%.2f"|format(obd_rate) }}${{ "%.2f"|format(obd_total) }}
OVI TestOpacity / Visual Inspection test{{ ovi_count }}${{ "%.2f"|format(ovi_rate) }}${{ "%.2f"|format(ovi_total) }}
Smoke Opacity TestSmoke opacity measurement{{ smoke_count }}${{ "%.2f"|format(smoke_rate) }}${{ "%.2f"|format(smoke_total) }}
+ +
+ + + + + + {% if discount_pct > 0 %} + + + + + {% endif %} + + + + +
Subtotal${{ "%.2f"|format(subtotal) }}
Fleet Discount ({{ discount_pct }}%)-${{ "%.2f"|format(discount_amount) }}
Total Due${{ "%.2f"|format(total) }}
+
+ +
+ Payment Terms + Payment due within {{ terms_days }} days of invoice date. + Make checks payable to {{ business_name }}. + For questions, contact {{ business_phone }} or {{ business_email }}. +
+ + + +""") + + +def create_invoice(customer_name, obd=0, ovi=0, smoke=0, fleet_discount=False, + customer_phone="", customer_email="", customer_address="", notes=""): + """Create an invoice and save as HTML + log entry.""" + os.makedirs(INVOICES_DIR, exist_ok=True) + os.makedirs(DATA_DIR, exist_ok=True) + + inv_id = next_invoice_id() + now = datetime.now() + date_issued = now.strftime("%Y-%m-%d") + date_due = (now + timedelta(days=INVOICE_TERMS_DAYS)).strftime("%Y-%m-%d") + + totals = calculate_totals(obd, ovi, smoke, fleet_discount) + + # Render HTML + html = INVOICE_HTML.render( + invoice_id=inv_id, + business_name=BUSINESS_NAME, + business_address=BUSINESS_ADDRESS, + business_phone=BUSINESS_PHONE, + business_email=BUSINESS_EMAIL, + business_website=BUSINESS_WEBSITE, + bar_license=BAR_LICENSE, + customer_name=customer_name, + customer_phone=customer_phone, + customer_email=customer_email, + customer_address=customer_address, + date_issued=date_issued, + date_due=date_due, + status="UNPAID", + status_class="unpaid", + obd_count=obd, obd_rate=totals["obd_rate"], obd_total=totals["obd_total"], + ovi_count=ovi, ovi_rate=totals["ovi_rate"], ovi_total=totals["ovi_total"], + smoke_count=smoke, smoke_rate=totals["smoke_rate"], smoke_total=totals["smoke_total"], + subtotal=totals["subtotal"], + discount_pct=totals["discount_pct"], + discount_amount=totals["discount_amount"], + total=totals["total"], + terms_days=INVOICE_TERMS_DAYS, + ) + + # Save HTML file + html_path = os.path.join(INVOICES_DIR, f"{inv_id}.html") + with open(html_path, "w") as f: + f.write(html) + + # Log to CSV + file_exists = os.path.exists(INVOICE_LOG) + with open(INVOICE_LOG, "a", newline="") as f: + writer = csv.DictWriter(f, fieldnames=INVOICE_FIELDS) + if not file_exists: + writer.writeheader() + writer.writerow({ + "invoice_id": inv_id, + "customer_name": customer_name, + "customer_phone": customer_phone, + "customer_email": customer_email, + "customer_address": customer_address, + "date_issued": date_issued, + "date_due": date_due, + "date_paid": "", + "obd_count": obd, + "obd_rate": totals["obd_rate"], + "ovi_count": ovi, + "ovi_rate": totals["ovi_rate"], + "smoke_count": smoke, + "smoke_rate": totals["smoke_rate"], + "discount_pct": totals["discount_pct"], + "subtotal": totals["subtotal"], + "discount_amount": totals["discount_amount"], + "total": totals["total"], + "status": "UNPAID", + "notes": notes, + "file_path": html_path, + }) + + print(f"\n Invoice created: {inv_id}") + print(f" Customer: {customer_name}") + print(f" Services: {obd} OBD, {ovi} OVI, {smoke} Smoke") + if totals["discount_pct"] > 0: + print(f" Discount: {totals['discount_pct']}% fleet discount (-${totals['discount_amount']:.2f})") + print(f" Total: ${totals['total']:.2f}") + print(f" Due: {date_due}") + print(f" File: {html_path}") + print(f"\n Open in browser to print/save as PDF.\n") + + return inv_id + + +def list_invoices(): + """List all invoices.""" + if not os.path.exists(INVOICE_LOG): + print(" No invoices yet.") + return + + with open(INVOICE_LOG, "r") as f: + reader = csv.DictReader(f) + invoices = list(reader) + + print(f"\n{'='*80}") + print(f" ALL INVOICES") + print(f"{'='*80}") + print(f" {'ID':<22s} {'Customer':<25s} {'Total':>10s} {'Due':<12s} {'Status':<10s}") + print(f" {'-'*22} {'-'*25} {'-'*10} {'-'*12} {'-'*10}") + + total_outstanding = 0 + total_paid = 0 + + for inv in invoices: + total = float(inv.get("total", 0)) + status = inv.get("status", "UNPAID") + if status == "PAID": + total_paid += total + else: + total_outstanding += total + + print(f" {inv['invoice_id']:<22s} {inv['customer_name']:<25s} " + f"${total:>8.2f} {inv.get('date_due', ''):<12s} {status:<10s}") + + print(f"\n Outstanding: ${total_outstanding:.2f} | Paid: ${total_paid:.2f}") + print(f"{'='*80}\n") + + +def show_overdue(): + """Show overdue invoices.""" + if not os.path.exists(INVOICE_LOG): + print(" No invoices yet.") + return + + today = datetime.now().strftime("%Y-%m-%d") + overdue = [] + + with open(INVOICE_LOG, "r") as f: + reader = csv.DictReader(f) + for inv in reader: + if inv.get("status") != "PAID" and inv.get("date_due", "9999") < today: + overdue.append(inv) + + print(f"\n{'='*60}") + print(f" OVERDUE INVOICES — {today}") + print(f"{'='*60}") + + if not overdue: + print(" All invoices current. Nice.") + else: + total = 0 + for inv in overdue: + amt = float(inv.get("total", 0)) + total += amt + days = (datetime.now() - datetime.strptime(inv["date_due"], "%Y-%m-%d")).days + print(f" {inv['invoice_id']} {inv['customer_name']:<25s} " + f"${amt:.2f} {days}d overdue {inv.get('customer_phone', '')}") + print(f"\n Total overdue: ${total:.2f}") + + print(f"{'='*60}\n") + + +def mark_paid(invoice_id): + """Mark an invoice as paid.""" + if not os.path.exists(INVOICE_LOG): + print(" No invoices found.") + return + + invoices = [] + found = False + + with open(INVOICE_LOG, "r") as f: + reader = csv.DictReader(f) + for inv in reader: + if inv["invoice_id"] == invoice_id: + inv["status"] = "PAID" + inv["date_paid"] = datetime.now().strftime("%Y-%m-%d") + found = True + print(f" Marked PAID: {invoice_id} — {inv['customer_name']} — ${inv['total']}") + invoices.append(inv) + + if not found: + print(f" Invoice not found: {invoice_id}") + return + + with open(INVOICE_LOG, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=INVOICE_FIELDS) + writer.writeheader() + writer.writerows(invoices) + + +def main(): + parser = argparse.ArgumentParser(description="NorCal Invoice Generator") + sub = parser.add_subparsers(dest="command") + + create_p = sub.add_parser("create", help="Create a new invoice") + create_p.add_argument("customer", help="Customer name") + create_p.add_argument("--obd", type=int, default=0, help="Number of OBD tests") + create_p.add_argument("--ovi", type=int, default=0, help="Number of OVI tests") + create_p.add_argument("--smoke", type=int, default=0, help="Number of smoke opacity tests") + create_p.add_argument("--fleet-discount", action="store_true", help="Apply fleet discount") + create_p.add_argument("--phone", default="", help="Customer phone") + create_p.add_argument("--email", default="", help="Customer email") + create_p.add_argument("--address", default="", help="Customer address") + create_p.add_argument("--notes", default="", help="Invoice notes") + + sub.add_parser("list", help="List all invoices") + sub.add_parser("overdue", help="Show overdue invoices") + + paid_p = sub.add_parser("paid", help="Mark invoice as paid") + paid_p.add_argument("invoice_id", help="Invoice ID (e.g., INV-20260317-001)") + + sub.add_parser("remind", help="Show who needs payment reminders") + + args = parser.parse_args() + + if args.command == "create": + create_invoice( + args.customer, obd=args.obd, ovi=args.ovi, smoke=args.smoke, + fleet_discount=args.fleet_discount, customer_phone=args.phone, + customer_email=args.email, customer_address=args.address, notes=args.notes, + ) + elif args.command == "list": + list_invoices() + elif args.command == "overdue": + show_overdue() + elif args.command == "paid": + mark_paid(args.invoice_id) + elif args.command == "remind": + show_overdue() # same view — overdue = needs reminder + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/norcal-toolkit/05_review_request.py b/norcal-toolkit/05_review_request.py new file mode 100644 index 0000000..ea86851 --- /dev/null +++ b/norcal-toolkit/05_review_request.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Review Request & Social Post Agent +Sends review requests after jobs and generates social media posts. + +Usage: + python3 05_review_request.py review "Valley Fleet" --phone "(916) 555-0101" + python3 05_review_request.py review "Delta Trucking" --email "joe@delta.com" + python3 05_review_request.py social --type job_done --city Sacramento --tests 4 + python3 05_review_request.py social --type tip --topic "OBD vs OVI" + python3 05_review_request.py blog --topic "CARB 2027 quarterly testing" + python3 05_review_request.py batch-review # review requests for today's tested jobs +""" +import argparse +import csv +import os +import sys +from datetime import datetime + +from config import ( + BUSINESS_NAME, BUSINESS_PHONE, BUSINESS_WEBSITE, OWNER_NAME, + GOOGLE_REVIEW_LINK, DATA_DIR, CRM_CSV, + SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, BUSINESS_EMAIL, +) + +# ─── Review Request Templates ──────────────────────────────────── + +REVIEW_SMS = """Hey {contact}! Thanks for choosing {business} today. If you have 30 seconds, a Google review helps us out a ton: + +{review_link} + +Appreciate you! — {owner}""" + +REVIEW_EMAIL_SUBJECT = "Thanks for choosing {business}!" + +REVIEW_EMAIL_BODY = """Hey {contact}, + +Thanks for letting us handle your testing today at {company}. We appreciate your business. + +If you have 30 seconds, a quick Google review would mean a lot to a small local operation like ours: + +{review_link} + +If there's anything we could have done better, just reply to this email — I read every one. + +Thanks again, +{owner} +{business} +{phone}""" + + +# ─── Social Media Post Templates ───────────────────────────────── + +SOCIAL_TEMPLATES = { + "job_done": [ + "{tests} vehicles tested on-site today in {city}. No trip to the shop needed. That's the mobile advantage. #NorCalCarbMobile #MobileSmogTesting #CARB", + "Another {tests}-vehicle day in {city}. Fleet tested, results delivered, zero downtime. #CleanTruckCheck #FleetCompliance #Sacramento", + "On-site in {city} today — {tests} trucks tested and done before lunch. Your fleet doesn't stop, we come to you. #MobileTesting #CARB #NorCal", + ], + "tip": [ + "Did you know? CARB requires all diesel vehicles over 14,000 lbs to be tested 2x/year. That doubles to 4x/year in October 2027. Is your fleet ready? #CARB #CleanTruckCheck", + "OBD vs OVI — what's the difference? OBD reads your truck's computer for emissions codes. OVI is a visual + opacity test for older vehicles. Both are required under HD I/M. #FleetTips", + "Non-compliance with CARB's Clean Truck Check can cost $10,000/DAY per vehicle + DMV registration hold. Don't wait for the letter. #CARBCompliance #FleetManagement", + "No exemptions under CARB's HD I/M program. Small fleets, low-use vehicles, even out-of-state trucks operating in CA — everyone's in. #CleanTruckCheck", + "$31.18/year per vehicle is the CARB annual fee. Compare that to $10K/day in fines. Compliance is the cheapest insurance you'll buy. #FleetCompliance", + ], + "behind_scenes": [ + "Loading up the mobile testing unit for another day in the field. {city} fleet owners, we're in your area this week. #MobileSmog #NorCal", + "Early morning calibration check before heading out. Every test has to be accurate — that's why we calibrate daily. #Quality #SmogTesting", + "Just wrapped a 12-vehicle fleet test. The owner said he used to lose half a day per truck driving to a station. No more. #MobileTesting", + ], + "milestone": [ + "100 vehicles tested and counting. Thank you to every fleet owner who trusts us with their compliance. #Milestone #NorCalCarbMobile", + "Started this year with a truck and a credential. Now serving fleets across Sacramento, Stockton, and the East Bay. Growth through service. #SmallBusiness", + ], +} + + +# ─── Blog Outline Templates ───────────────────────────────────── + +BLOG_TEMPLATES = { + "what_is_obd": { + "title": "What Is an OBD Test and When Does Your Truck Need One?", + "keyword": "OBD test truck California", + "outline": [ + "H1: What Is an OBD Test and When Does Your Truck Need One?", + "Intro: Direct answer — OBD = On-Board Diagnostics scan required under CARB HD I/M", + "H2: What Does an OBD Test Check?", + " - Reads DTCs (Diagnostic Trouble Codes) from vehicle's ECU", + " - Checks emissions readiness monitors", + " - Records VIN, odometer, test results", + "H2: Which Vehicles Need OBD Testing?", + " - All diesel/alt-fuel vehicles >14,000 lbs GVWR", + " - 2013+ model year (OBD-equipped)", + " - No exemptions: small fleet, low-use, out-of-state", + "H2: How Often?", + " - Currently: 2x per year", + " - October 2027: increases to 4x per year", + "H2: What If You Fail?", + " - Must repair and retest within 120 days", + " - Non-compliance: up to $10K/day + DMV hold", + "H2: Mobile OBD Testing — How It Works", + " - We come to your yard/lot", + " - Test takes 15-20 minutes per vehicle", + " - Results submitted to CARB same day", + "CTA: Schedule your fleet's OBD test today — [phone] / [website]", + ], + }, + "ovi_vs_obd": { + "title": "OVI vs OBD Testing: What's the Difference for Your Fleet?", + "keyword": "OVI vs OBD test difference", + "outline": [ + "H1: OVI vs OBD Testing: What's the Difference for Your Fleet?", + "Intro: Two test types under CARB's Clean Truck Check — here's which one your truck needs", + "H2: OBD Test (On-Board Diagnostics)", + " - For 2013+ model year vehicles with OBD systems", + " - Electronic scan of emissions computer", + " - Checks DTCs, readiness monitors", + "H2: OVI Test (Opacity / Visual Inspection)", + " - For pre-2013 vehicles without OBD capability", + " - Smoke opacity measurement (SAE J1667 snap-idle)", + " - Visual inspection of emissions controls", + "H2: Which Test Does Your Truck Need?", + " - Table: Model Year → Test Type", + " - 2013+: OBD | Pre-2013: OVI | Mixed fleet: both", + "H2: Testing Frequency", + " - OBD: 2x/year now, 4x/year Oct 2027", + " - OVI: 2x/year (unchanged)", + "H2: Costs and What to Expect", + " - Our pricing: $X OBD, $X OVI", + " - On-site mobile testing saves fleet downtime", + "CTA: Not sure which test your fleet needs? Call us — [phone]", + ], + }, + "carb_2027": { + "title": "CARB 2027 Quarterly Testing Mandate: What Fleet Owners Need to Know", + "keyword": "CARB 2027 quarterly testing requirement", + "outline": [ + "H1: CARB 2027 Quarterly Testing Mandate: What Fleet Owners Need to Know", + "Intro: Starting October 2027, OBD testing doubles from 2x to 4x per year", + "H2: What's Changing?", + " - OBD-equipped vehicles (2013+) go to quarterly testing", + " - That's 4 tests per vehicle per year", + " - Calendar-based scheduling (every 90 days)", + "H2: Why Is CARB Doing This?", + " - Catch emissions failures faster", + " - Align with federal EPA goals", + " - Data: X% of tested vehicles had unreported issues", + "H2: Impact on Your Fleet", + " - 10 trucks = 40 tests/year (up from 20)", + " - 50 trucks = 200 tests/year", + " - Scheduling headache if you're driving to a station", + "H2: How to Prepare NOW", + " - Build a testing schedule by VIN", + " - Partner with a mobile tester (eliminate downtime)", + " - Budget for 2x the testing costs", + "H2: Mobile Testing Is the Answer", + " - We come to you — test during load/unload", + " - Fleet scheduling: we track your deadlines", + " - Volume pricing for quarterly contracts", + "CTA: Lock in your 2027 testing schedule now — [phone] / [website]", + ], + }, +} + + +def send_review_request_email(contact, company, email): + """Send a review request email.""" + if not SMTP_USER or not SMTP_PASS: + print(f" [PREVIEW] Would email {email}:") + body = REVIEW_EMAIL_BODY.format( + contact=contact, company=company, business=BUSINESS_NAME, + review_link=GOOGLE_REVIEW_LINK, owner=OWNER_NAME, phone=BUSINESS_PHONE, + ) + print(f" Subject: {REVIEW_EMAIL_SUBJECT.format(business=BUSINESS_NAME)}") + for line in body.split("\n"): + print(f" {line}") + return + + import smtplib + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + + subject = REVIEW_EMAIL_SUBJECT.format(business=BUSINESS_NAME) + body = REVIEW_EMAIL_BODY.format( + contact=contact, company=company, business=BUSINESS_NAME, + review_link=GOOGLE_REVIEW_LINK, owner=OWNER_NAME, phone=BUSINESS_PHONE, + ) + + msg = MIMEMultipart() + msg["From"] = f"{OWNER_NAME} <{BUSINESS_EMAIL}>" + msg["To"] = email + msg["Subject"] = subject + msg.attach(MIMEText(body, "plain")) + + try: + with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as server: + server.starttls() + server.login(SMTP_USER, SMTP_PASS) + server.send_message(msg) + print(f" [SENT] Review request to {email}") + except Exception as e: + print(f" [ERROR] {e}") + + +def generate_sms(contact, company): + """Generate review request SMS text (for copy-paste or Twilio).""" + msg = REVIEW_SMS.format( + contact=contact, business=BUSINESS_NAME, + review_link=GOOGLE_REVIEW_LINK, owner=OWNER_NAME, + ) + print(f"\n SMS for {company} ({contact}):") + print(f" {'─'*40}") + for line in msg.split("\n"): + print(f" {line}") + print(f" {'─'*40}") + print(f" Characters: {len(msg)}\n") + + +def generate_social_post(post_type, city="Sacramento", tests=0, topic=""): + """Generate social media post.""" + import random + + templates = SOCIAL_TEMPLATES.get(post_type, SOCIAL_TEMPLATES["tip"]) + template = random.choice(templates) + + post = template.format( + city=city, tests=tests, topic=topic, + business=BUSINESS_NAME, owner=OWNER_NAME, + ) + + print(f"\n SOCIAL POST ({post_type}):") + print(f" {'─'*50}") + print(f" {post}") + print(f" {'─'*50}") + print(f" Characters: {len(post)}") + print(f" Platforms: Instagram, Facebook, LinkedIn, Google Business Profile") + print() + + +def generate_blog_outline(topic): + """Generate a blog post outline.""" + # Match topic to template + template = None + topic_lower = topic.lower() + for key, tmpl in BLOG_TEMPLATES.items(): + if key.replace("_", " ") in topic_lower or any( + word in topic_lower for word in key.split("_") + ): + template = tmpl + break + + if not template: + # Generic outline + print(f"\n BLOG OUTLINE: {topic}") + print(f" {'─'*50}") + print(f" H1: {topic}") + print(f" - Intro: Answer the question directly in first paragraph") + print(f" - H2: What it is / Why it matters") + print(f" - H2: How it works / What to expect") + print(f" - H2: Impact on your fleet (specific numbers)") + print(f" - H2: How we help (mobile testing advantage)") + print(f" - CTA: Call {BUSINESS_PHONE} or visit {BUSINESS_WEBSITE}") + print(f" Target: 800-1500 words | One primary keyword") + print(f" {'─'*50}\n") + return + + print(f"\n BLOG OUTLINE:") + print(f" {'─'*50}") + print(f" Title: {template['title']}") + print(f" Keyword: {template['keyword']}") + print(f" Target: 800-1500 words") + print() + for line in template["outline"]: + print(f" {line}") + print(f" {'─'*50}\n") + + +def batch_review_requests(): + """Send review requests to all jobs tested today.""" + if not os.path.exists(CRM_CSV): + print(" No CRM data. Run 03_crm_tracker.py import first.") + return + + today = datetime.now().strftime("%Y-%m-%d") + count = 0 + + with open(CRM_CSV, "r") as f: + reader = csv.DictReader(f) + for row in reader: + if row.get("status") == "TESTED" and row.get("updated_date") == today: + contact = row.get("contact_name") or row.get("business_name", "there").split()[0] + company = row.get("business_name", "") + + if row.get("email"): + send_review_request_email(contact, company, row["email"]) + count += 1 + + if row.get("phone"): + generate_sms(contact, company) + count += 1 + + if count == 0: + print(" No jobs marked TESTED today. Update CRM status first.") + else: + print(f"\n Sent {count} review requests for today's jobs.") + + +def main(): + parser = argparse.ArgumentParser(description="NorCal Review & Social Agent") + sub = parser.add_subparsers(dest="command") + + rev_p = sub.add_parser("review", help="Send review request") + rev_p.add_argument("company", help="Company name") + rev_p.add_argument("--phone", default="", help="Phone for SMS") + rev_p.add_argument("--email", default="", help="Email for review request") + rev_p.add_argument("--contact", default="", help="Contact person name") + + social_p = sub.add_parser("social", help="Generate social media post") + social_p.add_argument("--type", choices=["job_done", "tip", "behind_scenes", "milestone"], + default="job_done", help="Post type") + social_p.add_argument("--city", default="Sacramento", help="City name") + social_p.add_argument("--tests", type=int, default=4, help="Number of tests") + social_p.add_argument("--topic", default="", help="Topic for tip posts") + + blog_p = sub.add_parser("blog", help="Generate blog post outline") + blog_p.add_argument("--topic", required=True, help="Blog topic") + + sub.add_parser("batch-review", help="Review requests for today's tested jobs") + + args = parser.parse_args() + + if args.command == "review": + contact = args.contact or args.company.split()[0] + if args.email: + send_review_request_email(contact, args.company, args.email) + if args.phone: + generate_sms(contact, args.company) + if not args.email and not args.phone: + print(" Provide --email and/or --phone") + elif args.command == "social": + generate_social_post(args.type, city=args.city, tests=args.tests, topic=args.topic) + elif args.command == "blog": + generate_blog_outline(args.topic) + elif args.command == "batch-review": + batch_review_requests() + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/norcal-toolkit/config.py b/norcal-toolkit/config.py new file mode 100644 index 0000000..6d748d3 --- /dev/null +++ b/norcal-toolkit/config.py @@ -0,0 +1,96 @@ +""" +NorCal Carb Mobile Toolkit — Central Configuration +All API keys, business info, and defaults in one place. +Set via environment variables or edit defaults below. +""" +import os + +# ─── Business Info ─────────────────────────────────────────────── +BUSINESS_NAME = os.getenv("NORCAL_BUSINESS_NAME", "NorCal Carb Mobile") +BUSINESS_PHONE = os.getenv("NORCAL_PHONE", "(916) 555-0199") +BUSINESS_EMAIL = os.getenv("NORCAL_EMAIL", "bryan@norcalcarbmobile.com") +BUSINESS_WEBSITE = os.getenv("NORCAL_WEBSITE", "https://norcalcarbmobile.com") +BUSINESS_ADDRESS = os.getenv("NORCAL_ADDRESS", "Sacramento, CA") +BAR_LICENSE = os.getenv("NORCAL_BAR_LICENSE", "BAR-XXXXXX") +OWNER_NAME = os.getenv("NORCAL_OWNER", "Bryan") + +# ─── Google Places API ─────────────────────────────────────────── +GOOGLE_PLACES_API_KEY = os.getenv("GOOGLE_PLACES_API_KEY", "") + +# ─── Service Area (lat/lng centers for scraping radius) ────────── +SERVICE_AREAS = { + "sacramento": {"lat": 38.5816, "lng": -121.4944}, + "stockton": {"lat": 37.9577, "lng": -121.2908}, + "roseville": {"lat": 38.7521, "lng": -121.2880}, + "elk_grove": {"lat": 38.4088, "lng": -121.3716}, + "modesto": {"lat": 37.6391, "lng": -120.9969}, + "east_bay": {"lat": 37.8044, "lng": -122.2712}, + "vallejo": {"lat": 38.1041, "lng": -122.2566}, +} + +# ─── Scraper Defaults ──────────────────────────────────────────── +DEFAULT_SEARCH_RADIUS_METERS = 30000 # 30km +SCRAPE_QUERIES = [ + "trucking company", + "fleet management", + "diesel repair shop", + "construction company", + "auto dealer", + "tow yard", + "body shop", + "landscaping company", + "school bus service", + "freight broker", + "agricultural hauler", + "logistics company", + "property management", + "waste management", + "delivery service", +] + +# ─── Lead Scoring Weights ──────────────────────────────────────── +SCORING = { + "high_review_count": 2, # 10+ reviews = established + "in_service_area": 3, # within our service radius + "has_website": 1, # professional operation + "fleet_keywords": 3, # mentions fleet/trucks/vehicles + "compliance_keywords": 2, # mentions CARB/smog/emissions + "recently_opened": 1, # new business, needs services + "multiple_locations": 2, # bigger operation + "phone_available": 1, # reachable +} + +# ─── Pricing ───────────────────────────────────────────────────── +PRICING = { + "obd_test": 75.00, + "ovi_test": 85.00, + "smoke_opacity_test": 85.00, + "fleet_discount_threshold": 5, # 5+ vehicles = discount + "fleet_discount_pct": 10, # 10% off +} + +# ─── Email / SMTP ──────────────────────────────────────────────── +SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com") +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +SMTP_USER = os.getenv("SMTP_USER", "") +SMTP_PASS = os.getenv("SMTP_PASS", "") +COLD_EMAIL_FROM = os.getenv("COLD_EMAIL_FROM", "") # secondary domain for cold + +# ─── Google Review Link ────────────────────────────────────────── +GOOGLE_REVIEW_LINK = os.getenv( + "GOOGLE_REVIEW_LINK", + "https://g.page/r/YOUR_PLACE_ID/review" +) + +# ─── File Paths ────────────────────────────────────────────────── +DATA_DIR = os.path.join(os.path.dirname(__file__), "data") +LEADS_CSV = os.path.join(DATA_DIR, "leads.csv") +CRM_CSV = os.path.join(DATA_DIR, "crm.csv") +INVOICES_DIR = os.path.join(DATA_DIR, "invoices") +EMAILS_LOG = os.path.join(DATA_DIR, "emails_sent.csv") + +# ─── Follow-Up Cadence (days after initial contact) ────────────── +FOLLOWUP_SCHEDULE = [1, 3, 7, 14, 30] + +# ─── Invoice Terms ─────────────────────────────────────────────── +INVOICE_TERMS_DAYS = 15 # Net 15 diff --git a/norcal-toolkit/daily_workflow.py b/norcal-toolkit/daily_workflow.py new file mode 100644 index 0000000..b24ee92 --- /dev/null +++ b/norcal-toolkit/daily_workflow.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Daily Workflow Runner +Run this every morning. It executes the full daily cycle: + + python3 daily_workflow.py # full daily run + python3 daily_workflow.py --step 3 # run specific step only + python3 daily_workflow.py --dry-run # preview everything, change nothing + +THE DAILY LOOP: + 1. Scrape new leads (weekly on Monday, skip other days) + 2. Import new leads into CRM + 3. Show today's follow-ups (who to call/email) + 4. Send cold email sequences (Tier 1 first) + 5. Pipeline status check + 6. Generate a social media post + 7. Check overdue invoices + 8. Batch review requests for yesterday's tested jobs +""" +import os +import sys +import subprocess +from datetime import datetime + +TOOLKIT_DIR = os.path.dirname(os.path.abspath(__file__)) + + +def run_step(script, args=None, description=""): + """Run a toolkit script as a subprocess.""" + cmd = [sys.executable, os.path.join(TOOLKIT_DIR, script)] + if args: + cmd.extend(args) + + print(f"\n{'='*60}") + print(f" STEP: {description}") + print(f" CMD: {' '.join(cmd)}") + print(f"{'='*60}\n") + + result = subprocess.run(cmd, cwd=TOOLKIT_DIR, capture_output=False) + return result.returncode == 0 + + +def daily_workflow(step=None, dry_run=False): + """Execute the daily workflow.""" + today = datetime.now() + day_name = today.strftime("%A") + date_str = today.strftime("%Y-%m-%d") + + print(f"\n{'#'*60}") + print(f" NORCAL CARB MOBILE — DAILY WORKFLOW") + print(f" {day_name}, {date_str}") + print(f"{'#'*60}") + + steps = { + 1: { + "desc": "Scrape New Leads (Monday only)", + "script": "01_lead_scraper.py", + "args": [], + "condition": day_name == "Monday", + "skip_msg": "Lead scraping runs on Mondays. Skipping.", + }, + 2: { + "desc": "Import Leads to CRM", + "script": "03_crm_tracker.py", + "args": ["import"], + "condition": True, + }, + 3: { + "desc": "Today's Follow-Ups (WHO TO CALL)", + "script": "03_crm_tracker.py", + "args": ["due"], + "condition": True, + }, + 4: { + "desc": "Cold Email — Tier 1 Sequence #1", + "script": "02_cold_emailer.py", + "args": ["--preview", "--tier", "1", "--sequence", "1"] if dry_run + else ["--send", "--tier", "1", "--sequence", "1", "--dry-run"], + "condition": True, + }, + 5: { + "desc": "Pipeline Status", + "script": "03_crm_tracker.py", + "args": ["status"], + "condition": True, + }, + 6: { + "desc": "Generate Social Media Post", + "script": "05_review_request.py", + "args": ["social", "--type", "tip"], + "condition": True, + }, + 7: { + "desc": "Check Overdue Invoices", + "script": "04_invoice_generator.py", + "args": ["overdue"], + "condition": True, + }, + 8: { + "desc": "Batch Review Requests", + "script": "05_review_request.py", + "args": ["batch-review"], + "condition": True, + }, + } + + for num, s in steps.items(): + if step and num != step: + continue + + if not s.get("condition", True): + print(f"\n [SKIP] Step {num}: {s.get('skip_msg', 'Condition not met')}") + continue + + run_step(s["script"], s["args"], f"Step {num}: {s['desc']}") + + print(f"\n{'#'*60}") + print(f" DAILY WORKFLOW COMPLETE — {datetime.now().strftime('%H:%M')}") + print(f" Next: Make your calls. Close your deals.") + print(f"{'#'*60}\n") + + +def main(): + import argparse + parser = argparse.ArgumentParser(description="NorCal Daily Workflow") + parser.add_argument("--step", type=int, help="Run specific step only (1-8)") + parser.add_argument("--dry-run", action="store_true", help="Preview mode, no changes") + args = parser.parse_args() + + daily_workflow(step=args.step, dry_run=args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/norcal-toolkit/job_workflow.py b/norcal-toolkit/job_workflow.py new file mode 100644 index 0000000..3d9ec09 --- /dev/null +++ b/norcal-toolkit/job_workflow.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +NorCal Carb Mobile — Job Workflow (Call-to-Cash) +The exact workflow for when a call comes in and you book a job. + +Usage: + python3 job_workflow.py # interactive walkthrough + python3 job_workflow.py --quick "Valley Fleet" --obd 3 --ovi 1 + +This is the scenario from the howto: +"3 OBD tests and 1 OVI test today at 3pm" +""" +import argparse +import os +import sys +from datetime import datetime + +TOOLKIT_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, TOOLKIT_DIR) + +from config import PRICING, BUSINESS_PHONE, BUSINESS_NAME, OWNER_NAME + + +def calculate_quote(obd=0, ovi=0, smoke=0, fleet_discount=False): + """Quick price calculator.""" + total_vehicles = obd + ovi + smoke + subtotal = (obd * PRICING["obd_test"] + + ovi * PRICING["ovi_test"] + + smoke * PRICING["smoke_opacity_test"]) + + discount = 0 + if fleet_discount or total_vehicles >= PRICING["fleet_discount_threshold"]: + discount = subtotal * (PRICING["fleet_discount_pct"] / 100) + + return subtotal, discount, subtotal - discount + + +def interactive_workflow(): + """Walk through the full job workflow interactively.""" + print(f"\n{'='*60}") + print(f" NORCAL CARB MOBILE — JOB WORKFLOW") + print(f" Call-to-Cash Checklist") + print(f"{'='*60}") + + # Step 1: Capture + print(f"\n STEP 1: CAPTURE THE CALL") + print(f" {'─'*40}") + company = input(" Business name: ").strip() or "Walk-in Customer" + contact = input(" Contact person: ").strip() or company.split()[0] + phone = input(" Phone: ").strip() + email = input(" Email: ").strip() + address = input(" Service address: ").strip() + + print(f"\n STEP 2: SCOPE THE JOB") + print(f" {'─'*40}") + obd = int(input(" Number of OBD tests: ").strip() or "0") + ovi = int(input(" Number of OVI tests: ").strip() or "0") + smoke = int(input(" Number of Smoke Opacity tests: ").strip() or "0") + appt_time = input(" Appointment time (e.g., 3pm today): ").strip() or "TBD" + + # Calculate + subtotal, discount, total = calculate_quote(obd, ovi, smoke) + total_vehicles = obd + ovi + smoke + + print(f"\n STEP 3: CONFIRM QUOTE") + print(f" {'─'*40}") + print(f" Customer: {company} ({contact})") + print(f" Address: {address}") + print(f" Time: {appt_time}") + print(f" Services:") + if obd: print(f" {obd}x OBD Test @ ${PRICING['obd_test']:.0f} = ${obd * PRICING['obd_test']:.2f}") + if ovi: print(f" {ovi}x OVI Test @ ${PRICING['ovi_test']:.0f} = ${ovi * PRICING['ovi_test']:.2f}") + if smoke: print(f" {smoke}x Smoke @ ${PRICING['smoke_opacity_test']:.0f} = ${smoke * PRICING['smoke_opacity_test']:.2f}") + if discount > 0: + print(f" Fleet discount ({PRICING['fleet_discount_pct']}%): -${discount:.2f}") + print(f" ───────────────────────────") + print(f" TOTAL: ${total:.2f}") + + confirm = input(f"\n Send confirmation text/email? (y/n): ").strip().lower() + if confirm == "y": + confirmation = ( + f"Confirmed: {company}\n" + f"{obd + ovi + smoke} vehicle tests at {appt_time}\n" + f"Address: {address}\n" + f"Total: ${total:.2f}\n" + f"— {OWNER_NAME}, {BUSINESS_NAME} {BUSINESS_PHONE}" + ) + print(f"\n CONFIRMATION MESSAGE (copy/paste to text or email):") + print(f" {'─'*40}") + for line in confirmation.split("\n"): + print(f" {line}") + print(f" {'─'*40}") + + # Step 4: Add to CRM + print(f"\n STEP 4: ADD TO CRM") + print(f" {'─'*40}") + add_crm = input(" Add to CRM? (y/n): ").strip().lower() + if add_crm == "y": + from subprocess import run + run([sys.executable, os.path.join(TOOLKIT_DIR, "03_crm_tracker.py"), + "add", company, phone, email, f"Scheduled: {appt_time} | {obd} OBD, {ovi} OVI"], + cwd=TOOLKIT_DIR) + # Update status to SCHEDULED + run([sys.executable, os.path.join(TOOLKIT_DIR, "03_crm_tracker.py"), + "update", company, "--status", "SCHEDULED", + "--contact", contact, "--email", email, + "--note", f"Booked {total_vehicles} tests at {appt_time}"], + cwd=TOOLKIT_DIR) + + # Step 5: After testing + print(f"\n STEP 5: AFTER TESTING (run these after the job)") + print(f" {'─'*40}") + print(f" a) Update CRM status:") + print(f" python3 03_crm_tracker.py update \"{company}\" --status TESTED") + print(f"") + print(f" b) Generate invoice:") + print(f" python3 04_invoice_generator.py create \"{company}\" --obd {obd} --ovi {ovi}" + + (f" --smoke {smoke}" if smoke else "") + + (f" --fleet-discount" if total_vehicles >= PRICING["fleet_discount_threshold"] else "") + + f" --phone \"{phone}\" --email \"{email}\" --address \"{address}\"") + print(f"") + print(f" c) Send review request:") + print(f" python3 05_review_request.py review \"{company}\" --phone \"{phone}\"" + + (f" --email \"{email}\"" if email else "")) + print(f"") + print(f" d) Post to social:") + city = address.split(",")[0].strip() if "," in address else "your area" + print(f" python3 05_review_request.py social --type job_done --city \"{city}\" --tests {total_vehicles}") + print(f"") + print(f" e) Update CRM to invoiced:") + print(f" python3 03_crm_tracker.py update \"{company}\" --status INVOICED") + + print(f"\n{'='*60}") + print(f" JOB BOOKED. Go make money.") + print(f"{'='*60}\n") + + +def quick_workflow(company, obd=0, ovi=0, smoke=0, phone="", email="", address=""): + """Non-interactive quick booking.""" + subtotal, discount, total = calculate_quote(obd, ovi, smoke) + total_vehicles = obd + ovi + smoke + + print(f"\n QUICK BOOKING: {company}") + print(f" {'─'*40}") + print(f" {obd} OBD + {ovi} OVI + {smoke} Smoke = ${total:.2f}") + + if total_vehicles >= PRICING["fleet_discount_threshold"]: + print(f" Fleet discount applied: -${discount:.2f}") + + # Add to CRM + from subprocess import run + run([sys.executable, os.path.join(TOOLKIT_DIR, "03_crm_tracker.py"), + "add", company, phone, email, f"{obd} OBD, {ovi} OVI, {smoke} Smoke"], + cwd=TOOLKIT_DIR, capture_output=True) + run([sys.executable, os.path.join(TOOLKIT_DIR, "03_crm_tracker.py"), + "update", company, "--status", "SCHEDULED"], + cwd=TOOLKIT_DIR, capture_output=True) + + print(f" Added to CRM as SCHEDULED") + print(f"\n After job, run:") + print(f" python3 04_invoice_generator.py create \"{company}\" --obd {obd} --ovi {ovi}" + + (f" --smoke {smoke}" if smoke else "")) + print() + + +def main(): + parser = argparse.ArgumentParser(description="NorCal Job Workflow") + parser.add_argument("--quick", metavar="COMPANY", help="Quick booking (non-interactive)") + parser.add_argument("--obd", type=int, default=0) + parser.add_argument("--ovi", type=int, default=0) + parser.add_argument("--smoke", type=int, default=0) + parser.add_argument("--phone", default="") + parser.add_argument("--email", default="") + parser.add_argument("--address", default="") + args = parser.parse_args() + + if args.quick: + quick_workflow(args.quick, obd=args.obd, ovi=args.ovi, smoke=args.smoke, + phone=args.phone, email=args.email, address=args.address) + else: + interactive_workflow() + + +if __name__ == "__main__": + main() diff --git a/workers/cleantruckcheckstockton/worker.js b/workers/cleantruckcheckstockton/worker.js new file mode 100644 index 0000000..942b746 --- /dev/null +++ b/workers/cleantruckcheckstockton/worker.js @@ -0,0 +1,1311 @@ +// worker.js - Clean Truck Check Stockton +var worker_default = { + async fetch(request) { + const html = ` + + + + + + + + + + + + + + + + + Mobile CARB Testing Stockton CA | Clean Truck Check - HD-OBD & Opacity + + + + + +
+ +
+ + +
+
+
+

+ Mobile CARB Testing in Stockton, CA +

+

+ Licensed emissions testing comes to you. No downtime. No waiting at a shop. + HD-OBD, smoke/opacity, and fleet testing for the Central Valley and San Joaquin. +

+ +
+ ⚠️ Biannual testing is NOW REQUIRED in 2026. Don't wait for a citation. Schedule today. +
+ + + +
+
+
+
4.9★ / 47+ Reviews
+
+
+
+
Licensed CARB Tester
IF530523
+
+
+
🏁
+
Mobile Service
We Come To You
+
+
+
+
+
+ + +
+
+

Our Services

+

+ Professional CARB emissions testing for trucks and fleets. Licensed and certified for Central Valley operations. +

+ +
+
+
🔧
+

HD-OBD Testing

+

Heavy-duty on-board diagnostic testing for trucks 2013+. Complete compliance certification.

+
$75
+ Schedule Now → +
+ +
+
💨
+

Smoke/Opacity Testing

+

Professional smoke and opacity testing. Quick turnaround with official CARB documentation.

+
$199
+ Schedule Now → +
+ +
+
🚛
+

Fleet Opacity Testing

+

Multi-vehicle fleet testing with volume discounts. Mobile service reduces operational downtime.

+
$149+
+ Schedule Now → +
+ +
+
🏠
+

RV/Motorhome Testing

+

Full-service CARB testing for RVs and motorhomes. We handle all paperwork and compliance.

+
$300
+ Schedule Now → +
+
+ +
+

Need a Quote?

+

+ Get instant pricing and schedule your test. Our mobile service covers Stockton, Lodi, Tracy, + Manteca, and throughout the San Joaquin Valley. +

+ Call for Pricing +
+
+
+ + +
+
+

Why Choose Mobile CARB Testing?

+

+ No more taking your truck out of service. We bring the equipment to you. +

+ +
+
+
⏱️
+
+

Zero Downtime

+

We test at your location. No driving to a shop, no waiting in a queue. Keep operating.

+
+
+ +
+
💰
+
+

Cost Effective

+

Avoid fuel costs and operational delays. Competitive pricing for single vehicles and fleets.

+
+
+ +
+
+
+

Licensed & Certified

+

CARB Tester ID IF530523. All testing meets 2026 biannual compliance requirements.

+
+
+ +
+
📋
+
+

Instant Documentation

+

Receive official test results immediately. Digital and printed certificates provided.

+
+
+ +
+
🗺️
+
+

Central Valley Wide Coverage

+

Stockton, Lodi, Tracy, Manteca, Modesto, and throughout the San Joaquin Valley corridor.

+
+
+ +
+
+
+

Expert Service

+

Years of experience. Fast, professional, and reliable. 4.9★ rated by Central Valley customers.

+
+
+
+
+
+ + +
+
+

CARB Testing for Stockton & the San Joaquin Valley

+ +
+
+

Serving Stockton and Surrounding Areas

+

+ NorCal CARB Mobile LLC provides professional emissions testing throughout the Central Valley, + including Stockton, Lodi, Tracy, and Manteca. With the Port of Stockton and + major I-5 logistics hubs in our region, compliance testing is essential for fleet operations. +

+ +
    +
  • Stockton (Central Service Hub)
  • +
  • Lodi & Galt
  • +
  • Tracy & Manteca
  • +
  • I-5 Corridor Coverage
  • +
  • Port of Stockton Area
  • +
  • San Joaquin Industrial Districts
  • +
  • Modesto & Turlock
  • +
  • Ripon & Escalon
  • +
+ +
+ 2026 CARB Compliance: California's biannual testing requirement is in effect. + All heavy-duty trucks registered in California must undergo testing. Schedule now to avoid + penalties and vehicle downtime. +
+
+ +
+
+

Stockton Service Info

+ +
+

Primary Contact

+ + 916-890-4427 + +
+ +
+

CARB Tester ID

+

IF530523

+
+ +
+

Service Hours

+

Monday - Friday: 6am - 5pm
Saturday: 8am - 4pm

+
+ +
+

Primary Website

+ + norcalcarbmobile.com + +
+
+
+
+
+
+ + +
+
+

Frequently Asked Questions

+

+ Everything you need to know about CARB testing in Stockton. +

+ +
+
+
+

What is CARB testing and why do I need it?

+ + +
+
+

+ CARB (California Air Resources Board) testing measures heavy-duty truck emissions to ensure compliance with state air quality standards. + As of 2026, all trucks registered in California must undergo biannual (every 2 years) HD-OBD testing or smoke/opacity testing. + This is a legal requirement to operate in California and avoid citations. +

+
+
+ +
+
+

How long does CARB testing take?

+ + +
+
+

+ HD-OBD testing typically takes 30-45 minutes per vehicle. Smoke/opacity testing takes 15-30 minutes. + Since our testing is mobile, we come to your location, eliminating travel time and downtime. + You can keep your truck operational throughout the process. +

+
+
+ +
+
+

Do you test at our facility or do we need to go somewhere?

+ + +
+
+

+ We come to you. Our mobile testing service means we bring all equipment to your location in Stockton, + your fleet yard, or anywhere else in the San Joaquin Valley. There's no need to take your truck out of service + or drive to a testing center. This is one of our biggest advantages for busy fleets. +

+
+
+ +
+
+

What's the difference between HD-OBD and smoke/opacity testing?

+ + +
+
+

+ HD-OBD (Heavy-Duty On-Board Diagnostic) testing checks the truck's onboard computer systems for emissions compliance. + It's required for trucks 2013 and newer. Smoke/opacity testing measures visible emissions during acceleration. + Many trucks require both or can choose either. We'll help you determine which your vehicle needs. +

+
+
+ +
+
+

How much does CARB testing cost?

+ + +
+
+

+ Pricing varies by service: HD-OBD testing is $75 per vehicle, smoke/opacity testing is $199, + RV/motorhome testing is $300, and fleet opacity testing starts at $149+ with volume discounts. + Call us at 916-890-4427 for a specific quote based on your vehicles and needs. +

+
+
+ +
+
+

Are you licensed and certified?

+ + +
+
+

+ Yes. We are a licensed CARB tester with ID IF530523. Our team is certified to perform all required emissions + testing under California Air Resources Board regulations. All test results are official and valid for registration + and compliance purposes throughout California. +

+
+
+ +
+
+

How do I schedule a test?

+ + +
+
+

+ Call us at 916-890-4427 to schedule. We serve Stockton, Lodi, Tracy, Manteca, and throughout the San Joaquin Valley. + We offer flexible scheduling with early morning and weekend availability. You can also visit norcalcarbmobile.com/contact + to request service online and we'll follow up with you. +

+
+
+ +
+
+

What happens if my truck fails the test?

+ + +
+
+

+ If your truck doesn't pass, you'll receive documentation of the failure. You'll need to address the emissions issue + (often through repairs or maintenance) and then retest. We can discuss options with you and help schedule a retest + after repairs. Many failures are due to maintenance items that are relatively inexpensive to fix. +

+
+
+ +
+
+

Is your service available on weekends?

+ + +
+
+

+ Yes, we offer Saturday testing. Service hours are Monday-Friday 6am-5pm and Saturday 8am-4pm. + Call 916-890-4427 to check availability and schedule a weekend test time that works for your fleet. +

+
+
+
+
+
+ + +
+
+
+

Ready to Get Compliant?

+

+ Schedule your CARB test today. Mobile service in Stockton and the San Joaquin Valley. + Quick, professional, licensed testing. +

+
+ Call: 916-890-4427 + Schedule Online +
+
+
+
+ + + + + + + + + + + + diff --git a/workers/norcalcarbmobile/squarespace-export.xml b/workers/norcalcarbmobile/squarespace-export.xml new file mode 100644 index 0000000..690ec7b --- /dev/null +++ b/workers/norcalcarbmobile/squarespace-export.xml @@ -0,0 +1,11 @@ +squarespace API Key Generated +Keep your API key somewhere safe. For your security, this is the only time we’ll show your key. + +Key Name +Admin + +Permissions +Forms (Read Only), Inventory (Read and Write), Orders (Read and Write), Products (Read and Write), Profiles (Read Only), Transactions (Read Only) + +API Key +51b09d71-3c5e-4d51-bb56-056f34a4d197 diff --git a/workers/security-silverbackai/worker.js b/workers/security-silverbackai/worker.js new file mode 100644 index 0000000..a8a9fdc --- /dev/null +++ b/workers/security-silverbackai/worker.js @@ -0,0 +1,974 @@ +// worker.js - Silverback AI Security Dashboard +var worker_default = { + async fetch(request) { + const url = new URL(request.url); + + // Health check endpoint + if (url.pathname === '/api/health') { + return new Response(JSON.stringify({ status: 'operational', timestamp: new Date().toISOString() }), { + headers: { 'Content-Type': 'application/json' } + }); + } + + // Status endpoint + if (url.pathname === '/api/status') { + return new Response(JSON.stringify({ + systems: { + ai_engine: 'operational', + monitoring: 'operational', + alerts: 'operational', + weirdness_detection: 'operational' + }, + uptime: '99.97%', + last_check: new Date().toISOString() + }), { + headers: { 'Content-Type': 'application/json' } + }); + } + + const html = ` + + + + + + + + + + + + Silverback AI — Security Systems + + + + +
+
+
+
+
+ + + +
+
+

SILVERBACK AI

+ Security Systems +
+
+
+
+ All Systems Operational + Online +
+
+
+
+ +
+
+
+
+ + MONITORING ACTIVE +
+

+ AI-Powered Property Security +

+

+ Advanced threat detection and real-time monitoring for 3875 Ruby St, Oakland. Privacy-first AI security compliant with Oakland municipal law. +

+
+
+ +
+
+
+
System Uptime
+
99.97%
+
Last 30 days
+
+
+
Threats Blocked
+
1,247
+
This month
+
+
+
Active Monitors
+
8
+
Cameras + sensors
+
+
+
Avg Response
+
0.3s
+
Alert to detection
+
+
+
+ +
+
+
+

System Status

+ Updated just now +
+
+
+
+
🧠
+
+
AI Analysis Engine
+
Gemini-powered threat assessment
+
+
+ Operational +
+
+
+
📸
+
+
Video Monitoring
+
RTSP camera feeds active
+
+
+ Operational +
+
+
+
🚨
+
+
Real-Time Alerts
+
WebSocket push notifications
+
+
+ Operational +
+
+
+
🔎
+
+
Weirdness Detection
+
Anomaly pattern analysis
+
+
+ Operational +
+
+
+
📈
+
+
Event Logging
+
Firebase Firestore real-time DB
+
+
+ Operational +
+
+
+
🔐
+
+
Access Control
+
Role-based authentication
+
+
+ Operational +
+
+
+
+ +
+
+
+

Security Capabilities

+
+
+
+
🧠
+

AI Threat Detection

+

Powered by Google Gemini, our AI engine analyzes camera feeds and sensor data in real-time to detect threats including break-ins, theft, and suspicious activity before they escalate.

+ gemini-powered +
+
+
👻
+

Weirdness Algorithm

+

Proprietary anomaly detection that learns what "normal" looks like for your property. Configurable motion thresholds, lingering detection, and time-of-day awareness flag unusual patterns others miss.

+ anomaly-detection +
+
+
+

Real-Time Alerts

+

WebSocket-powered instant notifications for high and critical severity events. Email alerts for configured recipients with full event context and AI-generated analysis summaries.

+ websocket-push +
+
+
👁
+

Privacy-First Monitoring

+

Fully compliant with Oakland municipal privacy laws. Tenant tracking uses numeric IDs only, faces are blurred in all recordings, and data retention follows strict local regulations.

+ oakland-compliant +
+
+
+
+ +
+
+
+

Protected Property

+
+
+
+

Ruby Street Apartments

+
3875 Ruby St, Oakland, CA
+
+ Type + Vintage 24-Unit Apartment +
+
+ Cameras + 8 RTSP Feeds +
+
+ AI Analysis + 24/7 Active +
+
+ Event Types + Theft, Break-in, Sublease, Activity +
+
+ Reporting + Daily / Weekly / Monthly +
+
+
+
+ + Oakland Privacy Law Compliant +
+
+ + Tenant Privacy Protected (ID Only) +
+
+ + Face Blurring Active +
+
+ + Role-Based Access Control +
+
+
+
+
+ +
+
+
+

Recent Activity

+
+
+
+ event_logs / security_events +
+
+ Live +
+
+
+
+
+
+
+
+ + + + + +`; + + return new Response(html, { + headers: { + 'Content-Type': 'text/html;charset=UTF-8', + 'Cache-Control': 'public, max-age=300', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'strict-origin-when-cross-origin' + } + }); + } +}; + +export default worker_default; diff --git a/workers/security-silverbackai/wrangler.toml b/workers/security-silverbackai/wrangler.toml new file mode 100644 index 0000000..61aa5b1 --- /dev/null +++ b/workers/security-silverbackai/wrangler.toml @@ -0,0 +1,11 @@ +name = "security-silverbackai" +main = "worker.js" +compatibility_date = "2026-03-01" +account_id = "bafa242dd95d3fdce72540d20accd0a2" + +routes = [ + { pattern = "security.silverbackai.agency/*", zone_name = "silverbackai.agency" } +] + +[env.production] +name = "security-silverbackai" diff --git a/workers/silverback-ai-studio/.env.example b/workers/silverback-ai-studio/.env.example new file mode 100644 index 0000000..7a550fe --- /dev/null +++ b/workers/silverback-ai-studio/.env.example @@ -0,0 +1,9 @@ +# GEMINI_API_KEY: Required for Gemini AI API calls. +# AI Studio automatically injects this at runtime from user secrets. +# Users configure this via the Secrets panel in the AI Studio UI. +GEMINI_API_KEY="MY_GEMINI_API_KEY" + +# APP_URL: The URL where this applet is hosted. +# AI Studio automatically injects this at runtime with the Cloud Run service URL. +# Used for self-referential links, OAuth callbacks, and API endpoints. +APP_URL="MY_APP_URL" diff --git a/workers/silverback-ai-studio/README.md b/workers/silverback-ai-studio/README.md new file mode 100644 index 0000000..d02447b --- /dev/null +++ b/workers/silverback-ai-studio/README.md @@ -0,0 +1,30 @@ +# Silverback AI - Security Systems (AI Studio App) + +Comprehensive React-based security monitoring dashboard for AI-powered property surveillance at 3875 Ruby St, Oakland. + +View in AI Studio: https://ai.studio/apps/d3deeaf9-500c-40a5-a8f4-39a756d120af + +## Run Locally + +**Prerequisites:** Node.js + +1. Install dependencies: `npm install` +2. Set the `GEMINI_API_KEY` in `.env.local` to your Gemini API key +3. Run the app: `npm run dev` + +## Features +- Real-time WebSocket alert notifications with audio +- Firebase-integrated event logging and report generation +- AI-powered event analysis (Google Gemini) +- Weirdness detection algorithm +- CSV report export +- Branding asset generation via AI image synthesis +- Google Sign-In authentication +- Privacy-first architecture (Oakland law compliant) + +## Tech Stack +- React 19, Tailwind CSS 4, Framer Motion +- Firebase (Auth, Firestore) +- Google GenAI API (Gemini) +- Express + WebSocket server +- Vite build system diff --git a/workers/silverback-ai-studio/firebase-applet-config.json b/workers/silverback-ai-studio/firebase-applet-config.json new file mode 100644 index 0000000..b232b67 --- /dev/null +++ b/workers/silverback-ai-studio/firebase-applet-config.json @@ -0,0 +1,10 @@ +{ + "projectId": "custom-invoice-490714", + "appId": "1:595536838872:web:0a680a6d94249738e157ad", + "apiKey": "AIzaSyDJLSbEl1-VRGvCc5-8nE2f6w3HHWnwzYM", + "authDomain": "custom-invoice-490714.firebaseapp.com", + "firestoreDatabaseId": "ai-studio-d3deeaf9-500c-40a5-a8f4-39a756d120af", + "storageBucket": "custom-invoice-490714.firebasestorage.app", + "messagingSenderId": "595536838872", + "measurementId": "" +} diff --git a/workers/silverback-ai-studio/firebase-blueprint.json b/workers/silverback-ai-studio/firebase-blueprint.json new file mode 100644 index 0000000..0121bd1 --- /dev/null +++ b/workers/silverback-ai-studio/firebase-blueprint.json @@ -0,0 +1,145 @@ +{ + "entities": { + "EventLog": { + "title": "Event Log", + "description": "A log of a security event detected by the camera AI.", + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "description": "The time the event was detected." + }, + "type": { + "type": "string", + "enum": ["theft", "break-in", "sublease_violation", "general_activity", "weirdness_alert"], + "description": "The type of event detected." + }, + "description": { + "type": "string", + "description": "A brief description of the event." + }, + "severity": { + "type": "string", + "enum": ["low", "medium", "high", "critical"], + "description": "The severity level of the event." + }, + "location": { + "type": "string", + "description": "The specific area where the event occurred." + }, + "aiAnalysis": { + "type": "string", + "description": "Detailed analysis of the clip content generated by AI." + } + }, + "required": ["timestamp", "type", "description", "severity"] + }, + "WeirdnessConfig": { + "title": "Weirdness Configuration", + "description": "Configurable parameters for the weirdness detection algorithm.", + "type": "object", + "properties": { + "normalHours": { + "type": "array", + "items": { "type": "integer" }, + "description": "List of hours considered 'normal'." + }, + "motionThreshold": { + "type": "number", + "description": "The threshold for motion detection (0.0 to 1.0)." + }, + "lingeringThreshold": { + "type": "integer", + "description": "The threshold for lingering detection (in seconds)." + }, + "emailTo": { + "type": "string", + "format": "email", + "description": "The email address to send alerts to." + }, + "smtpServer": { + "type": "string", + "description": "The SMTP server to use for sending emails." + }, + "rtspUrl": { + "type": "string", + "description": "The RTSP URL of the camera stream." + }, + "rtspUsername": { + "type": "string", + "description": "The username for the RTSP stream." + }, + "rtspPassword": { + "type": "string", + "description": "The password for the RTSP stream." + } + }, + "required": ["normalHours", "motionThreshold", "lingeringThreshold", "emailTo"] + }, + "SecurityReport": { + "title": "Security Report", + "description": "A generated summary of security events over a period.", + "type": "object", + "properties": { + "generatedAt": { + "type": "string", + "format": "date-time", + "description": "When the report was generated." + }, + "period": { + "type": "string", + "enum": ["daily", "weekly", "monthly"], + "description": "The time period covered by the report." + }, + "summary": { + "type": "string", + "description": "A summary of the findings." + }, + "eventCount": { + "type": "integer", + "description": "Total number of events in this period." + }, + "highSeverityCount": { + "type": "integer", + "description": "Number of high severity events." + }, + "status": { + "type": "string", + "enum": ["generated", "emailed", "downloaded"], + "description": "The current status of the report." + } + }, + "required": ["generatedAt", "period", "summary", "eventCount"] + }, + "UserProfile": { + "title": "User Profile", + "description": "Information about the authorized user.", + "type": "object", + "properties": { + "uid": { "type": "string" }, + "email": { "type": "string" }, + "role": { "type": "string", "enum": ["admin", "viewer"] } + }, + "required": ["uid", "email", "role"] + } + }, + "firestore": { + "/event_logs/{logId}": { + "schema": "EventLog", + "description": "Collection of all security event logs." + }, + "/security_reports/{reportId}": { + "schema": "SecurityReport", + "description": "Collection of generated security reports." + }, + "/users/{userId}": { + "schema": "UserProfile", + "description": "Authorized user profiles." + }, + "/config/weirdness": { + "schema": "WeirdnessConfig", + "description": "Global configuration for the weirdness detection algorithm." + } + } +} diff --git a/workers/silverback-ai-studio/firestore.rules b/workers/silverback-ai-studio/firestore.rules new file mode 100644 index 0000000..598e0da --- /dev/null +++ b/workers/silverback-ai-studio/firestore.rules @@ -0,0 +1,84 @@ +rules_version = '2'; +service cloud.firestore { + match /databases/{database}/documents { + function isAuthenticated() { + return request.auth != null; + } + + function isOwner(userId) { + return isAuthenticated() && request.auth.uid == userId; + } + + function isAdmin() { + return isAuthenticated() && + (get(/databases/$(database)/documents/users/$(request.auth.uid)).data.role == 'admin' || + (request.auth.token.email == "bryan@norcalcarbmobile.com" && request.auth.token.email_verified == true)); + } + + function isValidEmail(email) { + return email is string && email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"); + } + + function isValidDateString(dateStr) { + return dateStr is string && dateStr.matches("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}.*Z?$"); + } + + function isValidUser(data) { + return data.keys().hasAll(['uid', 'email', 'role']) && + data.uid == request.auth.uid && + isValidEmail(data.email) && + data.role in ['admin', 'viewer']; + } + + function isValidEventLog(data) { + return data.keys().hasAll(['timestamp', 'type', 'description', 'severity']) && + isValidDateString(data.timestamp) && + data.type in ['theft', 'break-in', 'sublease_violation', 'general_activity', 'weirdness_alert'] && + data.description is string && data.description.size() < 1000 && + data.severity in ['low', 'medium', 'high', 'critical'] && + (!('aiAnalysis' in data) || (data.aiAnalysis is string && data.aiAnalysis.size() < 5000)); + } + + function isValidWeirdnessConfig(data) { + return data.keys().hasAll(['normalHours', 'motionThreshold', 'lingeringThreshold', 'emailTo']) && + data.normalHours is list && + data.motionThreshold is number && + data.lingeringThreshold is int && + isValidEmail(data.emailTo) && + (!('rtspUrl' in data) || data.rtspUrl is string) && + (!('rtspUsername' in data) || data.rtspUsername is string) && + (!('rtspPassword' in data) || data.rtspPassword is string); + } + + function isValidSecurityReport(data) { + return data.keys().hasAll(['generatedAt', 'period', 'summary', 'eventCount']) && + isValidDateString(data.generatedAt) && + data.period in ['daily', 'weekly', 'monthly'] && + data.summary is string && data.summary.size() < 5000 && + data.eventCount is int; + } + + match /users/{userId} { + allow read: if isOwner(userId) || isAdmin(); + allow create: if isOwner(userId) && isValidUser(request.resource.data) && (request.resource.data.role == 'viewer' || isAdmin()); + allow update: if isOwner(userId) && isValidUser(request.resource.data) && (request.resource.data.role == resource.data.role || isAdmin()); + } + + match /event_logs/{logId} { + allow read: if isAuthenticated(); + allow create: if isAdmin() && isValidEventLog(request.resource.data); + allow update, delete: if isAdmin(); + } + + match /security_reports/{reportId} { + allow read: if isAuthenticated(); + allow create: if isAdmin() && isValidSecurityReport(request.resource.data); + allow update, delete: if isAdmin(); + } + + match /config/weirdness { + allow read: if isAuthenticated(); + allow write: if isAdmin() && isValidWeirdnessConfig(request.resource.data); + } + } +} diff --git a/workers/silverback-ai-studio/index.html b/workers/silverback-ai-studio/index.html new file mode 100644 index 0000000..ab6e008 --- /dev/null +++ b/workers/silverback-ai-studio/index.html @@ -0,0 +1,12 @@ + + + + + + Silverback AI - Security Systems + + +
+ + + diff --git a/workers/silverback-ai-studio/metadata.json b/workers/silverback-ai-studio/metadata.json new file mode 100644 index 0000000..44f5aa4 --- /dev/null +++ b/workers/silverback-ai-studio/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "SILVERBACK AI - SECURITY", + "description": "Advanced AI Security for vintage 24-unit apartment buildings. Protecting 3875 Ruby St with privacy-first monitoring compliant with Oakland laws.", + "requestFramePermissions": [] +} diff --git a/workers/silverback-ai-studio/package.json b/workers/silverback-ai-studio/package.json new file mode 100644 index 0000000..4ea0f2d --- /dev/null +++ b/workers/silverback-ai-studio/package.json @@ -0,0 +1,37 @@ +{ + "name": "react-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "tsx server.ts", + "build": "vite build", + "preview": "vite preview", + "clean": "rm -rf dist", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@google/genai": "^1.29.0", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "dotenv": "^17.2.3", + "express": "^4.21.2", + "firebase": "^12.11.0", + "lucide-react": "^0.546.0", + "motion": "^12.23.24", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^6.2.0", + "ws": "^8.20.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^22.14.0", + "@types/ws": "^8.18.1", + "autoprefixer": "^10.4.21", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } +} diff --git a/workers/silverback-ai-studio/server.ts b/workers/silverback-ai-studio/server.ts new file mode 100644 index 0000000..bcd8500 --- /dev/null +++ b/workers/silverback-ai-studio/server.ts @@ -0,0 +1,88 @@ +import express from "express"; +import { createServer } from "http"; +import { WebSocketServer, WebSocket } from "ws"; +import { createServer as createViteServer } from "vite"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +async function startServer() { + const app = express(); + const server = createServer(app); + const wss = new WebSocketServer({ server }); + const PORT = 3000; + + app.use(express.json()); + + // Store connected clients + const clients = new Set(); + + wss.on("connection", (ws) => { + clients.add(ws); + console.log("Client connected to WebSocket"); + + ws.on("close", () => { + clients.delete(ws); + console.log("Client disconnected"); + }); + }); + + // Broadcast function + const broadcast = (data: any) => { + const message = JSON.stringify(data); + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); + } + }); + }; + + // API to trigger high-severity alerts (for testing/demo) + app.post("/api/trigger-alert", (req, res) => { + const { type, description, severity, location } = req.body; + + if (severity === 'high' || severity === 'critical') { + const alert = { + id: Date.now().toString(), + timestamp: new Date().toISOString(), + type: type || 'weirdness_alert', + description: description || 'High-severity event detected!', + severity: severity || 'high', + location: location || 'Main Entrance', + }; + + broadcast({ type: 'ALERT', payload: alert }); + return res.json({ status: "Alert broadcasted", alert }); + } + + res.status(400).json({ error: "Only high or critical severity alerts are broadcasted via WebSocket" }); + }); + + // Health check + app.get("/api/health", (req, res) => { + res.json({ status: "ok", clients: clients.size }); + }); + + // Vite middleware for development + if (process.env.NODE_ENV !== "production") { + const vite = await createViteServer({ + server: { middlewareMode: true }, + appType: "spa", + }); + app.use(vite.middlewares); + } else { + const distPath = path.join(process.cwd(), 'dist'); + app.use(express.static(distPath)); + app.get('*', (req, res) => { + res.sendFile(path.join(distPath, 'index.html')); + }); + } + + server.listen(PORT, "0.0.0.0", () => { + console.log(`Server running on http://localhost:${PORT}`); + }); +} + +startServer().catch(console.error); diff --git a/workers/silverback-ai-studio/src/App.tsx b/workers/silverback-ai-studio/src/App.tsx new file mode 100644 index 0000000..76017d2 --- /dev/null +++ b/workers/silverback-ai-studio/src/App.tsx @@ -0,0 +1,1673 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect } from "react"; +import { motion, AnimatePresence } from "motion/react"; +import { + Shield, + Lock, + Camera, + AlertTriangle, + UserCheck, + MapPin, + CheckCircle2, + Cpu, + FileText, + Download, + Mail, + Plus, + LogOut, + LogIn, + Loader2, + X, + History, + Settings, + Activity, + Zap, + Clock, + Palette, + Bell +} from "lucide-react"; +import { + auth, + db, + googleProvider, + signInWithPopup, + signOut, + onAuthStateChanged, + collection, + addDoc, + query, + orderBy, + onSnapshot, + doc, + setDoc, + getDoc, + updateDoc, + where, + limit, + User +} from "./firebase"; +import { GoogleGenAI } from "@google/genai"; + +// --- Types --- +enum OperationType { + CREATE = 'create', + UPDATE = 'update', + DELETE = 'delete', + LIST = 'list', + GET = 'get', + WRITE = 'write', +} + +interface FirestoreErrorInfo { + error: string; + operationType: OperationType; + path: string | null; + authInfo: { + userId: string | undefined; + email: string | null | undefined; + emailVerified: boolean | undefined; + isAnonymous: boolean | undefined; + tenantId: string | null | undefined; + providerInfo: { + providerId: string; + displayName: string | null; + email: string | null; + photoUrl: string | null; + }[]; + } +} + +interface EventLog { + id?: string; + timestamp: string; + type: 'theft' | 'break-in' | 'sublease_violation' | 'general_activity' | 'weirdness_alert'; + description: string; + severity: 'low' | 'medium' | 'high' | 'critical'; + location?: string; + aiAnalysis?: string; +} + +interface WeirdnessConfig { + normalHours: number[]; + motionThreshold: number; + lingeringThreshold: number; + emailTo: string; + smtpServer: string; + rtspUrl?: string; + rtspUsername?: string; + rtspPassword?: string; +} + +interface SecurityReport { + id?: string; + generatedAt: string; + period: 'daily' | 'weekly' | 'monthly'; + summary: string; + eventCount: number; + highSeverityCount: number; + status: 'generated' | 'emailed' | 'downloaded'; +} + +// --- Presentation Component --- +function PresentationView({ onClose, currentSlide, setCurrentSlide }: { onClose: () => void, currentSlide: number, setCurrentSlide: (s: number) => void }) { + const slides = [ + { + title: "SILVERBACK AI", + subtitle: "Next-Generation Security Systems", + content: "Protecting 3875 Ruby St with intelligent camera recognition and real-time weirdness detection. Secure, private, and compliant.", + bg: "https://images.unsplash.com/photo-1550751827-4bd374c3f58b?auto=format&fit=crop&q=80&w=1200" + }, + { + title: "The Challenge", + subtitle: "Vintage Property Security", + content: "Vintage 24-unit, 3-story apartment buildings present unique security challenges. Silverback AI provides proactive monitoring while respecting tenant privacy.", + bg: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=1200" + }, + { + title: "AI Tracking Demo", + subtitle: "Front Door & Mailboxes", + isDemo: true, + content: "Our AI tracks activity at the front door and mailboxes. Tenants are identified only by numeric IDs. Owners only receive footage during 'outside normal operations' alerts.", + bg: "https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=1200" + }, + { + title: "Data Sovereignty", + subtitle: "Oakland Law Compliance", + content: "All footage is stored on a secure Virtual Machine (VM). Access is restricted: footage is only passed to police upon their official request for an incident, in accordance with Oakland laws and tenant rights.", + bg: "https://images.unsplash.com/photo-1558494949-ef010cbdcc51?auto=format&fit=crop&q=80&w=1200" + } + ]; + + const nextSlide = () => setCurrentSlide((currentSlide + 1) % slides.length); + const prevSlide = () => setCurrentSlide((currentSlide - 1 + slides.length) % slides.length); + + return ( +
+
+ +
+ + + + {/* Background Image */} +
+ Background +
+
+ +
+ + + {slides[currentSlide].subtitle} + +

+ {slides[currentSlide].title} +

+ + {slides[currentSlide].isDemo ? ( +
+ Lobby Demo + {/* AI Tracking Overlay */} +
+ {/* Person 1 */} + +
+ Person #042 - Tracking +
+ {/* Privacy Box (Shoulders Up) */} +
+ Privacy Shield +
+
+ + {/* Person 2 */} + +
+ Person #109 - Tracking +
+ {/* Privacy Box (Shoulders Up) */} +
+ Privacy Shield +
+
+ + {/* Scanning Line */} + +
+ +
+
+ Live Feed - Front Door & Mailboxes +
+
+ ) : ( +

+ {slides[currentSlide].content} +

+ )} + +
+
+ + + {/* Controls */} +
+
+ {slides.map((_, i) => ( +
+ ))} +
+ +
+ + +
+
+
+ ); +} + +// --- Error Handling --- +function handleFirestoreError(error: unknown, operationType: OperationType, path: string | null) { + const errInfo: FirestoreErrorInfo = { + error: error instanceof Error ? error.message : String(error), + authInfo: { + userId: auth.currentUser?.uid, + email: auth.currentUser?.email, + emailVerified: auth.currentUser?.emailVerified, + isAnonymous: auth.currentUser?.isAnonymous, + tenantId: auth.currentUser?.tenantId, + providerInfo: auth.currentUser?.providerData.map(provider => ({ + providerId: provider.providerId, + displayName: provider.displayName, + email: provider.email, + photoUrl: provider.photoURL + })) || [] + }, + operationType, + path + }; + console.error('Firestore Error: ', JSON.stringify(errInfo)); + throw new Error(JSON.stringify(errInfo)); +} + +// --- Components --- +function NotificationToast({ notification, onClose }: { notification: EventLog, onClose: () => void, key?: string | number }) { + return ( + +
+
+
+ +
+
+
+ High Severity Alert + +
+

+ {notification.type.replace('_', ' ')} +

+

+ {notification.description} +

+
+
+ + {notification.location || 'Unknown Location'} +
+ + {new Date(notification.timestamp).toLocaleTimeString()} + +
+
+
+ + {/* Animated Scanning Bar */} + + + ); +} + +function ErrorBoundary({ children }: { children: React.ReactNode }) { + const [error, setError] = useState(null); + + useEffect(() => { + const handleError = (e: ErrorEvent) => { + try { + const parsed = JSON.parse(e.message); + if (parsed.error) { + setError(`Security Error: ${parsed.error} during ${parsed.operationType} on ${parsed.path}`); + } + } catch { + setError(e.message); + } + }; + window.addEventListener('error', handleError); + return () => window.removeEventListener('error', handleError); + }, []); + + if (error) { + return ( +
+
+ +

System Error

+

{error}

+ +
+
+ ); + } + return <>{children}; +} + +export default function App() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [reports, setReports] = useState([]); + const [events, setEvents] = useState([]); + const [isGenerating, setIsGenerating] = useState(false); + const [isAnalyzing, setIsAnalyzing] = useState(null); + const [showDashboard, setShowDashboard] = useState(false); + const [dashboardTab, setDashboardTab] = useState<'overview' | 'weirdness' | 'branding' | 'config'>('overview'); + const [isGeneratingAsset, setIsGeneratingAsset] = useState(false); + const [generatedAssetUrl, setGeneratedAssetUrl] = useState(null); + const [selectedAssetType, setSelectedAssetType] = useState('App Icon'); + const [selectedStyle, setSelectedStyle] = useState('High-Tech'); + const [weirdnessConfig, setWeirdnessConfig] = useState({ + normalHours: Array.from({ length: 16 }, (_, i) => i + 6), // 6am-10pm + motionThreshold: 0.05, + lingeringThreshold: 30, + emailTo: "bryan@norcalcarbmobile.com", + smtpServer: "smtp.gmail.com", + rtspUrl: "", + rtspUsername: "", + rtspPassword: "" + }); + + const [view, setView] = useState<'dashboard' | 'presentation'>('dashboard'); + const [currentSlide, setCurrentSlide] = useState(0); + const [notifications, setNotifications] = useState([]); + + // --- WebSocket Connection --- + useEffect(() => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}`; + const socket = new WebSocket(wsUrl); + + socket.onopen = () => { + console.log('Connected to real-time notification server'); + }; + + socket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'ALERT') { + const alert = data.payload as EventLog; + setNotifications(prev => [alert, ...prev]); + + // Play alert sound if possible + try { + const audio = new Audio('https://assets.mixkit.co/active_storage/sfx/2869/2869-preview.mp3'); + audio.volume = 0.5; + audio.play().catch(() => {}); + } catch (e) { + console.warn("Audio playback failed", e); + } + + // Auto-remove notification after 10 seconds + setTimeout(() => { + setNotifications(prev => prev.filter(n => n.id !== alert.id)); + }, 10000); + } + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + socket.onclose = () => { + console.log('Disconnected from notification server'); + }; + + return () => socket.close(); + }, []); + + // --- Auth --- + useEffect(() => { + const unsubscribe = onAuthStateChanged(auth, async (currentUser) => { + setUser(currentUser); + if (currentUser) { + // Sync user profile to Firestore + const userRef = doc(db, 'users', currentUser.uid); + try { + const userSnap = await getDoc(userRef); + if (!userSnap.exists()) { + await setDoc(userRef, { + uid: currentUser.uid, + email: currentUser.email, + role: currentUser.email === "bryan@norcalcarbmobile.com" ? 'admin' : 'viewer' + }); + } + } catch (error) { + console.error("Error syncing user profile:", error); + } + } + setLoading(false); + }); + return () => unsubscribe(); + }, []); + + // --- Data Fetching --- + useEffect(() => { + if (!user) return; + + const reportsQuery = query(collection(db, 'security_reports'), orderBy('generatedAt', 'desc'), limit(10)); + const eventsQuery = query(collection(db, 'event_logs'), orderBy('timestamp', 'desc'), limit(20)); + + const unsubReports = onSnapshot(reportsQuery, (snapshot) => { + setReports(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as SecurityReport))); + }, (error) => handleFirestoreError(error, OperationType.LIST, 'security_reports')); + + const unsubEvents = onSnapshot(eventsQuery, (snapshot) => { + setEvents(snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() } as EventLog))); + }, (error) => handleFirestoreError(error, OperationType.LIST, 'event_logs')); + + const configRef = doc(db, 'config', 'weirdness'); + const unsubConfig = onSnapshot(configRef, (snapshot) => { + if (snapshot.exists()) { + setWeirdnessConfig(snapshot.data() as WeirdnessConfig); + } + }, (error) => handleFirestoreError(error, OperationType.GET, 'config/weirdness')); + + return () => { + unsubReports(); + unsubEvents(); + unsubConfig(); + }; + }, [user]); + + const saveWeirdnessConfig = async (newConfig: Partial) => { + if (!user) return; + const configRef = doc(db, 'config', 'weirdness'); + try { + await setDoc(configRef, { ...weirdnessConfig, ...newConfig }); + } catch (error) { + handleFirestoreError(error, OperationType.WRITE, 'config/weirdness'); + } + }; + + const analyzeEvent = async (event: EventLog) => { + if (!user || !event.id) return; + setIsAnalyzing(event.id); + + try { + const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); + const response = await ai.models.generateContent({ + model: "gemini-3-flash-preview", + contents: `Analyze this security event clip description and provide a more detailed, professional security assessment. + Event Type: ${event.type} + Initial Description: ${event.description} + Severity: ${event.severity} + + Provide a detailed breakdown of what might be happening, potential risks, and recommended actions. Keep it concise but professional.`, + }); + + const analysis = response.text || "No analysis available."; + + const eventRef = doc(db, 'event_logs', event.id); + await updateDoc(eventRef, { aiAnalysis: analysis }); + } catch (error) { + console.error("AI Analysis failed:", error); + handleFirestoreError(error, OperationType.UPDATE, `event_logs/${event.id}`); + } finally { + setIsAnalyzing(null); + } + }; + + const generateBrandingAsset = async () => { + if (isGeneratingAsset) return; + + setIsGeneratingAsset(true); + try { + // Check for API key + if (!(await (window as any).aistudio.hasSelectedApiKey())) { + await (window as any).aistudio.openSelectKey(); + } + + const ai = new GoogleGenAI({ apiKey: (process.env as any).API_KEY }); + const prompt = `A professional, modern, and minimalist ${selectedAssetType} for an AI security company named "Silverback AI". The style should be ${selectedStyle}. The logo should feature a powerful and sleek silverback gorilla head integrated with digital or circuit patterns to represent AI. Use a color palette of silver, charcoal gray, and a vibrant security orange. The style should be clean, high-tech, and authoritative. No text in the image.`; + + const response = await ai.models.generateContent({ + model: 'gemini-2.5-flash-image', + contents: { + parts: [{ text: prompt }], + }, + config: { + imageConfig: { + aspectRatio: "1:1", + }, + }, + }); + + let foundImage = false; + for (const part of response.candidates?.[0]?.content?.parts || []) { + if (part.inlineData) { + const base64Data = part.inlineData.data; + setGeneratedAssetUrl(`data:image/png;base64,${base64Data}`); + foundImage = true; + break; + } + } + + if (!foundImage) { + console.warn("No image data found in response"); + } + } catch (error) { + console.error("Error generating branding asset:", error); + // If requested entity not found, reset key + if (error instanceof Error && error.message.includes("Requested entity was not found")) { + await (window as any).aistudio.openSelectKey(); + } + } finally { + setIsGeneratingAsset(false); + } + }; + + const downloadReportCSV = (report: SecurityReport) => { + const reportDate = new Date(report.generatedAt); + let startDate = new Date(reportDate); + + if (report.period === 'daily') { + startDate.setDate(reportDate.getDate() - 1); + } else if (report.period === 'weekly') { + startDate.setDate(reportDate.getDate() - 7); + } else if (report.period === 'monthly') { + startDate.setMonth(reportDate.getMonth() - 1); + } + + const filteredEvents = events.filter(event => { + const eventDate = new Date(event.timestamp); + return eventDate >= startDate && eventDate <= reportDate; + }); + + if (filteredEvents.length === 0) { + alert("No events found for this report period."); + return; + } + + const headers = ["ID", "Timestamp", "Type", "Severity", "Description", "Location", "AI Analysis"]; + const csvContent = [ + headers.join(","), + ...filteredEvents.map(e => [ + `"${(e.id || "").replace(/"/g, '""')}"`, + `"${e.timestamp.replace(/"/g, '""')}"`, + `"${e.type.replace(/"/g, '""')}"`, + `"${e.severity.replace(/"/g, '""')}"`, + `"${e.description.replace(/"/g, '""')}"`, + `"${(e.location || "").replace(/"/g, '""')}"`, + `"${(e.aiAnalysis || "").replace(/"/g, '""')}"` + ].join(",")) + ].join("\n"); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.setAttribute("href", url); + link.setAttribute("download", `Silverback_Report_${report.period}_${report.generatedAt.split('T')[0]}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const handleLogin = async () => { + try { + await signInWithPopup(auth, googleProvider); + } catch (error) { + console.error("Login failed:", error); + } + }; + + const handleLogout = () => signOut(auth); + + const generateReport = async (period: 'daily' | 'weekly' | 'monthly') => { + if (!user) return; + setIsGenerating(true); + + try { + // Simulate AI processing + await new Promise(resolve => setTimeout(resolve, 2000)); + + const highSeverityCount = events.filter(e => e.severity === 'high').length; + + const newReport: Omit = { + generatedAt: new Date().toISOString(), + period, + summary: `AI Analysis complete for 3875 Ruby St. Detected ${events.length} total events. ${highSeverityCount} high-priority alerts require immediate attention. Perimeter integrity remains stable.`, + eventCount: events.length, + highSeverityCount, + status: 'generated' + }; + + await addDoc(collection(db, 'security_reports'), newReport); + } catch (error) { + handleFirestoreError(error, OperationType.CREATE, 'security_reports'); + } finally { + setIsGenerating(false); + } + }; + + const addMockEvent = async (typeOverride?: EventLog['type']) => { + if (!user) return; + const types: EventLog['type'][] = ['theft', 'break-in', 'sublease_violation', 'general_activity', 'weirdness_alert']; + const severities: EventLog['severity'][] = ['low', 'medium', 'high', 'critical']; + + const type = typeOverride || types[Math.floor(Math.random() * types.length)]; + const isWeird = type === 'weirdness_alert'; + + const mockEvent: Omit = { + timestamp: new Date().toISOString(), + type: type, + description: isWeird + ? "WEIRD: Lingering stranger detected near the rear entrance for >30s." + : "AI detected suspicious movement near the rear entrance of 3875 Ruby St.", + severity: isWeird ? 'high' : severities[Math.floor(Math.random() * severities.length)], + location: "Rear Entrance" + }; + + try { + const docRef = await addDoc(collection(db, 'event_logs'), mockEvent); + + // If high severity, trigger real-time alert via server + if (mockEvent.severity === 'high' || mockEvent.severity === 'critical') { + fetch('/api/trigger-alert', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...mockEvent, id: docRef.id }) + }).catch(err => console.error("Failed to trigger server alert:", err)); + } + } catch (error) { + handleFirestoreError(error, OperationType.CREATE, 'event_logs'); + } + }; + + if (view === 'presentation') { + return ( + setView('dashboard')} + currentSlide={currentSlide} + setCurrentSlide={setCurrentSlide} + /> + ); + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( + +
+ {/* Real-time Notifications */} + + {notifications.map((notification, idx) => ( + setNotifications(prev => prev.filter(n => (n.id || n.timestamp) !== (notification.id || notification.timestamp)))} + /> + ))} + + + {/* Navigation */} + + + {!showDashboard ? ( + <> + {/* Hero Section */} +
+
+
+
+
+ +
+ +
+ + SILVERBACK AI - SECURITY: Secure and Smart +
+

+ Advanced AI Security for 3875 Ruby St. +

+

+ Protecting Oakland property with intelligent camera recognition. + Stop theft, prevent break-ins, and verify subleases without compromising resident privacy. +

+
+ {user ? ( + + ) : ( + + )} + +
+
+
+
+ + {/* Stats/Location Bar */} +
+
+
+
+ +
+
+
Deployment Site
+
3875 Ruby St, Oakland
+
+
+
+
+ +
+
+
AI Engine
+
Silverback Edge V2.0
+
+
+
+
+ +
+
+
Status
+
Active & Monitoring
+
+
+
+
+ + {/* Features Grid */} +
+
+
+

Built for Modern Security

+

+ Traditional cameras just record. Silverback AI understands. + Our software identifies patterns and anomalies in real-time. +

+
+ +
+ {[ + { + icon: , + title: "Theft Prevention", + description: "Real-time AI monitoring to detect and deter theft before it happens." + }, + { + icon: , + title: "Break-in Protection", + description: "Smart perimeter alerts that distinguish between residents and intruders." + }, + { + icon: , + title: "Sublease Verification", + description: "Identify unauthorized occupants and prove sublease violations with visual evidence." + }, + { + icon: , + title: "Weirdness Detection", + description: "Proprietary algorithm to detect 'weird' behavior like lingering strangers or high-speed motion after hours." + }, + { + icon: , + title: "Privacy First", + description: "Edge-processed AI ensures recognition happens locally. No data leaks, total privacy." + } + ].map((feature, idx) => ( + +
+ {feature.icon} +
+

{feature.title}

+

+ {feature.description} +

+
+ ))} +
+
+
+ + {/* Hardware Strategy Section */} +
+
+
+
+

The Hardware Strategy

+

+ Silverback AI is designed to run on what you already own. While many systems require expensive proprietary hubs, + we recommend the "Old Laptop" approach for 3875 Ruby St. +

+ +
+
+

Why Old Laptops?

+
    +
  • • Zero cost (repurpose old gear)
  • +
  • • Built-in battery backup (UPS)
  • +
  • • Superior cooling for garage environments
  • +
  • • Integrated screen for easy debugging
  • +
+
+
+

The Tapo Advantage

+
    +
  • • Tapo C120: High-res, low-light
  • +
  • • RTSP support for AI integration
  • +
  • • USB-C power simplicity
  • +
  • • Magnetic mount for quick positioning
  • +
+
+
+
+ +
+

+ + Deployment Checklist +

+
+ {[ + { step: "01", title: "Prep the Base", desc: "Dust off that old laptop, plug it in a secure garage corner." }, + { step: "02", title: "Install Core", desc: "Python + OpenCV, NumPy, and Schedule. Takes less than 5 mins." }, + { step: "03", title: "Mount Camera", desc: "Place Tapo C120 high near mailboxes. Hide cord behind trim." }, + { step: "04", title: "Enable RTSP", desc: "Get the URL from the Tapo app. Note the local IP address." }, + { step: "05", title: "Run Silverback", desc: "Paste RTSP URL into the script and launch the AI engine." } + ].map((item, i) => ( +
+ {item.step} +
+
{item.title}
+

{item.desc}

+
+
+ ))} +
+
+
+ +

+ PRO TIP: Ensure the laptop is set to "Never Sleep" when lid is closed for 24/7 monitoring. +

+
+
+
+
+
+
+ + {/* Privacy Section */} +
+
+
+

Security Without Surveillance

+

+ We believe privacy is a human right. Silverback AI uses advanced anonymization + techniques to track events and behaviors without storing personal identity data + unless a security breach is detected. +

+
    + {[ + "Local-only data processing", + "Automatic face blurring for non-events", + "Encrypted evidence storage", + "GDPR & CCPA compliant architecture" + ].map((item, i) => ( +
  • + + {item} +
  • + ))} +
+
+
+
+
+ +
SYSTEM_LOG: ENCRYPTED
+
+ Privacy Shield Active.
+ Monitoring 3875 Ruby St. +
+
+
+
+
+
+ + {/* CTA Section */} +
+
+
+
+

+ Ready to secure your property? +

+

+ Join the future of Oakland property management. Secure, smart, and privacy-focused. +

+ +
+
+
+ + ) : ( + /* Dashboard Section */ + +
+
+

Security Dashboard

+

Manage reports and monitor events for 3875 Ruby St.

+
+
+ {(['overview', 'weirdness', 'branding', 'config'] as const).map((tab) => ( + + ))} +
+
+ + +
+ +
+ {(['daily', 'weekly', 'monthly'] as const).map(p => ( + + ))} +
+
+
+
+ + {dashboardTab === 'overview' && ( +
+ {/* Reports List */} +
+

+ + Recent Reports +

+
+ + {reports.map((report) => ( + +
+
+
+ +
+
+

{report.period} Security Summary

+

{new Date(report.generatedAt).toLocaleString()}

+
+
+
+ + +
+
+

{report.summary}

+
+
+ {report.eventCount} Events +
+
+ {report.highSeverityCount} High Priority +
+
+
+ ))} +
+ {reports.length === 0 && ( +
+ +

No reports generated yet.

+
+ )} +
+
+ + {/* Live Events Feed */} +
+

+ + Live Event Feed +

+
+
+ {events.map((event) => ( +
+
+
+
+ + {event.type.replace('_', ' ')} + + + {new Date(event.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + +
+

{event.description}

+ + {event.aiAnalysis ? ( +
+
+ + AI Analysis +
+

+ {event.aiAnalysis} +

+
+ ) : ( + + )} +
+
+ ))} + {events.length === 0 && ( +

Waiting for events...

+ )} +
+
+
+
+ )} + + {dashboardTab === 'weirdness' && ( +
+
+
+
+

+ + Weirdness Monitor +

+
+ + Algorithm Active +
+
+ +
+
+
Current Activity Level
+
0.024%
+
Normal baseline: < 0.03%
+
+
+
Last Detection
+
None
+
No weirdness in last 24h
+
+
+ +
+

Active Detection Rules

+
+
+
+ + Off-Hours Motion (>5% change) +
+ ACTIVE +
+
+
+ + Lingering Stranger (>30s detection) +
+ ACTIVE +
+
+
+
+ +
+

Algorithm Logic (weird_detector.py)

+
+{`def is_weird(frame, timestamp):
+    # ... processing ...
+    motion_level = cv2.countNonZero(thresh) / total_pixels
+    
+    # "Weird" rules:
+    if motion_level > 0.05 and hour not in NORMAL_HOURS:
+        return True, "Fast motion after hours"
+    if motion_level > 0.03 and lingering_time > 30:
+        return True, "Lingering stranger"
+    return False, None`}
+                    
+
+
+ +
+

+ + Weirdness Alerts +

+
+
+ +
+

No weird events detected in the current session.

+ +
+
+
+ )} + + {dashboardTab === 'branding' && ( +
+
+
+
+

+ + Silverback Branding Studio +

+

Generate high-quality branding assets for your Silverback security systems.

+
+
+ + Powered by Nano Banana +
+
+ +
+
+
+ +
+ {['App Icon', 'Security Sign', 'Vehicle Decal', 'Uniform Patch'].map(type => ( + + ))} +
+
+ +
+ +
+ {['High-Tech', 'Minimalist', 'Aggressive', 'Professional', 'Futuristic'].map(style => ( + + ))} +
+
+ +
+ +

+ Estimated generation time: 12.4s +

+
+
+ +
+
+
+ {generatedAssetUrl ? ( +
+ Generated Branding Asset +
+ + +
+
+ ) : ( + <> +
+ +
+

Ready to Generate

+

Select an asset type and style to begin the AI branding process.

+ + )} +
+
+
+
+ +
+
+
Active Palette
+
+
+
+
+
+
+
+
+
+
Brand Font
+
Inter Display
+
+
Aa
+
+
+
+
Mono Font
+
JetBrains
+
+
01
+
+
+
+ )} + + {dashboardTab === 'config' && ( +
+
+

+ + Algorithm Configuration +

+ +
+
+
+ + { + const start = parseInt(e.target.value); + const end = weirdnessConfig.normalHours[weirdnessConfig.normalHours.length - 1]; + const newHours = Array.from({ length: end - start + 1 }, (_, i) => i + start); + setWeirdnessConfig({ ...weirdnessConfig, normalHours: newHours }); + }} + className="w-full bg-black border border-white/10 rounded-lg px-4 py-3 text-white focus:border-orange-500 outline-none transition-all" + /> +
+
+ + { + const end = parseInt(e.target.value); + const start = weirdnessConfig.normalHours[0]; + const newHours = Array.from({ length: end - start + 1 }, (_, i) => i + start); + setWeirdnessConfig({ ...weirdnessConfig, normalHours: newHours }); + }} + className="w-full bg-black border border-white/10 rounded-lg px-4 py-3 text-white focus:border-orange-500 outline-none transition-all" + /> +
+
+ +
+ + setWeirdnessConfig({ ...weirdnessConfig, motionThreshold: parseInt(e.target.value) / 100 })} + className="w-full accent-orange-500" + /> +
+ 1% (Sensitive) + {Math.round(weirdnessConfig.motionThreshold * 100)}% (Current) + 20% (Low) +
+
+ +
+ + setWeirdnessConfig({ ...weirdnessConfig, emailTo: e.target.value })} + className="w-full bg-black border border-white/10 rounded-lg px-4 py-3 text-white focus:border-orange-500 outline-none transition-all" + /> +
+ +
+

Camera Stream Configuration

+
+
+ + setWeirdnessConfig({ ...weirdnessConfig, rtspUrl: e.target.value })} + className="w-full bg-black border border-white/10 rounded-lg px-4 py-3 text-white focus:border-orange-500 outline-none transition-all" + /> +
+
+
+ + setWeirdnessConfig({ ...weirdnessConfig, rtspUsername: e.target.value })} + className="w-full bg-black border border-white/10 rounded-lg px-4 py-3 text-white focus:border-orange-500 outline-none transition-all" + /> +
+
+ + setWeirdnessConfig({ ...weirdnessConfig, rtspPassword: e.target.value })} + className="w-full bg-black border border-white/10 rounded-lg px-4 py-3 text-white focus:border-orange-500 outline-none transition-all" + /> +
+
+
+
+ +
+ +
+
+
+
+ )} + + {/* Privacy & Compliance Section */} +
+
+ +

Privacy & Compliance

+
+
+
+

+ Tenant Privacy: Tenants are identified only by anonymized numeric IDs. No facial data or PII is stored in the primary detection database. +

+

+ Data Sovereignty: All footage is stored on a secure, isolated Virtual Machine (VM). Owners only receive footage alerts for "outside normal operations" events. +

+
+
+

+ Law Enforcement: Footage is only shared with police upon official request for specific incidents, ensuring full compliance with Oakland surveillance ordinances. +

+

+ Rights: Our systems are designed to respect tenant rights and local laws while maintaining property security. +

+
+
+
+ + )} + + {/* Footer */} +
+
+
+
+ SAI +
+
+ SILVERBACK AI + Security +
+
+
+ © 2026 SILVERBACK AI - SECURITY. Secure and Smart. 3875 Ruby St, Oakland.
+ Compliant with Oakland Surveillance Ordinances & Tenant Rights. +
+ +
+
+
+ + ); +} diff --git a/workers/silverback-ai-studio/src/firebase.ts b/workers/silverback-ai-studio/src/firebase.ts new file mode 100644 index 0000000..ccef822 --- /dev/null +++ b/workers/silverback-ai-studio/src/firebase.ts @@ -0,0 +1,12 @@ +import { initializeApp } from 'firebase/app'; +import { getAuth, GoogleAuthProvider, signInWithPopup, signOut, onAuthStateChanged, User } from 'firebase/auth'; +import { getFirestore, collection, addDoc, getDocs, query, orderBy, limit, onSnapshot, doc, getDoc, setDoc, updateDoc, Timestamp, where } from 'firebase/firestore'; +import firebaseConfig from '../firebase-applet-config.json'; + +const app = initializeApp(firebaseConfig); +export const auth = getAuth(app); +export const db = getFirestore(app, firebaseConfig.firestoreDatabaseId); +export const googleProvider = new GoogleAuthProvider(); + +export { signInWithPopup, signOut, onAuthStateChanged, collection, addDoc, getDocs, query, orderBy, limit, onSnapshot, doc, getDoc, setDoc, updateDoc, Timestamp, where }; +export type { User }; diff --git a/workers/silverback-ai-studio/src/index.css b/workers/silverback-ai-studio/src/index.css new file mode 100644 index 0000000..b4e9ccb --- /dev/null +++ b/workers/silverback-ai-studio/src/index.css @@ -0,0 +1,27 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap'); +@import "tailwindcss"; + +@theme { + --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace; + + --color-brand-orange: #f97316; + --color-brand-silver: #94a3b8; + --color-brand-charcoal: #1e293b; + --color-brand-black: #020617; +} + +@layer base { + body { + @apply bg-brand-black text-zinc-100; + } +} + +.glass-card { + @apply bg-white/5 backdrop-blur-xl border border-white/10 rounded-2xl; +} + +.security-gradient { + background: radial-gradient(circle at top right, rgba(249, 115, 22, 0.15), transparent 40%), + radial-gradient(circle at bottom left, rgba(148, 163, 184, 0.05), transparent 40%); +} diff --git a/workers/silverback-ai-studio/src/main.tsx b/workers/silverback-ai-studio/src/main.tsx new file mode 100644 index 0000000..080dac3 --- /dev/null +++ b/workers/silverback-ai-studio/src/main.tsx @@ -0,0 +1,10 @@ +import {StrictMode} from 'react'; +import {createRoot} from 'react-dom/client'; +import App from './App.tsx'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/workers/silverback-ai-studio/tsconfig.json b/workers/silverback-ai-studio/tsconfig.json new file mode 100644 index 0000000..d88f175 --- /dev/null +++ b/workers/silverback-ai-studio/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "experimentalDecorators": true, + "useDefineForClassFields": false, + "module": "ESNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "allowJs": true, + "jsx": "react-jsx", + "paths": { + "@/*": [ + "./*" + ] + }, + "allowImportingTsExtensions": true, + "noEmit": true + } +} diff --git a/workers/silverback-ai-studio/vite.config.ts b/workers/silverback-ai-studio/vite.config.ts new file mode 100644 index 0000000..6b1fbc3 --- /dev/null +++ b/workers/silverback-ai-studio/vite.config.ts @@ -0,0 +1,22 @@ +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; +import {defineConfig, loadEnv} from 'vite'; + +export default defineConfig(({mode}) => { + const env = loadEnv(mode, '.', ''); + return { + plugins: [react(), tailwindcss()], + define: { + 'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), + }, + resolve: { + alias: { + '@': path.resolve(__dirname, '.'), + }, + }, + server: { + hmr: process.env.DISABLE_HMR !== 'true', + }, + }; +}); diff --git a/workers/silverbackai-toolkit/worker.js b/workers/silverbackai-toolkit/worker.js new file mode 100644 index 0000000..f50ccf5 --- /dev/null +++ b/workers/silverbackai-toolkit/worker.js @@ -0,0 +1,830 @@ +// worker.js - Silverback AI Toolkit +var worker_default = { + async fetch(request) { + const url = new URL(request.url); + + // Toolkit hub page + const html = ` + + + + + + + + + + + Silverback AI Toolkit — AI Tools Ready to Deploy + + + + + + + + + + +
+ +
+ +
+
+

The Toolkit

+

+ Pre-built AI tools ready to deploy. Pick one, plug it into your + workflow, and start saving hours immediately. +

+ +
+
+ +
+
+ + + + + + +
+
+ +
+
+
+ +
+
+
⚖️
+
Live
+
+

Contract Analyzer

+

Upload a contract and get a plain-English summary of key terms, obligations, deadlines, and red flags. Built for solo attorneys and small firms.

+ +
+ +
+
+
📚
+
Live
+
+

Case Research Assistant

+

AI-powered case law research. Describe your situation in plain language and get relevant precedents, statutes, and arguments. Saves hours of research time.

+ +
+ +
+
+
🏢
+
Live
+
+

Tenant Communication Bot

+

Automated tenant messaging for maintenance requests, lease renewals, and announcements. Handles routine inquiries 24/7 so you don't have to.

+ +
+ +
+
+
📋
+
Live
+
+

Lease Analyzer

+

Upload any lease agreement and get instant analysis of terms, rent escalation clauses, liability issues, and renewal conditions. Compare across your portfolio.

+ +
+ +
+
+
🔧
+
Beta
+
+

Maintenance Router

+

AI triages incoming maintenance requests by urgency, assigns to the right vendor, and tracks resolution. Reduces response time and keeps tenants happy.

+ +
+ +
+
+
🌐
+
Live
+
+

Site Builder AI

+

Describe your business and get a professional website generated with copy, layout, and SEO built in. Deploy to Cloudflare in minutes, not weeks.

+ +
+ +
+
+
📝
+
Live
+
+

Content Generator

+

Generate blog posts, landing page copy, social media content, and email campaigns tailored to your brand voice. Batch create weeks of content in one session.

+ +
+ +
+
+
🤖
+
Beta
+
+

Workflow Automator

+

Connect your existing tools with AI-powered automation. Email to spreadsheet, form to CRM, invoice to accounting — set it once and forget it.

+ +
+ +
+
+
📧
+
Live
+
+

Smart Inbox

+

AI reads, categorizes, and drafts replies to your email. Flags what needs your attention, handles the rest. Works with Gmail and Outlook.

+ +
+ +
+
+
📊
+
Beta
+
+

Data Insight Engine

+

Upload spreadsheets or connect databases and ask questions in plain English. Get charts, summaries, and trend analysis without writing a single formula.

+ +
+ +
+
+
📄
+
Coming Soon
+
+

Document OCR + Extract

+

Scan physical documents, receipts, or handwritten notes and extract structured data. Feed it into your existing systems automatically.

+ +
+ +
+
+
🔌
+
Coming Soon
+
+

API Bridge

+

Connect any API to any other API with a natural language interface. Tell it what you want to happen, and it builds the integration. No code required.

+ +
+ +
+ +
+

Need Something Custom?

+

+ Don't see what you need? We build custom AI tools for your + exact workflow. Tell us what you're trying to solve. +

+ Request a Custom Tool +
+
+
+ + + +