Jul 10 - Update codes to catch up with the single tenant project (Medium)
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Audience:** AI assistants and developers working on this codebase.
|
||||
> **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions.
|
||||
> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); MT-9 iOS pending**)
|
||||
> **Last reviewed:** July 2026 (doc-reconciliation pass — verified against code on disk. Adds previously-undocumented phase28 notify-fix, phase29 broadcasts, phase30–32 device registry; `broadcast` + `devices` + `api_devices` blueprints; Broadcast + DeviceRegistration models; corrected MT-8 billing status to DONE; resolved the device-registration collision (rule 84 — removed duplicate `api_devices` blueprint + `DeviceRegistration` model, consolidated on `DeviceToken`). Prior: Phase 19 + mobile API Phases A–E + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + Reports R1–R4 + Phase 24 notify defaults + Phase 25 GPS + Phase 26 vendor fields + Phase 27 score alerts + **MT-0 through MT-8 complete; self-service signup; trial enforcement; billing emails; invoice history; superadmin billing controls; per-tenant backup CLI; health dashboard; fail2ban; welcome email; dunning day-3/7/14; ProxyFix middleware; QR occupant issue reporting; issue handler type (phase39); support chat persistence + knowledge base (phase40); MT-9 iOS pending**)
|
||||
|
||||
---
|
||||
|
||||
@@ -381,6 +381,27 @@ facility_score_alerts: id, facility_id (FK→facilities CASCADE), sent_at DATETI
|
||||
|
||||
Records each score-trend alert dispatched for a facility. `send_score_alerts()` queries this table to skip re-alerting a facility within the last 24 hours, preventing notification storms on persistent score drops.
|
||||
|
||||
### SupportChatSession / SupportChatMessage (phase40)
|
||||
|
||||
```
|
||||
support_chat_sessions: id, customer_id (FK→users SET NULL), title VARCHAR(200) nullable,
|
||||
created_at DATETIME, last_msg_at DATETIME
|
||||
|
||||
support_chat_messages: id, session_id (FK→support_chat_sessions CASCADE),
|
||||
role VARCHAR(20) ('user'/'assistant'), content TEXT, created_at DATETIME
|
||||
```
|
||||
|
||||
AI chat conversations are now persisted to the DB. `GET /support/chat?session_id=N` loads a prior session's history. The `POST /support/chat/message` endpoint creates a new session (via `flush()`) on first message and persists both turns after the Groq call succeeds; it rolls back if Groq fails (no empty sessions). The session's `title` is auto-set from the first user message (truncated to 100 chars). History is loaded from DB for Groq context (last 40 messages) — the client no longer sends the history array.
|
||||
|
||||
### SupportKnowledge (phase40)
|
||||
|
||||
```
|
||||
support_knowledge: id, title VARCHAR(200), body TEXT, active BOOL DEFAULT TRUE,
|
||||
created_by (FK→users SET NULL), created_at DATETIME, updated_at DATETIME
|
||||
```
|
||||
|
||||
Admin-curated knowledge base entries. Active entries are appended to the Groq system prompt via `_system_prompt_with_kb()`, capped at `_KB_MAX_CHARS = 6000`. Managed at `/support/admin/knowledge` (`@supervisor_required`): add, edit, toggle active/inactive, delete. Deactivated entries are preserved but skipped from the system prompt.
|
||||
|
||||
### SupportTicket / SupportTicketReply
|
||||
|
||||
```
|
||||
@@ -520,7 +541,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **
|
||||
| `scheduled_reports` | `/scheduled-reports` | CRUD + manual trigger (accessible via Reports sub-nav) |
|
||||
| `inspection_schedules` | `/inspection-schedules` | phase34 — recurring inspection CRUD (`@project_manager_required`) + `POST /run-now` (manual) + `POST /run` (token-protected cron materialiser) |
|
||||
| `work_orders` | `/work-orders` | phase36 — **public, login-less** vendor pages: `GET /<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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
+225
-29
@@ -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-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')
|
||||
@@ -66,10 +84,24 @@ def chat():
|
||||
.order_by(Facility.name).all()) if cids else []
|
||||
|
||||
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'])
|
||||
@@ -242,13 +339,7 @@ def _notify_admins_new_ticket(ticket):
|
||||
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,
|
||||
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,
|
||||
link = link,
|
||||
send_email = True,
|
||||
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) }}">« 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 »</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 #}
|
||||
|
||||
@@ -42,20 +42,33 @@
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{# ── Chat card ── #}
|
||||
<div class="card shadow-sm">
|
||||
{# 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 }};
|
||||
{% 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,15 +220,16 @@
|
||||
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.disabled = true;
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
<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')
|
||||
Reference in New Issue
Block a user