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. > **Audience:** AI assistants and developers working on this codebase.
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. > **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. 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 ### 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) | | `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) | | `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`). | | `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) | | `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`) | | `devices` | `/admin/devices` | `GET /` (registered device list), `POST /notify` — notify users on outdated app versions (reads `api_device_tokens`) |
| `api` | `/api/v1` | parent blueprint | | `api` | `/api/v1` | parent blueprint |
@@ -791,7 +812,7 @@ limiter = Limiter(
## 17. Alembic Migration Chain ## 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`. **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 → phase36_issue_work_orders
→ phase37_contract_recipients → phase37_contract_recipients
→ phase38_facility_qr → 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 ### 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. | | 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__)`. | | 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. | | 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 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): class SupportTicket(db.Model):
__tablename__ = 'support_tickets' __tablename__ = 'support_tickets'
+224 -28
View File
@@ -6,12 +6,14 @@ from flask import (Blueprint, render_template, redirect, url_for,
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db 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.user import User
from app.models.facility import Facility from app.models.facility import Facility
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
from app.utils.scope import get_customer_scope 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.time_utils import now_eastern
from app.utils.notifications import notify from app.utils.notifications import notify
@@ -41,6 +43,8 @@ cannot resolve through guidance, say so clearly and suggest they click \
"Submit to Support" to reach the admin team directly.\ "Submit to Support" to reach the admin team directly.\
""" """
_KB_MAX_CHARS = 6000
# Preset FAQ questions shown as quick-reply chips on first load # Preset FAQ questions shown as quick-reply chips on first load
FAQS = [ FAQS = [
{'icon': 'bi-clipboard-check', 'text': 'How do I view my inspection reports?'}, {'icon': 'bi-clipboard-check', 'text': 'How do I view my inspection reports?'},
@@ -52,6 +56,20 @@ FAQS = [
] ]
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 ──────────────────────────────────────────────────────── # ── Customer chat page ────────────────────────────────────────────────────────
@bp.route('/chat') @bp.route('/chat')
@@ -66,10 +84,24 @@ def chat():
.order_by(Facility.name).all()) if cids else [] .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', return render_template('support/chat.html',
faqs=FAQS, faqs=FAQS,
facilities=facilities, facilities=facilities,
groq_ready=groq_ready) groq_ready=groq_ready,
chat_session=chat_session,
db_history=db_history)
# ── Groq chat AJAX endpoint ─────────────────────────────────────────────────── # ── Groq chat AJAX endpoint ───────────────────────────────────────────────────
@@ -88,21 +120,47 @@ def chat_message():
)}) )})
data = request.get_json(silent=True) or {} data = request.get_json(silent=True) or {}
history = data.get('messages', []) # list of {role, content} dicts
user_message = data.get('message', '').strip() user_message = data.get('message', '').strip()
client_sid = data.get('session_id')
if not user_message: if not user_message:
return jsonify({'error': 'Empty message'}), 400 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: try:
from groq import Groq from groq import Groq
client = Groq(api_key=api_key) client = Groq(api_key=api_key)
messages = [{'role': 'system', 'content': _SYSTEM_PROMPT}] messages = [{'role': 'system', 'content': _system_prompt_with_kb()}]
# Append prior conversation (cap at last 20 turns to control token usage) for m in prior[-20:]:
for m in history[-20:]: messages.append({'role': m.role, 'content': m.content})
if m.get('role') in ('user', 'assistant') and m.get('content'):
messages.append({'role': m['role'], 'content': m['content']})
messages.append({'role': 'user', 'content': user_message}) messages.append({'role': 'user', 'content': user_message})
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile') model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
@@ -113,16 +171,55 @@ def chat_message():
temperature=0.5, temperature=0.5,
) )
reply = completion.choices[0].message.content.strip() 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: except Exception as exc:
logger.error('SUPPORT | Groq error: %s', exc) logger.error('SUPPORT | Groq error: %s', exc)
db.session.rollback()
return jsonify({'reply': ( return jsonify({'reply': (
"I ran into a problem reaching the AI assistant. " "I ran into a problem reaching the AI assistant. "
"Please try again, or use **Submit to Support** to contact our team." "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 ───────────────────────────────────────────────────── # ── Submit support ticket ─────────────────────────────────────────────────────
@bp.route('/tickets', methods=['POST']) @bp.route('/tickets', methods=['POST'])
@@ -242,13 +339,7 @@ def _notify_admins_new_ticket(ticket):
f'{ticket.body[:300]}{"" if len(ticket.body) > 300 else ""}') f'{ticket.body[:300]}{"" if len(ticket.body) > 300 else ""}')
for admin in admins: for admin in admins:
notify( notify(recipient=admin, title=title, body=body, link=link, send_email=True)
recipient = admin,
title = title,
body = body,
link = link,
send_email = True,
)
db.session.commit() db.session.commit()
@@ -338,13 +429,7 @@ def _notify_customer_reply(ticket, reply):
f'{reply.body[:400]}{"" if len(reply.body) > 400 else ""}') f'{reply.body[:400]}{"" if len(reply.body) > 400 else ""}')
link = url_for('support.my_ticket_detail', ticket_id=ticket.id) link = url_for('support.my_ticket_detail', ticket_id=ticket.id)
notify( notify(recipient=ticket.customer, title=title, body=body, link=link, send_email=True)
recipient = ticket.customer,
title = title,
body = body,
link = link,
send_email = True,
)
db.session.commit() db.session.commit()
@@ -361,11 +446,122 @@ def _notify_admins_customer_reply(ticket, reply):
f'{reply.body[:400]}{"" if len(reply.body) > 400 else ""}') f'{reply.body[:400]}{"" if len(reply.body) > 400 else ""}')
for admin in admins: for admin in admins:
notify( notify(recipient=admin, title=title, body=body, link=link, send_email=True)
recipient = admin, 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, title = title,
body = body, body = body,
link = link, active = True,
send_email = True, created_by = current_user.id,
created_at = now_eastern(),
updated_at = now_eastern(),
) )
db.session.add(entry)
db.session.commit() 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> <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> <small class="text-muted">{{ tickets.total }} ticket{{ 's' if tickets.total != 1 }}</small>
</div> </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> </div>
{# Status filter tabs #} {# Status filter tabs #}
+39 -15
View File
@@ -42,20 +42,33 @@
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4> <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>
<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"> <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 <i class="bi bi-envelope me-1"></i>Submit to Support
</button> </button>
</div> </div>
</div>
{# ── Chat card ── #} {# ── Chat card ── #}
<div class="card shadow-sm"> <div class="card shadow-sm">
{# Message window #} {# Message window #}
<div id="chat-window"></div> <div id="chat-window"></div>
{# FAQ quick-reply chips #} {# 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"> <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> <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"> <div class="d-flex flex-wrap gap-2 mb-2">
{% for faq in faqs %} {% for faq in faqs %}
@@ -114,7 +127,7 @@
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">Subject <span class="text-danger">*</span></label> <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"> placeholder="Briefly describe your issue">
</div> </div>
{% if facilities %} {% if facilities %}
@@ -156,14 +169,23 @@
const faqSection = document.getElementById('faq-section'); const faqSection = document.getElementById('faq-section');
const CSRF_TOKEN = '{{ csrf_token() }}'; const CSRF_TOKEN = '{{ csrf_token() }}';
// In-memory conversation history sent to the server with each message // Persisted session ID — returned by the server on first message, sent on all subsequent ones
let history = []; 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 }}; var userName = {{ current_user.display_name | tojson }};
{% 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. " + appendMessage('ai', "👋 Hi " + userName + "! I'm your JQC support assistant. " +
"I can help you with inspections, issues, reports, and more. " + "I can help you with inspections, issues, reports, and more. " +
"Click a question below or type your own."); "Click a question below or type your own.");
{% endif %}
// ── Append a message bubble ──────────────────────────────────────────── // ── Append a message bubble ────────────────────────────────────────────
function appendMessage(role, text) { function appendMessage(role, text) {
@@ -198,15 +220,16 @@
if (el) el.remove(); if (el) el.remove();
} }
// ── Send message to Groq ─────────────────────────────────────────────── // ── Send message to server ─────────────────────────────────────────────
function sendMessage(text) { function sendMessage(text) {
if (!text.trim()) return; if (!text.trim()) return;
last_user_msg = text;
// Hide FAQ chips after first interaction // Hide FAQ chips after first interaction
if (faqSection) faqSection.style.display = 'none'; if (faqSection) faqSection.style.display = 'none';
appendMessage('user', text); appendMessage('user', text);
history.push({ role: 'user', content: text });
chatInput.value = ''; chatInput.value = '';
chatInput.disabled = true; chatInput.disabled = true;
@@ -219,14 +242,16 @@
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRFToken': CSRF_TOKEN, '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 (r) { return r.json(); })
.then(function (data) { .then(function (data) {
hideTyping(); 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.'; const reply = data.reply || 'Sorry, I could not process your request.';
appendMessage('ai', reply); appendMessage('ai', reply);
history.push({ role: 'assistant', content: reply });
// Suggest escalation if the AI hints it can't help // Suggest escalation if the AI hints it can't help
const lower = reply.toLowerCase(); const lower = reply.toLowerCase();
@@ -268,10 +293,9 @@
// ── Pre-fill modal subject from last user message ────────────────────── // ── Pre-fill modal subject from last user message ──────────────────────
document.getElementById('submitModal').addEventListener('show.bs.modal', function () { document.getElementById('submitModal').addEventListener('show.bs.modal', function () {
const subjectInput = this.querySelector('[name="subject"]'); const subjectInput = document.getElementById('ticketSubject');
if (subjectInput && !subjectInput.value && history.length) { if (subjectInput && !subjectInput.value && last_user_msg) {
const lastUser = [...history].reverse().find(function (m) { return m.role === 'user'; }); subjectInput.value = last_user_msg.slice(0, 200);
if (lastUser) subjectInput.value = lastUser.content.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 %}
+7 -2
View File
@@ -7,9 +7,14 @@
<h4 class="mb-0"><i class="bi bi-inbox me-2 text-primary"></i>My Support Requests</h4> <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> <small class="text-muted">{{ tickets | length }} request{{ 's' if tickets | length != 1 }}</small>
</div> </div>
<a href="{{ url_for('support.chat') }}" class="btn btn-outline-primary btn-sm"> <div class="d-flex gap-2">
<i class="bi bi-chat-dots me-1"></i>New Chat / Ask a Question <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>
<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> </div>
{% if tickets %} {% 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')