feat: add appointments, campaigns, admin, storage, tests and various updates

- Add new routers: admin, appointments, campaigns
- Add storage service and logging config
- Add migrations directory and test suite with pytest config
- Add supabase_migration_features.sql
- Update models, dependencies, config, and existing routers
- Remove whatsapp_service (deleted)
- Update pyproject.toml and uv.lock dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
belviskhoremk
2026-04-03 09:11:58 +00:00
parent 9dccc83293
commit 92d4c2fc5e
51 changed files with 7076 additions and 515 deletions

View File

@@ -7,7 +7,7 @@ import logging
logger = logging.getLogger(__name__)
RAG_SYSTEM_PROMPT = """You are a helpful AI assistant for {company_name}.
RAG_SYSTEM_PROMPT = """You are a helpful AI assistant for {company_name}.
Your role is to answer questions based on the provided context from company documents.
IMPORTANT RULES:
@@ -16,13 +16,20 @@ IMPORTANT RULES:
3. Be concise and helpful
4. Always maintain a professional, friendly tone
5. If asked about topics completely outside the context, politely redirect to relevant topics
{language_instruction}
{custom_instructions}
Context from knowledge base:
{context}
"""
LANGUAGE_NAMES = {
"en": "English", "fr": "French", "es": "Spanish", "de": "German",
"it": "Italian", "pt": "Portuguese", "ar": "Arabic", "zh": "Chinese",
"ja": "Japanese", "ko": "Korean", "ru": "Russian", "nl": "Dutch",
"tr": "Turkish", "pl": "Polish", "vi": "Vietnamese", "th": "Thai",
}
class RAGEngine:
def __init__(self):
@@ -102,8 +109,15 @@ class RAGEngine:
logger.warning(f"[RAG] No context found for query: '{query}' in collection '{collection_name}'")
# Step 4: Build messages
lang_name = LANGUAGE_NAMES.get(language, "English") if language and language != "en" else ""
language_instruction = (
f"\n6. Respond in {lang_name}. Match the language of the user's message."
if lang_name else ""
)
system_prompt = RAG_SYSTEM_PROMPT.format(
company_name=chatbot_config.get("company_name", ""),
language_instruction=language_instruction,
custom_instructions=chatbot_config.get("system_prompt") or "",
context=context,
)

46
app/services/storage.py Normal file
View File

@@ -0,0 +1,46 @@
"""
Supabase Storage helper utilities.
Used to delete files from storage buckets when deleting documents or chatbots.
"""
import logging
from typing import Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
def extract_storage_path(url: str, bucket: str) -> Optional[str]:
"""
Extract the file path from a Supabase Storage public URL.
URL format: {supabase_url}/storage/v1/object/public/{bucket}/{path}
Returns the path portion after the bucket name, or None if not parseable.
"""
if not url:
return None
try:
parsed = urlparse(url)
prefix = f"/storage/v1/object/public/{bucket}/"
if prefix in parsed.path:
idx = parsed.path.index(prefix) + len(prefix)
return parsed.path[idx:]
except Exception as e:
logger.warning(f"Failed to extract storage path from URL '{url}': {e}")
return None
def delete_from_storage(supabase, bucket: str, url: str) -> bool:
"""
Delete a file from a Supabase Storage bucket given its public URL.
Returns True on success, False if the URL couldn't be parsed or deletion failed.
"""
path = extract_storage_path(url, bucket)
if not path:
return False
try:
supabase.storage.from_(bucket).remove([path])
logger.info(f"Deleted storage file: {bucket}/{path}")
return True
except Exception as e:
logger.warning(f"Failed to delete storage file {bucket}/{path}: {e}")
return False

View File

@@ -1,36 +0,0 @@
import httpx
import hashlib
import hmac
import logging
logger = logging.getLogger(__name__)
_META_API = "https://graph.facebook.com/v19.0"
async def send_message(phone_number_id: str, to: str, text: str, access_token: str) -> bool:
try:
async with httpx.AsyncClient(timeout=10) as client:
r = await client.post(
f"{_META_API}/{phone_number_id}/messages",
headers={"Authorization": f"Bearer {access_token}"},
json={
"messaging_product": "whatsapp",
"to": to,
"type": "text",
"text": {"body": text},
},
)
return r.status_code == 200
except Exception as e:
logger.error(f"WhatsApp send error: {e}")
return False
def verify_signature(payload: bytes, signature: str, app_secret: str) -> bool:
expected = "sha256=" + hmac.new(
app_secret.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)

View File

@@ -1,140 +1,182 @@
def generate_widget_js(app_url: str) -> str:
"""Generate the embeddable widget JavaScript with app_url baked in."""
return f"""
(function() {{
var APP_URL = "{app_url}";
// Find script tag to get chatbot ID
var scripts = document.querySelectorAll('script[data-chatbot]');
var chatbotId = null;
if (scripts.length > 0) {{
chatbotId = scripts[scripts.length - 1].getAttribute('data-chatbot');
}}
if (!chatbotId) {{
console.warn('[Contexta] No data-chatbot attribute found on script tag');
return;
}}
// Styles
var style = document.createElement('style');
style.textContent = `
.contexta-btn {{
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: #6366f1;
border: none;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
display: flex;
align-items: center;
justify-content: center;
z-index: 999998;
transition: transform 0.2s, box-shadow 0.2s;
}}
.contexta-btn:hover {{
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(0,0,0,0.25);
}}
.contexta-btn svg {{
width: 26px;
height: 26px;
fill: white;
}}
.contexta-container {{
position: fixed;
bottom: 92px;
right: 24px;
width: 380px;
height: 580px;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
z-index: 999999;
overflow: hidden;
display: none;
flex-direction: column;
border: 1px solid #e5e7eb;
}}
.contexta-container.open {{
display: flex;
}}
.contexta-iframe {{
width: 100%;
height: 100%;
border: none;
flex: 1;
}}
.contexta-close {{
position: absolute;
top: 8px;
right: 8px;
background: rgba(0,0,0,0.3);
border: none;
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}}
@media (max-width: 480px) {{
.contexta-container {{
bottom: 0;
right: 0;
width: 100vw;
height: 100vh;
border-radius: 0;
}}
}}
`;
document.head.appendChild(style);
// Button
var btn = document.createElement('button');
btn.className = 'contexta-btn';
btn.setAttribute('aria-label', 'Open chat');
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>';
document.body.appendChild(btn);
// Container with iframe
var container = document.createElement('div');
container.className = 'contexta-container';
var closeBtn = document.createElement('button');
closeBtn.className = 'contexta-close';
closeBtn.innerHTML = '&times;';
closeBtn.setAttribute('aria-label', 'Close chat');
container.appendChild(closeBtn);
var iframe = document.createElement('iframe');
iframe.className = 'contexta-iframe';
iframe.src = APP_URL + '/chat/' + chatbotId;
iframe.setAttribute('allow', 'microphone');
container.appendChild(iframe);
document.body.appendChild(container);
// Toggle logic
var isOpen = false;
function toggle() {{
isOpen = !isOpen;
if (isOpen) {{
container.classList.add('open');
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>';
}} else {{
container.classList.remove('open');
btn.innerHTML = '<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2z"/></svg>';
}}
}}
btn.addEventListener('click', toggle);
closeBtn.addEventListener('click', toggle);
}})();
"""
Widget JS generator.
Produces a self-contained, framework-agnostic JavaScript bundle served at
GET /widget.js. Embed on any page with:
<script
src="https://api.yoursite.com/widget.js"
data-chatbot="<chatbot-id>">
</script>
Works on vanilla HTML, WordPress, Webflow, Shopify, Next.js (_document),
and any framework where you control the HTML shell.
For React/Vue projects that want a native component, host-side devs can call
window.Contexta.open() / .close() / .toggle()
from their own button, or await the dedicated npm package (@contexta/widget).
Design decisions
----------------
- All CSS is ID-scoped (#ctxa-*) to avoid colliding with host-page styles.
- The iframe src is set lazily on first open — zero network cost until use.
- document.currentScript is captured synchronously (before any async code)
so it works even when the host page has many script tags.
- z-index 2147483647 is the highest a browser will honour.
- sandbox attribute restricts the iframe while still allowing forms/popups.
"""
_TEMPLATE = r"""(function () {
'use strict';
/* ── Double-init guard ─────────────────────────────────────────────── */
if (window.__ctxa) return;
/* ── Read chatbot ID from <script data-chatbot="..."> ──────────────── */
var _cur = document.currentScript;
var _id = _cur && _cur.getAttribute('data-chatbot');
if (!_id) {
var _all = document.querySelectorAll('script[data-chatbot]');
if (_all.length) _id = _all[_all.length - 1].getAttribute('data-chatbot');
}
if (!_id) {
console.warn('[Contexta] widget loaded but no data-chatbot attribute found.');
return;
}
var _base = '__APP_URL__';
var _url = _base + '/chat/' + _id;
/* ── Styles ─────────────────────────────────────────────────────────── */
var _css = [
'#ctxa-btn{',
'position:fixed;bottom:24px;right:24px;',
'width:56px;height:56px;border-radius:50%;',
'background:linear-gradient(135deg,#6366f1,#4f46e5);',
'border:none;cursor:pointer;padding:0;',
'box-shadow:0 4px 20px rgba(99,102,241,.45);',
'display:flex;align-items:center;justify-content:center;',
'z-index:2147483646;',
'transition:transform .2s ease,box-shadow .2s ease;',
'outline:none;',
'}',
'#ctxa-btn:hover{transform:scale(1.1);box-shadow:0 6px 28px rgba(99,102,241,.55)}',
'#ctxa-btn:focus-visible{outline:2px solid #6366f1;outline-offset:3px}',
/* Icon animations */
'#ctxa-ico-chat,#ctxa-ico-x{position:absolute;transition:opacity .15s ease,transform .2s ease}',
'#ctxa-ico-x{opacity:0;transform:rotate(-90deg)}',
'#ctxa-btn.open #ctxa-ico-chat{opacity:0;transform:rotate(90deg)}',
'#ctxa-btn.open #ctxa-ico-x{opacity:1;transform:rotate(0)}',
/* Panel */
'#ctxa-panel{',
'position:fixed;bottom:92px;right:24px;',
'width:380px;height:600px;',
'max-height:calc(100dvh - 120px);',
'border-radius:20px;overflow:hidden;background:#fff;',
'box-shadow:0 20px 60px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.07);',
'z-index:2147483647;',
'opacity:0;transform:translateY(12px) scale(.97);pointer-events:none;',
'transition:opacity .22s cubic-bezier(.4,0,.2,1),transform .22s cubic-bezier(.4,0,.2,1);',
'}',
'#ctxa-panel.open{opacity:1;transform:translateY(0) scale(1);pointer-events:all}',
'#ctxa-frame{width:100%;height:100%;border:none;display:block;background:#fff}',
/* Mobile: full-screen */
'@media(max-width:480px){',
'#ctxa-panel{bottom:0;right:0;left:0;width:100%;height:100%;',
'max-height:100dvh;border-radius:0;box-shadow:none}',
'#ctxa-btn{bottom:16px;right:16px}',
'}',
].join('');
var _style = document.createElement('style');
_style.textContent = _css;
document.head.appendChild(_style);
/* ── Button ─────────────────────────────────────────────────────────── */
var _btn = document.createElement('button');
_btn.id = 'ctxa-btn';
_btn.setAttribute('aria-label', 'Open chat');
_btn.setAttribute('aria-expanded', 'false');
_btn.innerHTML = (
'<svg id="ctxa-ico-chat" width="24" height="24" viewBox="0 0 24 24" fill="none">' +
'<path d="M12 2C6.477 2 2 6.2 2 11.4c0 2.8 1.26 5.3 3.26 7.04L4 22l4.2-1.75' +
'A11.1 11.1 0 0 0 12 20.8c5.523 0 10-4.2 10-9.4S17.523 2 12 2z" fill="white"/>' +
'</svg>' +
'<svg id="ctxa-ico-x" width="20" height="20" viewBox="0 0 24 24" fill="none">' +
'<path d="M18 6L6 18M6 6l12 12" stroke="white" stroke-width="2.5"' +
' stroke-linecap="round" stroke-linejoin="round"/>' +
'</svg>'
);
/* ── Panel + lazy iframe ─────────────────────────────────────────────── */
var _panel = document.createElement('div');
_panel.id = 'ctxa-panel';
_panel.setAttribute('role', 'dialog');
_panel.setAttribute('aria-label', 'Chat');
var _frame = document.createElement('iframe');
_frame.id = 'ctxa-frame';
_frame.title = 'Contexta chat';
_frame.setAttribute('allow', 'clipboard-write');
/* sandbox: scripts + same-origin needed for the React app to run */
_frame.setAttribute('sandbox',
'allow-scripts allow-same-origin allow-forms allow-popups allow-top-navigation-by-user-activation'
);
_panel.appendChild(_frame);
/* ── Mount after DOM ready ───────────────────────────────────────────── */
function _mount() {
document.body.appendChild(_btn);
document.body.appendChild(_panel);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _mount);
} else {
_mount();
}
/* ── Open / Close ────────────────────────────────────────────────────── */
var _isOpen = false;
var _loaded = false;
function _open() {
if (!_loaded) { _frame.src = _url; _loaded = true; }
_isOpen = true;
_panel.classList.add('open');
_btn.classList.add('open');
_btn.setAttribute('aria-expanded', 'true');
_btn.setAttribute('aria-label', 'Close chat');
}
function _close() {
_isOpen = false;
_panel.classList.remove('open');
_btn.classList.remove('open');
_btn.setAttribute('aria-expanded', 'false');
_btn.setAttribute('aria-label', 'Open chat');
}
_btn.addEventListener('click', function () { _isOpen ? _close() : _open(); });
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && _isOpen) _close();
});
/* ── Public API ──────────────────────────────────────────────────────── */
window.__ctxa = true;
window.Contexta = {
open: _open,
close: _close,
toggle: function () { _isOpen ? _close() : _open(); },
};
}());
"""
def generate_widget_js(app_url: str) -> str:
"""Return the widget bundle with the frontend app URL baked in."""
return _TEMPLATE.replace('__APP_URL__', app_url.rstrip('/'))