06/09 Update a customer AI chatbot and support form submision

This commit is contained in:
2026-06-09 16:13:12 -04:00
parent 58d4b351b4
commit 38dff9ea02
9 changed files with 1029 additions and 1 deletions
+9 -1
View File
@@ -116,13 +116,19 @@ def create_app(config_name='default'):
pv_count = Issue.query.filter_by(
status='pending_verification'
).count()
# Open support tickets — admin/director only
open_support = 0
if current_user.role in ('admin', 'director'):
from app.models.support import SupportTicket
open_support = SupportTicket.query.filter_by(status='open').count()
return {
'unread_notification_count': unread,
'pending_verification_count': pv_count,
'open_support_tickets_count': open_support,
}
except Exception:
pass
return {'unread_notification_count': 0, 'pending_verification_count': 0}
return {'unread_notification_count': 0, 'pending_verification_count': 0, 'open_support_tickets_count': 0}
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
@@ -133,6 +139,7 @@ def create_app(config_name='default'):
from app.routes import projects # Phase 1/2 — Project management
from app.routes import customers # Phase 5 — Customer management
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
from app.routes import support # Support chat + admin tickets
app.register_blueprint(auth.bp)
app.register_blueprint(dashboard.bp)
@@ -146,6 +153,7 @@ def create_app(config_name='default'):
app.register_blueprint(projects.bp)
app.register_blueprint(customers.bp)
app.register_blueprint(scheduled_reports.bp)
app.register_blueprint(support.bp)
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
+41
View File
@@ -0,0 +1,41 @@
from app import db
from app.utils.time_utils import now_eastern
class SupportTicket(db.Model):
__tablename__ = 'support_tickets'
id = db.Column(db.Integer, primary_key=True)
customer_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id', ondelete='SET NULL'), nullable=True)
subject = db.Column(db.String(200), nullable=False)
body = db.Column(db.Text, nullable=False)
status = db.Column(db.String(20), nullable=False, default='open') # open / answered / closed
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
customer = db.relationship('User', foreign_keys=[customer_id], backref='support_tickets')
facility = db.relationship('Facility', foreign_keys=[facility_id], backref='support_tickets')
replies = db.relationship(
'SupportTicketReply', backref='ticket',
cascade='all, delete-orphan',
order_by='SupportTicketReply.created_at',
lazy='dynamic',
)
def __repr__(self):
return f'<SupportTicket {self.id} [{self.status}]>'
class SupportTicketReply(db.Model):
__tablename__ = 'support_ticket_replies'
id = db.Column(db.Integer, primary_key=True)
ticket_id = db.Column(db.Integer, db.ForeignKey('support_tickets.id', ondelete='CASCADE'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
body = db.Column(db.Text, nullable=False)
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
author = db.relationship('User', foreign_keys=[user_id])
def __repr__(self):
return f'<SupportTicketReply {self.id} ticket={self.ticket_id}>'
+338
View File
@@ -0,0 +1,338 @@
import os
import logging
import threading
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, jsonify, abort)
from flask_login import login_required, current_user
from flask_mail import Message
from app import db, mail
from app.models.support import SupportTicket, SupportTicketReply
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.time_utils import now_eastern
bp = Blueprint('support', __name__, url_prefix='/support')
logger = logging.getLogger(__name__)
# ── Groq system prompt ────────────────────────────────────────────────────────
_SYSTEM_PROMPT = """\
You are JQC Support, a friendly assistant for customers of JQC (Janitorial Quality Control), \
a commercial cleaning quality management platform.
Help customers with:
- Navigating the portal: Dashboard, Inspections, Issues, Reports pages
- Inspection scores: 90%+ = Excellent, 70-89% = Satisfactory, below 70% = Needs Improvement
- SLA timelines: Critical issues = 4 h, High = 24 h, Medium = 72 h, Low = 168 h
- Issue statuses: Open → In Progress → Pending Verification → Resolved
- Following issues to receive email/in-app update notifications
- Reporting new cleaning concerns via the Issues > Log Issue page
- Understanding facility scorecards and trend charts in Reports
Rules:
- Keep answers concise (3-5 sentences max) and friendly.
- Never invent specific staff names, contract prices, schedules, or contact numbers.
- If the customer has an access problem, billing question, or a concern you genuinely \
cannot resolve through guidance, say so clearly and suggest they click \
"Submit to Support" to reach the admin team directly.\
"""
# Preset FAQ questions shown as quick-reply chips on first load
FAQS = [
{'icon': 'bi-clipboard-check', 'text': 'How do I view my inspection reports?'},
{'icon': 'bi-graph-up', 'text': 'What do inspection scores mean?'},
{'icon': 'bi-exclamation-circle','text': 'How do I track an open issue?'},
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
]
# ── Customer chat page ────────────────────────────────────────────────────────
@bp.route('/chat')
@login_required
def chat():
if current_user.role != 'customer':
return redirect(url_for('support.admin_tickets'))
cids = get_customer_scope(current_user) or []
facilities = (Facility.query
.filter(Facility.id.in_(cids), Facility.active == True)
.order_by(Facility.name).all()) if cids else []
groq_ready = bool(os.environ.get('GROQ_API_KEY'))
return render_template('support/chat.html',
faqs=FAQS,
facilities=facilities,
groq_ready=groq_ready)
# ── Groq chat AJAX endpoint ───────────────────────────────────────────────────
@bp.route('/chat/message', methods=['POST'])
@login_required
def chat_message():
if current_user.role != 'customer':
return jsonify({'error': 'Forbidden'}), 403
api_key = os.environ.get('GROQ_API_KEY')
if not api_key:
return jsonify({'reply': (
"I'm sorry, the AI assistant isn't configured right now. "
"Please use the **Submit to Support** form to reach our team directly."
)})
data = request.get_json(silent=True) or {}
history = data.get('messages', []) # list of {role, content} dicts
user_message = data.get('message', '').strip()
if not user_message:
return jsonify({'error': 'Empty message'}), 400
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.append({'role': 'user', 'content': user_message})
model = os.environ.get('GROQ_MODEL', 'llama-3.3-70b-versatile')
completion = client.chat.completions.create(
model=model,
messages=messages,
max_tokens=512,
temperature=0.5,
)
reply = completion.choices[0].message.content.strip()
return jsonify({'reply': reply})
except Exception as exc:
logger.error('SUPPORT | Groq error: %s', exc)
return jsonify({'reply': (
"I ran into a problem reaching the AI assistant. "
"Please try again, or use **Submit to Support** to contact our team."
)})
# ── Submit support ticket ─────────────────────────────────────────────────────
@bp.route('/tickets', methods=['POST'])
@login_required
def submit_ticket():
if current_user.role != 'customer':
abort(403)
subject = request.form.get('subject', '').strip()
body = request.form.get('body', '').strip()
facility_id = request.form.get('facility_id', type=int)
if not subject or not body:
flash('Please fill in both subject and description.', 'warning')
return redirect(url_for('support.chat'))
# Validate facility belongs to this customer
cids = get_customer_scope(current_user) or []
if facility_id and facility_id not in cids:
facility_id = None
ticket = SupportTicket(
customer_id = current_user.id,
facility_id = facility_id,
subject = subject,
body = body,
status = 'open',
created_at = now_eastern(),
)
db.session.add(ticket)
db.session.commit()
log_action(ACTION_CREATE, 'SupportTicket', ticket.id,
f'#{ticket.id}: {subject[:60]}',
f'customer={current_user.username}')
_notify_admins_new_ticket(ticket)
flash('Your message has been submitted. Our team will get back to you soon.', 'success')
return redirect(url_for('support.chat'))
def _notify_admins_new_ticket(ticket):
"""Send email notification to all active admin users in a background thread."""
admins = User.query.filter_by(role='admin', active=True).all()
if not admins:
return
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
ticket_url = f'{base_url}{url_for("support.admin_ticket_detail", ticket_id=ticket.id)}'
facility_label = ticket.facility.name if ticket.facility else 'N/A'
customer_label = ticket.customer.display_name if ticket.customer else 'Unknown'
subject_line = f'[JQC Support] New ticket #{ticket.id}: {ticket.subject}'
html_body = f"""\
<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
<h2 style="color:#0d6efd;">New Support Ticket #{ticket.id}</h2>
<p><strong>From:</strong> {customer_label}</p>
<p><strong>Facility:</strong> {facility_label}</p>
<p><strong>Subject:</strong> {ticket.subject}</p>
<hr style="border:none;border-top:1px solid #eee;">
<p style="white-space:pre-wrap;">{ticket.body}</p>
<hr style="border:none;border-top:1px solid #eee;">
<p>
<a href="{ticket_url}"
style="background:#0d6efd;color:#fff;padding:10px 20px;
text-decoration:none;border-radius:4px;display:inline-block;">
View &amp; Reply
</a>
</p>
<p style="font-size:12px;color:#888;">JQC Support System — automated notification.</p>
</body>
</html>"""
def _send():
try:
with current_app.app_context():
for admin in admins:
msg = Message(
subject = subject_line,
recipients = [admin.email],
html = html_body,
sender = current_app.config.get('MAIL_DEFAULT_SENDER'),
)
mail.send(msg)
except Exception as exc:
logger.error('SUPPORT | email notification failed: %s', exc)
threading.Thread(target=_send, daemon=True).start()
# ── Admin: ticket list ────────────────────────────────────────────────────────
@bp.route('/admin/tickets')
@login_required
@supervisor_required
def admin_tickets():
status_filter = request.args.get('status', '')
page = request.args.get('page', 1, type=int)
q = SupportTicket.query.order_by(SupportTicket.created_at.desc())
if status_filter:
q = q.filter(SupportTicket.status == status_filter)
tickets = q.paginate(page=page, per_page=25, error_out=False)
return render_template('support/admin_tickets.html',
tickets=tickets,
status_filter=status_filter)
# ── Admin: ticket detail + reply ──────────────────────────────────────────────
@bp.route('/admin/tickets/<int:ticket_id>', methods=['GET', 'POST'])
@login_required
@supervisor_required
def admin_ticket_detail(ticket_id):
ticket = db.session.get(SupportTicket, ticket_id)
if ticket is None:
abort(404)
if request.method == 'POST':
action = request.form.get('action')
if action == 'reply':
body = request.form.get('body', '').strip()
if not body:
flash('Reply cannot be empty.', 'warning')
return redirect(url_for('support.admin_ticket_detail', ticket_id=ticket_id))
reply = SupportTicketReply(
ticket_id = ticket.id,
user_id = current_user.id,
body = body,
created_at = now_eastern(),
)
db.session.add(reply)
# Auto-advance status to answered if still open
if ticket.status == 'open':
ticket.status = 'answered'
db.session.commit()
log_action(ACTION_UPDATE, 'SupportTicket', ticket.id,
f'#{ticket.id}: {ticket.subject[:60]}',
f'reply added by {current_user.username}')
_notify_customer_reply(ticket, reply)
flash('Reply sent.', 'success')
elif action == 'status':
new_status = request.form.get('status', '')
if new_status in ('open', 'answered', 'closed'):
ticket.status = new_status
db.session.commit()
log_action(ACTION_UPDATE, 'SupportTicket', ticket.id,
f'#{ticket.id}: {ticket.subject[:60]}',
f'status={new_status}')
flash(f'Ticket marked as {new_status}.', 'success')
return redirect(url_for('support.admin_ticket_detail', ticket_id=ticket_id))
replies = ticket.replies.order_by(SupportTicketReply.created_at.asc()).all()
return render_template('support/admin_ticket_detail.html',
ticket=ticket,
replies=replies)
def _notify_customer_reply(ticket, reply):
"""Email the customer when an admin replies to their ticket."""
if not ticket.customer or not ticket.customer.email:
return
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
chat_url = f'{base_url}{url_for("support.chat")}'
admin_name = reply.author.display_name if reply.author else 'Support Team'
html_body = f"""\
<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;color:#333;max-width:600px;margin:auto;">
<h2 style="color:#0d6efd;">Reply to Your Support Request</h2>
<p>Hi {ticket.customer.display_name},</p>
<p><strong>{admin_name}</strong> replied to your support ticket
<strong>#{ticket.id}: {ticket.subject}</strong>:</p>
<blockquote style="border-left:4px solid #0d6efd;padding-left:12px;color:#555;
white-space:pre-wrap;">{reply.body}</blockquote>
<p>
<a href="{chat_url}"
style="background:#0d6efd;color:#fff;padding:10px 20px;
text-decoration:none;border-radius:4px;display:inline-block;">
View Support Chat
</a>
</p>
<p style="font-size:12px;color:#888;">JQC Support System — automated notification.</p>
</body>
</html>"""
def _send():
try:
with current_app.app_context():
msg = Message(
subject = f'[JQC Support] Reply to #{ticket.id}: {ticket.subject}',
recipients = [ticket.customer.email],
html = html_body,
sender = current_app.config.get('MAIL_DEFAULT_SENDER'),
)
mail.send(msg)
except Exception as exc:
logger.error('SUPPORT | customer reply email failed: %s', exc)
threading.Thread(target=_send, daemon=True).start()
+19
View File
@@ -165,6 +165,25 @@
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="{{ url_for('support.admin_tickets') }}">
Support
{% if open_support_tickets_count > 0 %}
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role == 'customer' %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="{{ url_for('support.chat') }}">
<i class="bi bi-chat-dots me-1"></i>Support
</a>
</li>
{% endif %}
{% if current_user.role == 'admin' %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}" href="{{ url_for('audit.index') }}">Audit Trail</a>
@@ -0,0 +1,160 @@
{% extends "base.html" %}
{% block title %}Ticket #{{ ticket.id }}{% endblock %}
{% block extra_css %}
<style>
.reply-bubble {
max-width: 85%;
padding: .65rem 1rem;
border-radius: 1rem;
font-size: .9rem;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
}
.reply-admin { background:#d1ecf1; border-bottom-right-radius:.25rem; align-self:flex-end; }
.reply-system { background:#f8d7da; border-bottom-left-radius:.25rem; align-self:flex-start; }
#reply-thread { display:flex; flex-direction:column; gap:.75rem; }
</style>
{% endblock %}
{% block content %}
<div class="mb-3">
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>All Tickets
</a>
</div>
<div class="row g-4">
{# ── Left column: original message + replies ── #}
<div class="col-lg-8">
{# Original ticket card #}
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Ticket #{{ ticket.id }}: {{ ticket.subject }}</strong>
{% set status_class = {'open': 'danger', 'answered': 'success', 'closed': 'secondary'} %}
<span class="badge bg-{{ status_class.get(ticket.status, 'secondary') }}">
{{ ticket.status | capitalize }}
</span>
</div>
<div class="card-body">
<div class="text-muted small mb-2">
<i class="bi bi-person me-1"></i>
{{ ticket.customer.display_name if ticket.customer else 'Unknown' }}
{% if ticket.facility %}
&nbsp;·&nbsp;<i class="bi bi-building me-1"></i>{{ ticket.facility.name }}
{% endif %}
&nbsp;·&nbsp;<i class="bi bi-clock me-1"></i>
{{ ticket.created_at.strftime('%b %d, %Y %I:%M %p') }}
</div>
<p class="mb-0" style="white-space:pre-wrap;">{{ ticket.body }}</p>
</div>
</div>
{# Reply thread #}
{% if replies %}
<div class="card shadow-sm mb-3">
<div class="card-header"><i class="bi bi-chat-left-text me-2"></i>Replies</div>
<div class="card-body">
<div id="reply-thread">
{% for reply in replies %}
<div class="d-flex flex-column">
<div class="reply-bubble reply-admin ms-auto">
<div class="text-muted small mb-1">
<strong>{{ reply.author.display_name if reply.author else 'Support Team' }}</strong>
&nbsp;·&nbsp;{{ reply.created_at.strftime('%b %d, %Y %I:%M %p') }}
</div>
{{ reply.body }}
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{# Add reply form #}
{% if ticket.status != 'closed' %}
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-reply me-2"></i>Add Reply</div>
<div class="card-body">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="reply">
<div class="mb-3">
<textarea name="body" class="form-control" rows="5" required
placeholder="Type your reply to the customer…"></textarea>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Send Reply
</button>
<span class="text-muted small ms-2">
The customer will be notified by email.
</span>
</form>
</div>
</div>
{% else %}
<div class="alert alert-secondary">
<i class="bi bi-lock me-2"></i>This ticket is closed. Change the status to re-open it.
</div>
{% endif %}
</div>
{# ── Right column: status management ── #}
<div class="col-lg-4">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-gear me-2"></i>Ticket Status</div>
<div class="card-body">
<p class="mb-2 text-muted small">Current status:</p>
<p class="fw-semibold mb-3">
<span class="badge bg-{{ status_class.get(ticket.status, 'secondary') }} fs-6">
{{ ticket.status | capitalize }}
</span>
</p>
<form method="post" class="d-grid gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="status">
{% if ticket.status != 'open' %}
<button type="submit" name="status" value="open" class="btn btn-outline-danger btn-sm">
<i class="bi bi-envelope-open me-1"></i>Reopen
</button>
{% endif %}
{% if ticket.status != 'answered' %}
<button type="submit" name="status" value="answered" class="btn btn-outline-success btn-sm">
<i class="bi bi-check-circle me-1"></i>Mark Answered
</button>
{% endif %}
{% if ticket.status != 'closed' %}
<button type="submit" name="status" value="closed" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-x-circle me-1"></i>Close Ticket
</button>
{% endif %}
</form>
</div>
</div>
{# Customer info card #}
{% if ticket.customer %}
<div class="card shadow-sm mt-3">
<div class="card-header"><i class="bi bi-person-circle me-2"></i>Customer</div>
<div class="card-body small">
<p class="mb-1"><strong>{{ ticket.customer.display_name }}</strong></p>
<p class="mb-1 text-muted">{{ ticket.customer.email }}</p>
{% if ticket.facility %}
<hr class="my-2">
<p class="mb-1"><i class="bi bi-building me-1 text-muted"></i>{{ ticket.facility.name }}</p>
{% if ticket.facility.address %}
<p class="mb-0 text-muted">{{ ticket.facility.address }}</p>
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+114
View File
@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}Support Tickets{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<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>
{# Status filter tabs #}
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link {% if not status_filter %}active{% endif %}"
href="{{ url_for('support.admin_tickets') }}">All</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'open' %}active{% endif %}"
href="{{ url_for('support.admin_tickets', status='open') }}">
<span class="badge bg-danger me-1">!</span>Open
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'answered' %}active{% endif %}"
href="{{ url_for('support.admin_tickets', status='answered') }}">Answered</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'closed' %}active{% endif %}"
href="{{ url_for('support.admin_tickets', status='closed') }}">Closed</a>
</li>
</ul>
{% if tickets.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 style="width:60px">#</th>
<th>Customer</th>
<th>Facility</th>
<th>Subject</th>
<th>Status</th>
<th>Submitted</th>
<th></th>
</tr>
</thead>
<tbody>
{% for ticket in tickets.items %}
{% set status_class = {'open': 'danger', 'answered': 'success', 'closed': 'secondary'} %}
<tr>
<td class="text-muted small">{{ ticket.id }}</td>
<td>{{ ticket.customer.display_name if ticket.customer else '—' }}</td>
<td>{{ ticket.facility.name if ticket.facility else '—' }}</td>
<td>{{ ticket.subject }}</td>
<td>
<span class="badge bg-{{ status_class.get(ticket.status, 'secondary') }}">
{{ ticket.status | capitalize }}
</span>
</td>
<td class="text-muted small text-nowrap">
{{ ticket.created_at.strftime('%b %d, %Y %I:%M %p') }}
</td>
<td>
<a href="{{ url_for('support.admin_ticket_detail', ticket_id=ticket.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>
{# Pagination #}
{% if tickets.pages > 1 %}
<nav class="mt-3">
<ul class="pagination justify-content-center mb-0">
<li class="page-item {% if not tickets.has_prev %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('support.admin_tickets', page=tickets.prev_num, status=status_filter) }}">
&laquo; Prev
</a>
</li>
{% for p in tickets.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == tickets.page %}active{% endif %}">
<a class="page-link"
href="{{ url_for('support.admin_tickets', page=p, status=status_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
<li class="page-item {% if not tickets.has_next %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('support.admin_tickets', page=tickets.next_num, status=status_filter) }}">
Next &raquo;
</a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
No tickets{% if status_filter %} with status "{{ status_filter }}"{% endif %}.
</div>
{% endif %}
{% endblock %}
+277
View File
@@ -0,0 +1,277 @@
{% extends "base.html" %}
{% block title %}Support Chat{% endblock %}
{% block extra_css %}
<style>
#chat-window {
height: 420px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: .75rem;
padding: 1rem;
background: #f8f9fa;
}
.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:#fff; border:1px solid #dee2e6; border-bottom-left-radius:.25rem; align-self:flex-start; }
.msg-system { background:#fff3cd; border:1px solid #ffc107; border-radius:.5rem; align-self:center;
font-size:.8rem; text-align:center; padding:.4rem .8rem; color:#664d03; }
.typing-dot { display:inline-block; width:8px; height:8px; border-radius:50%;
background:#adb5bd; margin:0 2px; animation:blink 1.2s infinite; }
.typing-dot:nth-child(2) { animation-delay:.2s; }
.typing-dot:nth-child(3) { animation-delay:.4s; }
@keyframes blink { 0%,80%,100%{opacity:.2} 40%{opacity:1} }
.faq-btn { font-size:.82rem; }
</style>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
{# ── Header ── #}
<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>
</div>
<button class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#submitModal">
<i class="bi bi-envelope me-1"></i>Submit to Support
</button>
</div>
{# ── Chat card ── #}
<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">
<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 %}
<button class="btn btn-outline-secondary btn-sm faq-btn"
onclick="sendFaq(this, {{ faq.text | tojson }})">
<i class="{{ faq.icon }} me-1"></i>{{ faq.text }}
</button>
{% endfor %}
</div>
</div>
{# Input row #}
<div class="p-3 border-top bg-white">
<div class="input-group">
<input id="chat-input" type="text" class="form-control"
placeholder="Type your question…" maxlength="500"
{% if not groq_ready %}disabled title="AI assistant not configured"{% endif %}>
<button id="send-btn" class="btn btn-primary" onclick="sendUserMessage()"
{% if not groq_ready %}disabled{% endif %}>
<i class="bi bi-send"></i>
</button>
</div>
{% if not groq_ready %}
<div class="text-muted small mt-1">
<i class="bi bi-info-circle me-1"></i>
AI assistant is not configured. Please
<a href="#" data-bs-toggle="modal" data-bs-target="#submitModal">submit a request</a>
to reach our team.
</div>
{% endif %}
</div>
</div>
<p class="text-muted small mt-2 text-center">
Can't find what you need?
<a href="#" data-bs-toggle="modal" data-bs-target="#submitModal">Submit a support request</a>
and our team will respond by email.
</p>
</div>
</div>
{# ── Submit to Support modal ── #}
<div class="modal fade" id="submitModal" tabindex="-1" aria-labelledby="submitModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="submitModalLabel">
<i class="bi bi-envelope me-2"></i>Submit Support Request
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="post" action="{{ url_for('support.submit_ticket') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<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"
placeholder="Briefly describe your issue">
</div>
{% if facilities %}
<div class="mb-3">
<label class="form-label fw-semibold">Related Facility</label>
<select name="facility_id" class="form-select">
<option value="">— Not facility-specific —</option>
{% for f in facilities %}
<option value="{{ f.id }}">{{ f.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="mb-3">
<label class="form-label fw-semibold">Description <span class="text-danger">*</span></label>
<textarea name="body" class="form-control" rows="5" required
placeholder="Please describe your question or concern in detail…"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Submit Request
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
'use strict';
const chatWindow = document.getElementById('chat-window');
const chatInput = document.getElementById('chat-input');
const faqSection = document.getElementById('faq-section');
const CSRF_TOKEN = '{{ csrf_token() }}';
// In-memory conversation history sent to the server with each message
let history = [];
// ── Greeting on page load ──────────────────────────────────────────────
appendMessage('ai', "👋 Hi {{ current_user.display_name }}! I'm your JQC support assistant. " +
"I can help you with inspections, issues, reports, and more. " +
"Click a question below or type your own.");
// ── Append a message bubble ────────────────────────────────────────────
function appendMessage(role, text) {
const div = document.createElement('div');
div.className = 'msg-bubble msg-' + role;
div.textContent = text;
chatWindow.appendChild(div);
chatWindow.scrollTop = chatWindow.scrollHeight;
return div;
}
function appendSystem(text) {
const div = document.createElement('div');
div.className = 'msg-bubble msg-system';
div.textContent = text;
chatWindow.appendChild(div);
chatWindow.scrollTop = chatWindow.scrollHeight;
}
// ── Typing indicator ───────────────────────────────────────────────────
function showTyping() {
const div = document.createElement('div');
div.id = 'typing-indicator';
div.className = 'msg-bubble msg-ai';
div.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
chatWindow.appendChild(div);
chatWindow.scrollTop = chatWindow.scrollHeight;
}
function hideTyping() {
const el = document.getElementById('typing-indicator');
if (el) el.remove();
}
// ── Send message to Groq ───────────────────────────────────────────────
function sendMessage(text) {
if (!text.trim()) return;
// 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;
document.getElementById('send-btn').disabled = true;
showTyping();
fetch('{{ url_for("support.chat_message") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': CSRF_TOKEN,
},
body: JSON.stringify({ message: text, messages: history }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
hideTyping();
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();
if (lower.includes('submit to support') || lower.includes('admin team') ||
lower.includes("can't resolve") || lower.includes('contact us')) {
appendSystem('💡 Tip: Click "Submit to Support" above to send a message directly to our team.');
}
})
.catch(function () {
hideTyping();
appendMessage('ai', 'Network error — please check your connection and try again.');
})
.finally(function () {
chatInput.disabled = false;
document.getElementById('send-btn').disabled = false;
chatInput.focus();
});
}
// ── Public helpers called from inline onclick ──────────────────────────
window.sendUserMessage = function () {
sendMessage(chatInput.value);
};
window.sendFaq = function (btn, text) {
btn.disabled = true;
sendMessage(text);
};
// ── Enter key support ──────────────────────────────────────────────────
if (chatInput) {
chatInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(chatInput.value);
}
});
}
// ── 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);
}
});
}());
</script>
{% endblock %}
@@ -0,0 +1,70 @@
"""phase23 — support tickets
Creates two tables:
support_tickets — customer-submitted help requests
support_ticket_replies — admin/staff replies to those tickets
"""
import sqlalchemy as sa
from alembic import op
revision = 'phase23_support_tickets'
down_revision = 'phase22_comment_visibility'
branch_labels = None
depends_on = None
def _table_exists(bind, table: str) -> bool:
result = bind.execute(sa.text(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema = DATABASE() AND table_name = :t"
), {'t': table})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
if not _table_exists(bind, 'support_tickets'):
op.execute(sa.text("""
CREATE TABLE support_tickets (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
customer_id INT NULL,
facility_id INT NULL,
subject VARCHAR(200) NOT NULL,
body TEXT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'open',
created_at DATETIME NOT NULL,
CONSTRAINT fk_st_customer FOREIGN KEY (customer_id)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_st_facility FOREIGN KEY (facility_id)
REFERENCES facilities(id) ON DELETE SET NULL,
INDEX ix_support_tickets_customer (customer_id),
INDEX ix_support_tickets_status (status),
INDEX ix_support_tickets_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
if not _table_exists(bind, 'support_ticket_replies'):
op.execute(sa.text("""
CREATE TABLE support_ticket_replies (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
ticket_id INT NOT NULL,
user_id INT NULL,
body TEXT NOT NULL,
created_at DATETIME NOT NULL,
CONSTRAINT fk_str_ticket FOREIGN KEY (ticket_id)
REFERENCES support_tickets(id) ON DELETE CASCADE,
CONSTRAINT fk_str_user FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE SET NULL,
INDEX ix_support_replies_ticket (ticket_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
"""))
def downgrade():
bind = op.get_bind()
if _table_exists(bind, 'support_ticket_replies'):
op.execute(sa.text('DROP TABLE support_ticket_replies'))
if _table_exists(bind, 'support_tickets'):
op.execute(sa.text('DROP TABLE support_tickets'))
+1
View File
@@ -21,3 +21,4 @@ reportlab
pytz
pyJWT
openpyxl
groq