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"""\
From: {customer_label}
Facility: {facility_label}
Subject: {ticket.subject}
{ticket.body}
JQC Support System — automated notification.
""" 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/Hi {ticket.customer.display_name},
{admin_name} replied to your support ticket #{ticket.id}: {ticket.subject}:
{reply.body}
JQC Support System — automated notification.
""" 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()