Jul 10 - Update codes to catch up with the single tenant project (Medium)

This commit is contained in:
2026-07-10 12:58:57 -04:00
parent e41998b561
commit aa749107c7
12 changed files with 815 additions and 68 deletions
+42 -4
View File
@@ -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, phase3032 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 AE + 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 R1R4 + 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, phase3032 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 AE + 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 R1R4 + 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 /<token>` (contractor view) + `POST /<token>` (acknowledge/complete). Token is the authorization. Staff dispatch is `POST /issues/<id>/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/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>` |
| `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/<id>`, `GET /my-conversations`, `GET /my-conversations/<id>`, `GET /admin/tickets`, `GET/POST /admin/tickets/<id>`, `GET /admin/conversations`, `GET /admin/conversations/<id>`, `GET /admin/knowledge`, `POST /admin/knowledge/add`, `GET/POST /admin/knowledge/<id>/edit`, `POST /admin/knowledge/<id>/toggle`, `POST /admin/knowledge/<id>/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/<token>/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. |
---
+54
View File
@@ -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'<SupportChatSession {self.id}>'
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'<SupportChatMessage {self.id} [{self.role}]>'
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'<SupportKnowledge {self.id} "{self.title[:30]}">'
class SupportTicket(db.Model):
__tablename__ = 'support_tickets'
+235 -39
View File
@@ -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/<int:session_id>')
@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/<int:session_id>')
@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/<int:entry_id>/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/<int:entry_id>/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/<int:entry_id>/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'))
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}AI Chat Conversations{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>AI Chat Conversations</h4>
<small class="text-muted">{{ sessions.total }} conversation{{ 's' if sessions.total != 1 }}</small>
</div>
<div>
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm me-1">
<i class="bi bi-inbox me-1"></i>Tickets
</a>
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-book me-1"></i>Knowledge Base
</a>
</div>
</div>
{% if sessions.items %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>#</th>
<th>Customer</th>
<th>Topic</th>
<th>Messages</th>
<th>Last Activity</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in sessions.items %}
<tr>
<td class="text-muted small">{{ s.id }}</td>
<td>{{ s.customer.display_name if s.customer else '<em class="text-muted">deleted</em>' | safe }}</td>
<td>{{ (s.title or '—')[:80] }}</td>
<td class="text-muted small">{{ s.messages | length }}</td>
<td class="text-muted small text-nowrap">{{ s.last_msg_at.strftime('%b %d, %Y %I:%M %p') }}</td>
<td>
<a href="{{ url_for('support.admin_conversation_detail', session_id=s.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye me-1"></i>View
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if sessions.pages > 1 %}
<nav class="mt-3">
<ul class="pagination justify-content-center mb-0">
<li class="page-item {% if not sessions.has_prev %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('support.admin_conversations', page=sessions.prev_num) }}">&laquo; Prev</a>
</li>
{% for p in sessions.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == sessions.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('support.admin_conversations', page=p) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
<li class="page-item {% if not sessions.has_next %}disabled{% endif %}">
<a class="page-link" href="{{ url_for('support.admin_conversations', page=sessions.next_num) }}">Next &raquo;</a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-chat-dots fs-1 d-block mb-2"></i>
No AI chat conversations yet.
</div>
{% endif %}
{% endblock %}
+104
View File
@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}Support Knowledge Base{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-book me-2 text-primary"></i>Support Knowledge Base</h4>
<small class="text-muted">Active entries are injected into the AI chatbot system prompt</small>
</div>
<div>
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-outline-secondary btn-sm me-1">
<i class="bi bi-inbox me-1"></i>Tickets
</a>
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-chat-dots me-1"></i>Conversations
</a>
</div>
</div>
{# Add new entry form #}
<div class="card shadow-sm mb-4">
<div class="card-header fw-semibold"><i class="bi bi-plus-circle me-2"></i>Add Knowledge Entry</div>
<div class="card-body">
<form method="post" action="{{ url_for('support.admin_knowledge_add') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-semibold">Title <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-control" maxlength="200" required
placeholder="e.g. Contract Hours, Emergency Contacts, Service Scope">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Content <span class="text-danger">*</span></label>
<textarea name="body" class="form-control" rows="4" required
placeholder="Add facts, FAQs, or instructions the AI should know about this customer's account…"></textarea>
<div class="form-text">Keep entries focused and factual. Combined active entries are capped at 6,000 characters.</div>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-plus me-1"></i>Add Entry
</button>
</form>
</div>
</div>
{# Entry list #}
{% if entries %}
<div class="card shadow-sm">
<div class="card-header fw-semibold">
{{ entries | length }} entr{{ 'ies' if entries | length != 1 else 'y' }}
({{ entries | selectattr('active') | list | length }} active)
</div>
<div class="list-group list-group-flush">
{% for entry in entries %}
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1 me-3">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="fw-semibold">{{ entry.title }}</span>
{% if entry.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</div>
<p class="mb-1 text-muted small" style="white-space:pre-wrap;">{{ entry.body[:300] }}{% if entry.body | length > 300 %}…{% endif %}</p>
<div class="text-muted" style="font-size:.75rem;">
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 %}
</div>
</div>
<div class="d-flex gap-1 flex-shrink-0">
<a href="{{ url_for('support.admin_knowledge_edit', entry_id=entry.id) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i>
</a>
<form method="post" action="{{ url_for('support.admin_knowledge_toggle', entry_id=entry.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm {{ 'btn-outline-warning' if entry.active else 'btn-outline-success' }}"
title="{{ 'Deactivate' if entry.active else 'Activate' }}">
<i class="bi bi-{{ 'pause' if entry.active else 'play' }}"></i>
</button>
</form>
<form method="post" action="{{ url_for('support.admin_knowledge_delete', entry_id=entry.id) }}"
onsubmit="return confirm('Delete this knowledge entry? The AI will no longer have this context.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-book fs-1 d-block mb-2"></i>
No knowledge entries yet. Add context above to improve the AI assistant's answers.
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Edit Knowledge Entry{% endblock %}
{% block content %}
<div class="mb-3">
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Knowledge Base
</a>
</div>
<div class="card shadow-sm" style="max-width:700px;">
<div class="card-header fw-semibold"><i class="bi bi-pencil me-2"></i>Edit Knowledge Entry</div>
<div class="card-body">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-semibold">Title <span class="text-danger">*</span></label>
<input type="text" name="title" class="form-control" maxlength="200" required
value="{{ entry.title }}">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Content <span class="text-danger">*</span></label>
<textarea name="body" class="form-control" rows="8" required>{{ entry.body }}</textarea>
<div class="form-text">Combined active entries are capped at 6,000 characters in the AI prompt.</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg me-1"></i>Save Changes
</button>
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
{% endblock %}
+8
View File
@@ -7,6 +7,14 @@
<h4 class="mb-0"><i class="bi bi-inbox me-2 text-primary"></i>Customer Support Tickets</h4>
<small class="text-muted">{{ tickets.total }} ticket{{ 's' if tickets.total != 1 }}</small>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-chat-dots me-1"></i>AI Conversations
</a>
<a href="{{ url_for('support.admin_knowledge') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-book me-1"></i>Knowledge Base
</a>
</div>
</div>
{# Status filter tabs #}
+46 -22
View File
@@ -42,11 +42,23 @@
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4>
<small class="text-muted">Ask a question or browse common topics below</small>
<small class="text-muted">
{% if chat_session %}
Conversation from {{ chat_session.created_at.strftime('%b %d, %Y') }}
· <a href="{{ url_for('support.chat') }}">New Chat</a>
{% else %}
Ask a question or browse common topics below
{% endif %}
</small>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('support.my_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-clock-history me-1"></i>History
</a>
<button class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#submitModal">
<i class="bi bi-envelope me-1"></i>Submit to Support
</button>
</div>
<button class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#submitModal">
<i class="bi bi-envelope me-1"></i>Submit to Support
</button>
</div>
{# ── Chat card ── #}
@@ -54,8 +66,9 @@
{# Message window #}
<div id="chat-window"></div>
{# FAQ quick-reply chips #}
<div id="faq-section" class="px-3 pt-2 pb-1 border-top bg-white">
{# FAQ quick-reply chips — hidden when viewing a prior session #}
<div id="faq-section" class="px-3 pt-2 pb-1 border-top bg-white"
{% if db_history %}style="display:none;"{% endif %}>
<p class="small text-muted mb-2"><i class="bi bi-lightning-charge me-1"></i>Common questions:</p>
<div class="d-flex flex-wrap gap-2 mb-2">
{% for faq in faqs %}
@@ -114,7 +127,7 @@
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">Subject <span class="text-danger">*</span></label>
<input type="text" name="subject" class="form-control" required maxlength="200"
<input type="text" name="subject" id="ticketSubject" class="form-control" required maxlength="200"
placeholder="Briefly describe your issue">
</div>
{% if facilities %}
@@ -156,14 +169,23 @@
const faqSection = document.getElementById('faq-section');
const CSRF_TOKEN = '{{ csrf_token() }}';
// In-memory conversation history sent to the server with each message
let history = [];
// Persisted session ID — returned by the server on first message, sent on all subsequent ones
let session_id = {{ (chat_session.id if chat_session else none) | tojson }};
// ── Greeting on page load ──────────────────────────────────────────────
// Last user message — used to pre-fill the support ticket subject
let last_user_msg = '';
// ── Render DB history on page load ────────────────────────────────────
var userName = {{ current_user.display_name | tojson }};
appendMessage('ai', "👋 Hi " + userName + "! I'm your JQC support assistant. " +
"I can help you with inspections, issues, reports, and more. " +
"Click a question below or type your own.");
{% if db_history %}
{% for msg in db_history %}
appendMessage({{ msg.role | tojson }}, {{ msg.content | tojson }});
{% endfor %}
{% else %}
appendMessage('ai', "👋 Hi " + userName + "! I'm your JQC support assistant. " +
"I can help you with inspections, issues, reports, and more. " +
"Click a question below or type your own.");
{% endif %}
// ── Append a message bubble ────────────────────────────────────────────
function appendMessage(role, text) {
@@ -198,17 +220,18 @@
if (el) el.remove();
}
// ── Send message to Groq ───────────────────────────────────────────────
// ── Send message to server ─────────────────────────────────────────────
function sendMessage(text) {
if (!text.trim()) return;
last_user_msg = text;
// Hide FAQ chips after first interaction
if (faqSection) faqSection.style.display = 'none';
appendMessage('user', text);
history.push({ role: 'user', content: text });
chatInput.value = '';
chatInput.value = '';
chatInput.disabled = true;
document.getElementById('send-btn').disabled = true;
showTyping();
@@ -219,14 +242,16 @@
'Content-Type': 'application/json',
'X-CSRFToken': CSRF_TOKEN,
},
body: JSON.stringify({ message: text, messages: history }),
body: JSON.stringify({ message: text, session_id: session_id }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
hideTyping();
// Store session_id returned by the server for subsequent messages
if (data.session_id) session_id = data.session_id;
const reply = data.reply || 'Sorry, I could not process your request.';
appendMessage('ai', reply);
history.push({ role: 'assistant', content: reply });
// Suggest escalation if the AI hints it can't help
const lower = reply.toLowerCase();
@@ -268,10 +293,9 @@
// ── Pre-fill modal subject from last user message ──────────────────────
document.getElementById('submitModal').addEventListener('show.bs.modal', function () {
const subjectInput = this.querySelector('[name="subject"]');
if (subjectInput && !subjectInput.value && history.length) {
const lastUser = [...history].reverse().find(function (m) { return m.role === 'user'; });
if (lastUser) subjectInput.value = lastUser.content.slice(0, 200);
const subjectInput = document.getElementById('ticketSubject');
if (subjectInput && !subjectInput.value && last_user_msg) {
subjectInput.value = last_user_msg.slice(0, 200);
}
});
}());
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Conversation #{{ chat_session.id }}{% endblock %}
{% block extra_css %}
<style>
.msg-bubble {
max-width: 80%;
padding: .6rem .9rem;
border-radius: 1rem;
font-size: .9rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.msg-user { background:#0d6efd; color:#fff; border-bottom-right-radius:.25rem; align-self:flex-end; }
.msg-ai { background:#f8f9fa; border:1px solid #dee2e6; border-bottom-left-radius:.25rem; align-self:flex-start; }
.msg-row { display:flex; flex-direction:column; gap:.75rem; }
</style>
{% endblock %}
{% block content %}
<div class="mb-3">
{% if is_admin %}
<a href="{{ url_for('support.admin_conversations') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>All Conversations
</a>
{% else %}
<a href="{{ url_for('support.my_conversations') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>My Conversations
</a>
{% endif %}
</div>
<div class="row g-4">
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>{{ chat_session.title or 'Conversation #' ~ chat_session.id }}</strong>
<small class="text-muted">{{ messages | length }} messages</small>
</div>
<div class="card-body">
{% if messages %}
<div class="msg-row">
{% for msg in messages %}
<div class="msg-bubble msg-{{ msg.role }}">
{{ msg.content }}
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted text-center py-3">No messages in this conversation.</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-info-circle me-2"></i>Details</div>
<div class="card-body small">
{% if is_admin and chat_session.customer %}
<p class="mb-1"><strong>Customer:</strong> {{ chat_session.customer.display_name }}</p>
<p class="mb-2 text-muted">{{ chat_session.customer.email }}</p>
<hr class="my-2">
{% endif %}
<p class="mb-1"><strong>Started:</strong> {{ chat_session.created_at.strftime('%b %d, %Y %I:%M %p') }}</p>
<p class="mb-2"><strong>Last message:</strong> {{ chat_session.last_msg_at.strftime('%b %d, %Y %I:%M %p') }}</p>
{% if not is_admin %}
<a href="{{ url_for('support.chat', session_id=chat_session.id) }}"
class="btn btn-primary btn-sm w-100 mt-2">
<i class="bi bi-chat me-1"></i>Continue This Chat
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}My Chat History{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-clock-history me-2 text-primary"></i>My Chat History</h4>
<small class="text-muted">{{ sessions|length }} conversation{{ 's' if sessions|length != 1 }}</small>
</div>
<a href="{{ url_for('support.chat') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle me-1"></i>New Chat
</a>
</div>
{% if sessions %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Topic</th>
<th>Last Activity</th>
<th>Messages</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in sessions %}
<tr>
<td>
<span class="fw-semibold">{{ s.title or 'Conversation' }}</span>
</td>
<td class="text-muted small text-nowrap">
{{ s.last_msg_at.strftime('%b %d, %Y %I:%M %p') }}
</td>
<td class="text-muted small">{{ s.messages | length }}</td>
<td class="text-nowrap">
<a href="{{ url_for('support.my_conversation_detail', session_id=s.id) }}"
class="btn btn-sm btn-outline-secondary me-1">
<i class="bi bi-eye me-1"></i>View
</a>
<a href="{{ url_for('support.chat', session_id=s.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-chat me-1"></i>Continue
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-chat-dots fs-1 d-block mb-2"></i>
No conversations yet.
<a href="{{ url_for('support.chat') }}">Start a chat</a> with our AI assistant.
</div>
{% endif %}
{% endblock %}
+8 -3
View File
@@ -7,9 +7,14 @@
<h4 class="mb-0"><i class="bi bi-inbox me-2 text-primary"></i>My Support Requests</h4>
<small class="text-muted">{{ tickets | length }} request{{ 's' if tickets | length != 1 }}</small>
</div>
<a href="{{ url_for('support.chat') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-chat-dots me-1"></i>New Chat / Ask a Question
</a>
<div class="d-flex gap-2">
<a href="{{ url_for('support.my_conversations') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-clock-history me-1"></i>Chat History
</a>
<a href="{{ url_for('support.chat') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-chat-dots me-1"></i>New Chat
</a>
</div>
</div>
{% if tickets %}
@@ -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')