Files
JQC_multi_tenant/app/routes/support.py
T

602 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import logging
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, jsonify, abort)
from flask_login import login_required, current_user
from app import db
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, ACTION_DELETE
from app.utils.time_utils import now_eastern
from app.utils.notifications import notify
bp = Blueprint('support', __name__, url_prefix='/support')
logger = logging.getLogger(__name__)
# ── Outbound PII redaction ────────────────────────────────────────────────────
# Groq is a THIRD PARTY. Customers routinely paste contact details (their own,
# or a coworker's) into a support question, and none of that needs to leave the
# app to get a helpful, generic answer. This scrubs a best-effort set of PII
# patterns from the copy of the text sent to Groq ONLY — the original is still
# stored verbatim in support_chat_messages, so the customer's own conversation
# history reads normally in the app and staff see what was actually said.
#
# Best-effort by design: over-redacting a support question costs nothing, while
# under-redacting leaks a real address. Order matters — the 1319 digit card
# pattern runs before the phone pattern so a card number is not partly consumed
# as a phone number first.
import re as _re
_PII_PATTERNS = [
(_re.compile(r'[\w.+-]+@[\w-]+\.[\w.-]+'), '[redacted-email]'),
(_re.compile(r'\b\d{3}-\d{2}-\d{4}\b'), '[redacted-ssn]'),
(_re.compile(r'\b(?:\d[ -]?){13,19}\b'), '[redacted-number]'),
(_re.compile(r'\b(?:\+?1[ .-]?)?\(?\d{3}\)?[ .-]?\d{3}[ .-]?\d{4}\b'), '[redacted-phone]'),
]
def _redact_pii(text):
"""Best-effort scrub of email/phone/SSN/card-like sequences from outbound text."""
if not text:
return text
redacted = text
for pattern, placeholder in _PII_PATTERNS:
redacted = pattern.sub(placeholder, redacted)
return redacted
# ── 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.\
"""
_KB_MAX_CHARS = 6000
# Preset FAQ questions shown as quick-reply chips on first load
FAQS = [
{'icon': 'bi-clipboard-check', 'text': 'How do I view my inspection reports?'},
{'icon': 'bi-graph-up', 'text': 'What do inspection scores mean?'},
{'icon': 'bi-exclamation-circle', 'text': 'How do I track an open issue?'},
{'icon': 'bi-megaphone', 'text': 'How do I report a cleaning concern?'},
{'icon': 'bi-alarm', 'text': 'What is SLA and how does it work?'},
{'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'},
]
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')
@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'))
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,
chat_session=chat_session,
db_history=db_history)
# ── 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 {}
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_with_kb()}]
# Redact before the text leaves the app for Groq. The unredacted
# originals are persisted below, so nothing is lost in-app.
for m in prior[-20:]:
messages.append({'role': m.role, 'content': _redact_pii(m.content)})
messages.append({'role': 'user', 'content': _redact_pii(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()
# 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'])
@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.my_tickets'))
# ── Customer: my tickets list ─────────────────────────────────────────────────
@bp.route('/my-tickets')
@login_required
def my_tickets():
if current_user.role != 'customer':
abort(403)
tickets = (SupportTicket.query
.filter_by(customer_id=current_user.id)
.order_by(SupportTicket.created_at.desc())
.all())
return render_template('support/my_tickets.html', tickets=tickets)
# ── Customer: ticket detail ───────────────────────────────────────────────────
@bp.route('/my-tickets/<int:ticket_id>', methods=['GET', 'POST'])
@login_required
def my_ticket_detail(ticket_id):
if current_user.role != 'customer':
abort(403)
ticket = db.session.get(SupportTicket, ticket_id)
if ticket is None or ticket.customer_id != current_user.id:
abort(404)
if request.method == 'POST':
if ticket.status == 'closed':
flash('This ticket is closed and cannot receive new replies.', 'warning')
return redirect(url_for('support.my_ticket_detail', ticket_id=ticket_id))
body = request.form.get('body', '').strip()
if not body:
flash('Reply cannot be empty.', 'warning')
return redirect(url_for('support.my_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)
# Reopen if it was answered so admin sees there's a follow-up
if ticket.status == 'answered':
ticket.status = 'open'
db.session.commit()
log_action(ACTION_CREATE, 'SupportTicketReply', reply.id,
f'ticket #{ticket.id}',
f'customer reply by {current_user.username}')
_notify_admins_customer_reply(ticket, reply)
flash('Your reply has been sent.', 'success')
return redirect(url_for('support.my_ticket_detail', ticket_id=ticket_id))
replies = ticket.replies.order_by(SupportTicketReply.created_at.asc()).all()
return render_template('support/my_ticket_detail.html',
ticket=ticket, replies=replies)
def _notify_admins_new_ticket(ticket):
"""Create in-app notifications and send emails to all active admin users."""
admins = User.query.filter_by(role='admin', active=True).all()
if not admins:
return
customer_label = ticket.customer.display_name if ticket.customer else 'Unknown'
facility_label = ticket.facility.name if ticket.facility else 'N/A'
link = url_for('support.admin_ticket_detail', ticket_id=ticket.id)
title = f'New support ticket #{ticket.id} from {customer_label}'
body = (f'Subject: {ticket.subject}\n'
f'Facility: {facility_label}\n\n'
f'{ticket.body[:300]}{"…" if len(ticket.body) > 300 else ""}')
for admin in admins:
notify(recipient=admin, title=title, body=body, link=link, send_email=True)
db.session.commit()
# ── 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):
"""Create an in-app notification and send an email to the customer."""
if not ticket.customer:
return
admin_name = reply.author.display_name if reply.author else 'Support Team'
title = f'Reply to your support request #{ticket.id}'
body = (f'{admin_name} replied to your ticket "{ticket.subject}":\n\n'
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)
db.session.commit()
def _notify_admins_customer_reply(ticket, reply):
"""Notify admins when a customer adds a follow-up reply to their ticket."""
admins = User.query.filter_by(role='admin', active=True).all()
if not admins:
return
customer_label = ticket.customer.display_name if ticket.customer else 'Unknown'
link = url_for('support.admin_ticket_detail', ticket_id=ticket.id)
title = f'Customer reply on ticket #{ticket.id} from {customer_label}'
body = (f'Re: {ticket.subject}\n\n'
f'{reply.body[:400]}{"…" if len(reply.body) > 400 else ""}')
for admin in admins:
notify(recipient=admin, title=title, body=body, link=link, send_email=True)
db.session.commit()
# ── Admin: AI conversations list ──────────────────────────────────────────────
@bp.route('/admin/conversations')
@login_required
@supervisor_required
def admin_conversations():
page = request.args.get('page', 1, type=int)
sessions = (SupportChatSession.query
.order_by(SupportChatSession.last_msg_at.desc())
.paginate(page=page, per_page=25, error_out=False))
return render_template('support/admin_conversations.html', sessions=sessions)
@bp.route('/admin/conversations/<int:session_id>')
@login_required
@supervisor_required
def admin_conversation_detail(session_id):
chat_session = db.session.get(SupportChatSession, session_id)
if chat_session is None:
abort(404)
return render_template('support/conversation_detail.html',
chat_session=chat_session,
messages=list(chat_session.messages),
is_admin=True)
# ── Admin: knowledge base ─────────────────────────────────────────────────────
@bp.route('/admin/knowledge')
@login_required
@supervisor_required
def admin_knowledge():
entries = SupportKnowledge.query.order_by(SupportKnowledge.created_at.desc()).all()
return render_template('support/admin_knowledge.html', entries=entries)
@bp.route('/admin/knowledge/add', methods=['POST'])
@login_required
@supervisor_required
def admin_knowledge_add():
title = request.form.get('title', '').strip()
body = request.form.get('body', '').strip()
if not title or not body:
flash('Title and body are both required.', 'warning')
return redirect(url_for('support.admin_knowledge'))
entry = SupportKnowledge(
title = title,
body = body,
active = True,
created_by = current_user.id,
created_at = now_eastern(),
updated_at = now_eastern(),
)
db.session.add(entry)
db.session.commit()
log_action(ACTION_CREATE, 'SupportKnowledge', entry.id, title[:60], '')
flash('Knowledge entry added.', 'success')
return redirect(url_for('support.admin_knowledge'))
@bp.route('/admin/knowledge/<int:entry_id>/edit', methods=['GET', 'POST'])
@login_required
@supervisor_required
def admin_knowledge_edit(entry_id):
entry = db.session.get(SupportKnowledge, entry_id)
if entry is None:
abort(404)
if request.method == 'POST':
title = request.form.get('title', '').strip()
body = request.form.get('body', '').strip()
if not title or not body:
flash('Title and body are both required.', 'warning')
return redirect(url_for('support.admin_knowledge_edit', entry_id=entry_id))
entry.title = title
entry.body = body
entry.updated_at = now_eastern()
db.session.commit()
log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, title[:60], 'edited')
flash('Knowledge entry updated.', 'success')
return redirect(url_for('support.admin_knowledge'))
return render_template('support/admin_knowledge_edit.html', entry=entry)
@bp.route('/admin/knowledge/<int:entry_id>/toggle', methods=['POST'])
@login_required
@supervisor_required
def admin_knowledge_toggle(entry_id):
entry = db.session.get(SupportKnowledge, entry_id)
if entry is None:
abort(404)
entry.active = not entry.active
entry.updated_at = now_eastern()
db.session.commit()
log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, entry.title[:60],
f'active={entry.active}')
flash(f'Entry {"activated" if entry.active else "deactivated"}.', 'success')
return redirect(url_for('support.admin_knowledge'))
@bp.route('/admin/knowledge/<int:entry_id>/delete', methods=['POST'])
@login_required
@supervisor_required
def admin_knowledge_delete(entry_id):
entry = db.session.get(SupportKnowledge, entry_id)
if entry is None:
abort(404)
label = entry.title[:60]
db.session.delete(entry)
db.session.commit()
log_action(ACTION_DELETE, 'SupportKnowledge', entry_id, label, '')
flash('Knowledge entry deleted.', 'success')
return redirect(url_for('support.admin_knowledge'))