Files
JQC_multi_tenant/app/routes/support.py
T

568 lines
22 KiB
Python

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__)
# ── 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()}]
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')
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'))