diff --git a/CLAUDE.md b/CLAUDE.md index a87eb43..3b7ff02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ > **Audience:** AI assistants and developers working on this codebase. > **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. -> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); MT-9 iOS pending**) +> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); support chat persistence + knowledge base (phase40); MT-9 iOS pending**) --- @@ -381,6 +381,27 @@ facility_score_alerts: id, facility_id (FK→facilities CASCADE), sent_at DATETI Records each score-trend alert dispatched for a facility. `send_score_alerts()` queries this table to skip re-alerting a facility within the last 24 hours, preventing notification storms on persistent score drops. +### SupportChatSession / SupportChatMessage (phase40) + +``` +support_chat_sessions: id, customer_id (FK→users SET NULL), title VARCHAR(200) nullable, + created_at DATETIME, last_msg_at DATETIME + +support_chat_messages: id, session_id (FK→support_chat_sessions CASCADE), + role VARCHAR(20) ('user'/'assistant'), content TEXT, created_at DATETIME +``` + +AI chat conversations are now persisted to the DB. `GET /support/chat?session_id=N` loads a prior session's history. The `POST /support/chat/message` endpoint creates a new session (via `flush()`) on first message and persists both turns after the Groq call succeeds; it rolls back if Groq fails (no empty sessions). The session's `title` is auto-set from the first user message (truncated to 100 chars). History is loaded from DB for Groq context (last 40 messages) — the client no longer sends the history array. + +### SupportKnowledge (phase40) + +``` +support_knowledge: id, title VARCHAR(200), body TEXT, active BOOL DEFAULT TRUE, + created_by (FK→users SET NULL), created_at DATETIME, updated_at DATETIME +``` + +Admin-curated knowledge base entries. Active entries are appended to the Groq system prompt via `_system_prompt_with_kb()`, capped at `_KB_MAX_CHARS = 6000`. Managed at `/support/admin/knowledge` (`@supervisor_required`): add, edit, toggle active/inactive, delete. Deactivated entries are preserved but skipped from the system prompt. + ### SupportTicket / SupportTicketReply ``` @@ -520,7 +541,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were ** | `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) | | `inspection_schedules` | `/inspection-schedules` | phase34 — recurring inspection CRUD (`@project_manager_required`) + `POST /run-now` (manual) + `POST /run` (token-protected cron materialiser) | | `work_orders` | `/work-orders` | phase36 — **public, login-less** vendor pages: `GET /` (contractor view) + `POST /` (acknowledge/complete). Token is the authorization. Staff dispatch is `POST /issues//work-order` on the `issues` blueprint (`@project_manager_required`). | -| `support` | `/support` | `GET /chat`, `POST /chat/message` (AJAX→Groq), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/`, `GET /admin/tickets`, `GET/POST /admin/tickets/` | +| `support` | `/support` | `GET /chat` (accepts `?session_id=`), `POST /chat/message` (AJAX→Groq, persists turns, returns `session_id`), `POST /tickets`, `GET /my-tickets`, `GET/POST /my-tickets/`, `GET /my-conversations`, `GET /my-conversations/`, `GET /admin/tickets`, `GET/POST /admin/tickets/`, `GET /admin/conversations`, `GET /admin/conversations/`, `GET /admin/knowledge`, `POST /admin/knowledge/add`, `GET/POST /admin/knowledge//edit`, `POST /admin/knowledge//toggle`, `POST /admin/knowledge//delete` (phase40) | | `broadcast` | `/admin/broadcast` | `GET /` (compose + history), `POST /send` — admin-only push to iOS via Notification rows (phase29) | | `devices` | `/admin/devices` | `GET /` (registered device list), `POST /notify` — notify users on outdated app versions (reads `api_device_tokens`) | | `api` | `/api/v1` | parent blueprint | @@ -791,7 +812,7 @@ limiter = Limiter( ## 17. Alembic Migration Chain -**Current HEAD:** `phase38_facility_qr` (36 migrations total). +**Current HEAD:** `phase40_support_chat_kb` (38 migrations total). **Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`. @@ -825,7 +846,22 @@ limiter = Limiter( → phase36_issue_work_orders → phase37_contract_recipients → phase38_facility_qr - → phase39_issue_handler_type ← HEAD + → phase39_issue_handler_type → phase40_support_chat_kb ← HEAD +``` + +### phase40_support_chat_kb + +Creates three tables backing support chat persistence and the AI knowledge base: +- `support_chat_sessions` — one row per customer chat thread (`customer_id`, `title`, `created_at`, `last_msg_at`) +- `support_chat_messages` — individual turns (`session_id` CASCADE, `role` user/assistant, `content`, `created_at`) +- `support_knowledge` — admin-curated chatbot context entries (`title`, `body`, `active`, `created_by`, `created_at`, `updated_at`) + +All three tables guarded with `INFORMATION_SCHEMA` table-existence checks — safe to re-run. `down_revision = 'phase39_issue_handler_type'`. + +**Deploy order:** +```bash +flask db upgrade +sudo systemctl restart gunicorn ``` ### phase39_issue_handler_type @@ -1370,6 +1406,8 @@ set -a; . /etc/jqc/control.env; set +a | 92 | **`POST /f//report` creates issues with `reported_by=None` — honeypot protects it** | phase39. The occupant report endpoint shares the same authorization model as rule 91 (token = credential, no login). The honeypot field (`name="website"`, CSS-hidden, `position:absolute;left:-9999px`) silently drops bot submissions by redirecting to the success URL without creating an issue. Rate-limited `5/hr` per IP. The notification fires `notify_by_matrix('issue_created', issue_id=..., facility_id=...)` so admins are notified via the standard matrix. Do not add login gates, photo upload, or internal fields (assignee, comments) to this form — it is intentionally minimal. | | 93 | **ProxyFix must wrap `app.wsgi_app` — without it, rate limiting and fail2ban are broken** | `app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)` reads `X-Forwarded-For` set by Nginx. Without it, `get_remote_address()` returns `127.0.0.1` for every request — Flask-Limiter shares one counter across all users and fail2ban can never ban an attacker's real IP. Always set in `create_app()` immediately after `app = Flask(__name__)`. | | 94 | **`handler_type` NULL and `'internal'` are equivalent** | NULL means the column was not set (pre-phase39 row or unmodified new row); the application treats both as "Janitorial Staff". The dashboard `handler_breakdown['internal']` counter and the `?handler_type=internal` issues-list filter both use `db.or_(Issue.handler_type == 'internal', Issue.handler_type.is_(None))`. Never coerce NULL to 'internal' at the DB layer — the nullable default is intentional for backwards compatibility. | +| 95 | **Chat history is loaded from DB — never pass client-sent history to Groq** | phase40. `POST /support/chat/message` loads prior turns from `SupportChatMessage` (newest-first, limit 40, reversed). The JSON body sends only `{ message, session_id }` — no history array. This prevents history tampering by clients and ensures accuracy across page reloads. | +| 96 | **`db.session.flush()` to get session ID before first message insert** | When creating a new `SupportChatSession` in `chat_message()`, call `db.session.flush()` after `db.session.add(chat_session)` to get the autoincrement `id` before constructing `SupportChatMessage` rows. If Groq fails, `db.session.rollback()` undoes the flush — no orphaned empty session is left in the DB. | --- diff --git a/app/models/support.py b/app/models/support.py index f2b89c4..706a7cb 100644 --- a/app/models/support.py +++ b/app/models/support.py @@ -2,6 +2,60 @@ from app import db from app.utils.time_utils import now_eastern +class SupportChatSession(db.Model): + __tablename__ = 'support_chat_sessions' + + id = db.Column(db.Integer, primary_key=True) + customer_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True) + title = db.Column(db.String(200), nullable=True) + created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + last_msg_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + + customer = db.relationship('User', foreign_keys=[customer_id], backref='chat_sessions') + messages = db.relationship( + 'SupportChatMessage', back_populates='session', + cascade='all, delete-orphan', + order_by='SupportChatMessage.created_at', + ) + + def __repr__(self): + return f'' + + +class SupportChatMessage(db.Model): + __tablename__ = 'support_chat_messages' + + id = db.Column(db.Integer, primary_key=True) + session_id = db.Column(db.Integer, + db.ForeignKey('support_chat_sessions.id', ondelete='CASCADE'), + nullable=False) + role = db.Column(db.String(20), nullable=False) # 'user' | 'assistant' + content = db.Column(db.Text, nullable=False) + created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + + session = db.relationship('SupportChatSession', back_populates='messages') + + def __repr__(self): + return f'' + + +class SupportKnowledge(db.Model): + __tablename__ = 'support_knowledge' + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=False) + body = db.Column(db.Text, nullable=False) + active = db.Column(db.Boolean, default=True, nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True) + created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + updated_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + + creator = db.relationship('User', foreign_keys=[created_by]) + + def __repr__(self): + return f'' + + class SupportTicket(db.Model): __tablename__ = 'support_tickets' diff --git a/app/routes/support.py b/app/routes/support.py index 2db4b9e..8594c7b 100644 --- a/app/routes/support.py +++ b/app/routes/support.py @@ -6,12 +6,14 @@ from flask import (Blueprint, render_template, redirect, url_for, from flask_login import login_required, current_user from app import db -from app.models.support import SupportTicket, SupportTicketReply +from app.models.support import (SupportTicket, SupportTicketReply, + SupportChatSession, SupportChatMessage, + SupportKnowledge) from app.models.user import User from app.models.facility import Facility from app.utils.decorators import supervisor_required from app.utils.scope import get_customer_scope -from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE +from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.time_utils import now_eastern from app.utils.notifications import notify @@ -41,17 +43,33 @@ cannot resolve through guidance, say so clearly and suggest they click \ "Submit to Support" to reach the admin team directly.\ """ +_KB_MAX_CHARS = 6000 + # Preset FAQ questions shown as quick-reply chips on first load FAQS = [ - {'icon': 'bi-clipboard-check', 'text': 'How do I view my inspection reports?'}, - {'icon': 'bi-graph-up', 'text': 'What do inspection scores mean?'}, - {'icon': 'bi-exclamation-circle','text': 'How do I track an open issue?'}, - {'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'}, - {'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'}, - {'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'}, + {'icon': 'bi-clipboard-check', 'text': 'How do I view my inspection reports?'}, + {'icon': 'bi-graph-up', 'text': 'What do inspection scores mean?'}, + {'icon': 'bi-exclamation-circle', 'text': 'How do I track an open issue?'}, + {'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'}, + {'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'}, + {'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'}, ] +def _system_prompt_with_kb(): + """Return the Groq system prompt, appending active knowledge base entries.""" + try: + entries = SupportKnowledge.query.filter_by(active=True).order_by(SupportKnowledge.id).all() + except Exception: + return _SYSTEM_PROMPT + if not entries: + return _SYSTEM_PROMPT + kb_text = '\n\n'.join(f'[{e.title}]\n{e.body}' for e in entries) + if len(kb_text) > _KB_MAX_CHARS: + kb_text = kb_text[:_KB_MAX_CHARS] + '\n…(truncated)' + return _SYSTEM_PROMPT + '\n\n# Additional Context\n' + kb_text + + # ── Customer chat page ──────────────────────────────────────────────────────── @bp.route('/chat') @@ -65,11 +83,25 @@ def chat(): .filter(Facility.id.in_(cids), Facility.active == True) .order_by(Facility.name).all()) if cids else [] - groq_ready = bool(os.environ.get('GROQ_API_KEY')) + groq_ready = bool(os.environ.get('GROQ_API_KEY')) + session_id = request.args.get('session_id', type=int) + chat_session = None + db_history = [] + + if session_id: + chat_session = db.session.get(SupportChatSession, session_id) + # Security: only show this customer's own sessions + if chat_session and chat_session.customer_id != current_user.id: + chat_session = None + if chat_session: + db_history = list(chat_session.messages) + return render_template('support/chat.html', faqs=FAQS, facilities=facilities, - groq_ready=groq_ready) + groq_ready=groq_ready, + chat_session=chat_session, + db_history=db_history) # ── Groq chat AJAX endpoint ─────────────────────────────────────────────────── @@ -88,21 +120,47 @@ def chat_message(): )}) data = request.get_json(silent=True) or {} - history = data.get('messages', []) # list of {role, content} dicts user_message = data.get('message', '').strip() + client_sid = data.get('session_id') if not user_message: return jsonify({'error': 'Empty message'}), 400 + # ── Resolve or create a chat session ────────────────────────────────── + chat_session = None + if client_sid: + try: + chat_session = db.session.get(SupportChatSession, int(client_sid)) + except (TypeError, ValueError): + chat_session = None + # Security: disallow cross-customer session access + if chat_session and chat_session.customer_id != current_user.id: + chat_session = None + + if chat_session is None: + chat_session = SupportChatSession( + customer_id = current_user.id, + title = user_message[:100], + created_at = now_eastern(), + last_msg_at = now_eastern(), + ) + db.session.add(chat_session) + db.session.flush() # get autoincrement ID before referencing in messages + + # ── Load prior turns from DB for Groq context ───────────────────────── + prior = (SupportChatMessage.query + .filter_by(session_id=chat_session.id) + .order_by(SupportChatMessage.created_at.desc()) + .limit(40).all()) + prior = list(reversed(prior)) # oldest → newest + try: from groq import Groq client = Groq(api_key=api_key) - messages = [{'role': 'system', 'content': _SYSTEM_PROMPT}] - # Append prior conversation (cap at last 20 turns to control token usage) - for m in history[-20:]: - if m.get('role') in ('user', 'assistant') and m.get('content'): - messages.append({'role': m['role'], 'content': m['content']}) + messages = [{'role': 'system', 'content': _system_prompt_with_kb()}] + for m in prior[-20:]: + messages.append({'role': m.role, 'content': m.content}) messages.append({'role': 'user', 'content': user_message}) model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile') @@ -113,16 +171,55 @@ def chat_message(): temperature=0.5, ) reply = completion.choices[0].message.content.strip() - return jsonify({'reply': reply}) + + # Persist both turns and update session timestamp + now = now_eastern() + db.session.add(SupportChatMessage( + session_id=chat_session.id, role='user', content=user_message, created_at=now)) + db.session.add(SupportChatMessage( + session_id=chat_session.id, role='assistant', content=reply, created_at=now)) + chat_session.last_msg_at = now + db.session.commit() + + return jsonify({'reply': reply, 'session_id': chat_session.id}) except Exception as exc: logger.error('SUPPORT | Groq error: %s', exc) + db.session.rollback() return jsonify({'reply': ( "I ran into a problem reaching the AI assistant. " "Please try again, or use **Submit to Support** to contact our team." )}) +# ── Customer: my chat conversations ────────────────────────────────────────── + +@bp.route('/my-conversations') +@login_required +def my_conversations(): + if current_user.role != 'customer': + abort(403) + sessions = (SupportChatSession.query + .filter_by(customer_id=current_user.id) + .order_by(SupportChatSession.last_msg_at.desc()) + .all()) + return render_template('support/my_conversations.html', sessions=sessions) + + +@bp.route('/my-conversations/') +@login_required +def my_conversation_detail(session_id): + if current_user.role != 'customer': + abort(403) + chat_session = db.session.get(SupportChatSession, session_id) + if chat_session is None or chat_session.customer_id != current_user.id: + abort(404) + return render_template('support/conversation_detail.html', + chat_session=chat_session, + messages=list(chat_session.messages), + is_admin=False) + + # ── Submit support ticket ───────────────────────────────────────────────────── @bp.route('/tickets', methods=['POST']) @@ -235,20 +332,14 @@ def _notify_admins_new_ticket(ticket): customer_label = ticket.customer.display_name if ticket.customer else 'Unknown' facility_label = ticket.facility.name if ticket.facility else 'N/A' - link = url_for('support.admin_ticket_detail', ticket_id=ticket.id) + link = url_for('support.admin_ticket_detail', ticket_id=ticket.id) title = f'New support ticket #{ticket.id} from {customer_label}' body = (f'Subject: {ticket.subject}\n' f'Facility: {facility_label}\n\n' f'{ticket.body[:300]}{"…" if len(ticket.body) > 300 else ""}') for admin in admins: - notify( - recipient = admin, - title = title, - body = body, - link = link, - send_email = True, - ) + notify(recipient=admin, title=title, body=body, link=link, send_email=True) db.session.commit() @@ -338,13 +429,7 @@ def _notify_customer_reply(ticket, reply): f'{reply.body[:400]}{"…" if len(reply.body) > 400 else ""}') link = url_for('support.my_ticket_detail', ticket_id=ticket.id) - notify( - recipient = ticket.customer, - title = title, - body = body, - link = link, - send_email = True, - ) + notify(recipient=ticket.customer, title=title, body=body, link=link, send_email=True) db.session.commit() @@ -361,11 +446,122 @@ def _notify_admins_customer_reply(ticket, reply): f'{reply.body[:400]}{"…" if len(reply.body) > 400 else ""}') for admin in admins: - notify( - recipient = admin, - title = title, - body = body, - link = link, - send_email = True, - ) + notify(recipient=admin, title=title, body=body, link=link, send_email=True) db.session.commit() + + +# ── Admin: AI conversations list ────────────────────────────────────────────── + +@bp.route('/admin/conversations') +@login_required +@supervisor_required +def admin_conversations(): + page = request.args.get('page', 1, type=int) + sessions = (SupportChatSession.query + .order_by(SupportChatSession.last_msg_at.desc()) + .paginate(page=page, per_page=25, error_out=False)) + return render_template('support/admin_conversations.html', sessions=sessions) + + +@bp.route('/admin/conversations/') +@login_required +@supervisor_required +def admin_conversation_detail(session_id): + chat_session = db.session.get(SupportChatSession, session_id) + if chat_session is None: + abort(404) + return render_template('support/conversation_detail.html', + chat_session=chat_session, + messages=list(chat_session.messages), + is_admin=True) + + +# ── Admin: knowledge base ───────────────────────────────────────────────────── + +@bp.route('/admin/knowledge') +@login_required +@supervisor_required +def admin_knowledge(): + entries = SupportKnowledge.query.order_by(SupportKnowledge.created_at.desc()).all() + return render_template('support/admin_knowledge.html', entries=entries) + + +@bp.route('/admin/knowledge/add', methods=['POST']) +@login_required +@supervisor_required +def admin_knowledge_add(): + title = request.form.get('title', '').strip() + body = request.form.get('body', '').strip() + if not title or not body: + flash('Title and body are both required.', 'warning') + return redirect(url_for('support.admin_knowledge')) + + entry = SupportKnowledge( + title = title, + body = body, + active = True, + created_by = current_user.id, + created_at = now_eastern(), + updated_at = now_eastern(), + ) + db.session.add(entry) + db.session.commit() + log_action(ACTION_CREATE, 'SupportKnowledge', entry.id, title[:60], '') + flash('Knowledge entry added.', 'success') + return redirect(url_for('support.admin_knowledge')) + + +@bp.route('/admin/knowledge//edit', methods=['GET', 'POST']) +@login_required +@supervisor_required +def admin_knowledge_edit(entry_id): + entry = db.session.get(SupportKnowledge, entry_id) + if entry is None: + abort(404) + + if request.method == 'POST': + title = request.form.get('title', '').strip() + body = request.form.get('body', '').strip() + if not title or not body: + flash('Title and body are both required.', 'warning') + return redirect(url_for('support.admin_knowledge_edit', entry_id=entry_id)) + entry.title = title + entry.body = body + entry.updated_at = now_eastern() + db.session.commit() + log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, title[:60], 'edited') + flash('Knowledge entry updated.', 'success') + return redirect(url_for('support.admin_knowledge')) + + return render_template('support/admin_knowledge_edit.html', entry=entry) + + +@bp.route('/admin/knowledge//toggle', methods=['POST']) +@login_required +@supervisor_required +def admin_knowledge_toggle(entry_id): + entry = db.session.get(SupportKnowledge, entry_id) + if entry is None: + abort(404) + entry.active = not entry.active + entry.updated_at = now_eastern() + db.session.commit() + log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, entry.title[:60], + f'active={entry.active}') + flash(f'Entry {"activated" if entry.active else "deactivated"}.', 'success') + return redirect(url_for('support.admin_knowledge')) + + +@bp.route('/admin/knowledge//delete', methods=['POST']) +@login_required +@supervisor_required +def admin_knowledge_delete(entry_id): + entry = db.session.get(SupportKnowledge, entry_id) + if entry is None: + abort(404) + label = entry.title[:60] + db.session.delete(entry) + db.session.commit() + log_action(ACTION_DELETE, 'SupportKnowledge', entry_id, label, '') + flash('Knowledge entry deleted.', 'success') + return redirect(url_for('support.admin_knowledge')) diff --git a/app/templates/support/admin_conversations.html b/app/templates/support/admin_conversations.html new file mode 100644 index 0000000..b55fc24 --- /dev/null +++ b/app/templates/support/admin_conversations.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block title %}AI Chat Conversations{% endblock %} + +{% block content %} +
+
+

