339 lines
14 KiB
Python
339 lines
14 KiB
Python
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 & 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()
|