AI Chat Conversations

+ {{ sessions.total }} conversation{{ 's' if sessions.total != 1 }} +
+ +
+ +{% if sessions.items %} +
+
+ + + + + + + + + + + + + {% for s in sessions.items %} + + + + + + + + + {% endfor %} + +
#CustomerTopicMessagesLast Activity
{{ s.id }}{{ s.customer.display_name if s.customer else 'deleted' | safe }}{{ (s.title or '—')[:80] }}{{ s.messages | length }}{{ s.last_msg_at.strftime('%b %d, %Y %I:%M %p') }} + + View + +
+
+
+ +{% if sessions.pages > 1 %} + +{% endif %} + +{% else %} +
+ + No AI chat conversations yet. +
+{% endif %} +{% endblock %} diff --git a/app/templates/support/admin_knowledge.html b/app/templates/support/admin_knowledge.html new file mode 100644 index 0000000..d24e814 --- /dev/null +++ b/app/templates/support/admin_knowledge.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} +{% block title %}Support Knowledge Base{% endblock %} + +{% block content %} +
+
+

Support Knowledge Base

+ Active entries are injected into the AI chatbot system prompt +
+ +
+ +{# Add new entry form #} +
+
Add Knowledge Entry
+
+
+ +
+ + +
+
+ + +
Keep entries focused and factual. Combined active entries are capped at 6,000 characters.
+
+ +
+
+
+ +{# Entry list #} +{% if entries %} +
+
+ {{ entries | length }} entr{{ 'ies' if entries | length != 1 else 'y' }} + ({{ entries | selectattr('active') | list | length }} active) +
+
+ {% for entry in entries %} +
+
+
+
+ {{ entry.title }} + {% if entry.active %} + Active + {% else %} + Inactive + {% endif %} +
+

{{ entry.body[:300] }}{% if entry.body | length > 300 %}…{% endif %}

+
+ Added {{ entry.created_at.strftime('%b %d, %Y') }} + {% if entry.creator %} by {{ entry.creator.display_name }}{% endif %} + {% if entry.updated_at != entry.created_at %} + · Updated {{ entry.updated_at.strftime('%b %d, %Y') }} + {% endif %} +
+
+
+ + + +
+ + +
+
+ + +
+
+
+
+ {% endfor %} +
+
+{% else %} +
+ + No knowledge entries yet. Add context above to improve the AI assistant's answers. +
+{% endif %} +{% endblock %} diff --git a/app/templates/support/admin_knowledge_edit.html b/app/templates/support/admin_knowledge_edit.html new file mode 100644 index 0000000..d7addec --- /dev/null +++ b/app/templates/support/admin_knowledge_edit.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Edit Knowledge Entry{% endblock %} + +{% block content %} + + +
+
Edit Knowledge Entry
+
+
+ +
+ + +
+
+ + +
Combined active entries are capped at 6,000 characters in the AI prompt.
+
+
+ + Cancel +
+
+
+
+{% endblock %} diff --git a/app/templates/support/admin_tickets.html b/app/templates/support/admin_tickets.html index b591d9b..9015c26 100644 --- a/app/templates/support/admin_tickets.html +++ b/app/templates/support/admin_tickets.html @@ -7,6 +7,14 @@

Customer Support Tickets

{{ tickets.total }} ticket{{ 's' if tickets.total != 1 }} + {# Status filter tabs #} diff --git a/app/templates/support/chat.html b/app/templates/support/chat.html index 4450b36..94c1945 100644 --- a/app/templates/support/chat.html +++ b/app/templates/support/chat.html @@ -42,11 +42,23 @@

JQC Support Chat

- Ask a question or browse common topics below + + {% if chat_session %} + Conversation from {{ chat_session.created_at.strftime('%b %d, %Y') }} + · New Chat + {% else %} + Ask a question or browse common topics below + {% endif %} + +
+
+ + History + +
-
{# ── Chat card ── #} @@ -54,8 +66,9 @@ {# Message window #}
- {# FAQ quick-reply chips #} -
+ {# FAQ quick-reply chips — hidden when viewing a prior session #} +

Common questions:

{% for faq in faqs %} @@ -114,7 +127,7 @@ - - New Chat / Ask a Question - +
{% if tickets %} diff --git a/migrations/versions/phase40_support_chat_kb.py b/migrations/versions/phase40_support_chat_kb.py new file mode 100644 index 0000000..c9e3845 --- /dev/null +++ b/migrations/versions/phase40_support_chat_kb.py @@ -0,0 +1,62 @@ +"""Add support_chat_sessions, support_chat_messages, support_knowledge (phase40).""" +from alembic import op +import sqlalchemy as sa + +revision = 'phase40_support_chat_kb' +down_revision = 'phase39_issue_handler_type' +branch_labels = None +depends_on = None + + +def _table_exists(conn, table): + row = conn.execute(sa.text(""" + SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t + """), {'t': table}).scalar() + return bool(row) + + +def upgrade(): + conn = op.get_bind() + + if not _table_exists(conn, 'support_chat_sessions'): + op.create_table( + 'support_chat_sessions', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('customer_id', sa.Integer, + sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('title', sa.String(200), nullable=True), + sa.Column('created_at', sa.DateTime, nullable=False), + sa.Column('last_msg_at', sa.DateTime, nullable=False), + ) + + if not _table_exists(conn, 'support_chat_messages'): + op.create_table( + 'support_chat_messages', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('session_id', sa.Integer, + sa.ForeignKey('support_chat_sessions.id', ondelete='CASCADE'), + nullable=False), + sa.Column('role', sa.String(20), nullable=False), + sa.Column('content', sa.Text, nullable=False), + sa.Column('created_at', sa.DateTime, nullable=False), + ) + + if not _table_exists(conn, 'support_knowledge'): + op.create_table( + 'support_knowledge', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('title', sa.String(200), nullable=False), + sa.Column('body', sa.Text, nullable=False), + sa.Column('active', sa.Boolean, nullable=False, server_default='1'), + sa.Column('created_by', sa.Integer, + sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True), + sa.Column('created_at', sa.DateTime, nullable=False), + sa.Column('updated_at', sa.DateTime, nullable=False), + ) + + +def downgrade(): + op.drop_table('support_knowledge') + op.drop_table('support_chat_messages') + op.drop_table('support_chat_sessions')