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 used by a janitorial \ service provider and its clients. You help the client (customer) understand and use \ their portal. Only describe what a CUSTOMER can do — do not tell customers they can \ perform staff-only actions (assigning issues, running inspections, editing templates, \ managing users, notification matrix, etc.). === WHAT JQC DOES === The janitorial provider performs quality inspections of the customer's facilities \ against checklist templates, tracks any problems ("issues"), and shares scores and \ reports. Work is organized as: Contracts → Facilities → Areas. A customer only sees \ the facilities they are assigned to. === CUSTOMER PORTAL NAVIGATION === - Dashboard: at-a-glance cards — open issues (split by who handles them), issues \ opened/resolved today, recent inspections, and a "Your Facilities" panel with search. - Facilities: the customer's assigned facilities; open one to see its details, areas, \ scorecard, and QR code. - Inspections: completed and in-progress inspections at their facilities, with scores; \ open one to see the checklist results and any flagged issues. - Issues: all cleaning issues at their facilities; filter by status, severity, facility, \ date. Customers can log a new issue here. - Reports: facility scorecards, score trends, "Avg Score by Facility" (filterable by \ Contract), and downloadable PDF summaries. - Support: this AI chat (Ask a Question), My Conversations (saved chats), and \ My Requests (support tickets they submitted). === INSPECTION SCORES === Each completed inspection has an overall score (0–100%). Interpretation: - 90%+ = Excellent, 80–89% = Good, 70–79% = Fair/Satisfactory, below 70% = Needs Improvement. Scorecards and the Reports page show a facility's average score and its trend over time. \ Note: checklist items left unanswered (score 0) are excluded from the average. === ISSUES === - Lifecycle (status): Open → In Progress → Pending Verification → Resolved. - Severity: Critical, High, Medium, Low — this drives the SLA (resolution target). - "Handled By" tells you who is resolving it: * Janitorial Staff — the cleaning provider's own crew. * Facility Staff — the facility's own on-site staff are handling it. * External Vendor — an outside contractor was engaged. In every case a member of the provider's team stays responsible for following up and \ verifying the fix. - A customer can LOG a new issue (Issues → Log Issue / "Report a cleaning concern"): \ pick the facility, describe the problem, set severity, optionally attach a photo. \ Customers cannot assign issues to staff — the provider triages them. - FOLLOW an issue (the Follow button on the issue page) to get email + in-app \ notifications whenever its status changes. Customers can also comment on issues they \ reported or follow. === SLA (resolution targets by severity) === Critical = 4 hours, High = 24 hours, Medium = 72 hours, Low = 168 hours (7 days). \ These are targets measured from when the issue was reported; the system flags issues \ that are at risk of, or have passed, their SLA. === FACILITY QR CODES === Every facility has a printable QR code (from the facility's page, or "Print All QR \ Codes" on the Facilities page). Anyone can scan it — no login — to see the facility's \ recent cleaning quality and to "Report a Problem" (which files an issue). Customers can \ view, print, and regenerate their facilities' QR codes; regenerating invalidates any \ previously printed code, so it must be reprinted. === NOTIFICATIONS === Customers get in-app (bell icon) and email notifications for relevant events — e.g. an \ inspection completed at their facility, or updates on issues they follow/reported. \ Notification Preferences let a customer turn specific email types off or switch to a \ digest. === GETTING HUMAN HELP === If the customer needs something this chat can't resolve — an access/login problem, a \ billing question, a specific scheduling request, or a concern that needs a person — tell \ them clearly and point them to the "Submit to Support" button (top of the chat), which \ opens a request that the provider's admin team answers by email and in "My Requests". === STYLE & RULES === - Be concise, warm, and practical. Prefer short paragraphs or numbered steps. - Ground answers in the features above. If you are not sure or the app may differ, say \ so honestly rather than guessing — and suggest "Submit to Support". - NEVER invent specific staff names, contract prices, cleaning schedules, phone numbers, \ facility data, or scores. You do not have access to the customer's live data — guide \ them to where to find it in the portal instead. - Do not claim to perform actions yourself; explain where in the portal the customer does it.\ """ # 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 or follow an 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-people', 'text': 'What does "Handled By" mean on an issue?'}, {'icon': 'bi-qr-code', 'text': "How do I print my facility's QR code?"}, {'icon': 'bi-bell', 'text': 'How do I get notified on issue updates?'}, ] # Soft cap on injected knowledge to keep prompt size (and token cost) reasonable. _KB_MAX_CHARS = 6000 # ── PII redaction for the outbound Groq payload ─────────────────────────────── # Groq is a third-party processor. Customers may type identifying details # (their own email/phone, or a coworker's) into a support question; there is # no need for that 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 text is still saved as-is in support_chat_messages # so the customer's own conversation history reads normally in the app. 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 def _system_prompt_with_kb(): """Return the base system prompt plus all ACTIVE admin knowledge entries (phase38), so staff can curate the chatbot's knowledge without code changes. Best-effort — a KB failure never breaks the chat.""" prompt = _SYSTEM_PROMPT try: entries = (SupportKnowledge.query .filter_by(active=True) .order_by(SupportKnowledge.sort_order.asc(), SupportKnowledge.id.asc()) .all()) if entries: parts = ["\n\n=== ADDITIONAL KNOWLEDGE (curated by the JQC team; " "treat as authoritative and prefer it over general guesses) ==="] total = 0 for e in entries: block = f"\n\nTopic: {e.title}\n{e.content.strip()}" if total + len(block) > _KB_MAX_CHARS: break parts.append(block) total += len(block) prompt += ''.join(parts) except Exception as exc: logger.warning('SUPPORT | knowledge-base load failed: %s', exc) return prompt # ── 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 [] # Load the customer's most recent conversation so it continues on return. # A ?new=1 param (New conversation button) starts a fresh, empty window. start_new = request.args.get('new') session = None if not start_new: session = (SupportChatSession.query .filter_by(customer_id=current_user.id) .order_by(SupportChatSession.updated_at.desc()) .first()) chat_history = [] if session: chat_history = [ {'role': m.role, 'content': m.content} for m in session.messages.order_by(SupportChatMessage.created_at.asc()).all() ] groq_ready = bool(os.environ.get('GROQ_API_KEY')) return render_template('support/chat.html', faqs=FAQS, facilities=facilities, groq_ready=groq_ready, chat_session_id=(session.id if session else None), chat_history=chat_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 data = request.get_json(silent=True) or {} history = data.get('messages', []) # list of {role, content} dicts user_message = data.get('message', '').strip() session_id = data.get('session_id') if not user_message: return jsonify({'error': 'Empty message'}), 400 # ── Generate the reply ──────────────────────────────────────────────── api_key = os.environ.get('GROQ_API_KEY') if not api_key: reply = ("I'm sorry, the AI assistant isn't configured right now. " "Please use the **Submit to Support** form to reach our team directly.") else: try: from groq import Groq client = Groq(api_key=api_key) messages = [{'role': 'system', 'content': _system_prompt_with_kb()}] # Append prior conversation (cap at last 20 turns to control token usage). # Redact PII-shaped text before it leaves the app for the Groq API — # the unredacted originals stay in support_chat_messages below. for m in history[-20:]: if m.get('role') in ('user', 'assistant') and m.get('content'): 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() except Exception as exc: logger.error('SUPPORT | Groq error: %s', exc) reply = ("I ran into a problem reaching the AI assistant. " "Please try again, or use **Submit to Support** to contact our team.") # ── Persist the turn (user message + assistant reply) ───────────────── now = now_eastern() session = None if session_id: session = db.session.get(SupportChatSession, session_id) if session is not None and session.customer_id != current_user.id: session = None # never write into someone else's session if session is None: session = SupportChatSession(customer_id=current_user.id, created_at=now, updated_at=now) db.session.add(session) db.session.flush() # assign session.id db.session.add(SupportChatMessage(session_id=session.id, role='user', content=user_message, created_at=now)) db.session.add(SupportChatMessage(session_id=session.id, role='assistant', content=reply, created_at=now)) session.updated_at = now db.session.commit() return jsonify({'reply': reply, 'session_id': session.id}) # ── Customer: saved 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.updated_at.desc()) .all()) return render_template('support/my_conversations.html', sessions=sessions) @bp.route('/my-conversations/') @login_required def conversation_detail(session_id): if current_user.role != 'customer': abort(403) session = db.session.get(SupportChatSession, session_id) if session is None or session.customer_id != current_user.id: abort(404) messages = session.messages.order_by(SupportChatMessage.created_at.asc()).all() return render_template('support/conversation_detail.html', session=session, messages=messages) # ── 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/', 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/', 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) # ── Admin: view saved chat conversations (read-only) ────────────────────────── @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.updated_at.desc()) .paginate(page=page, per_page=25, error_out=False)) return render_template('support/admin_conversations.html', sessions=sessions) @bp.route('/admin/conversations/') @login_required @supervisor_required def admin_conversation_detail(session_id): session = db.session.get(SupportChatSession, session_id) if session is None: abort(404) messages = session.messages.order_by(SupportChatMessage.created_at.asc()).all() return render_template('support/admin_conversation_detail.html', session=session, messages=messages) # ── Admin: AI chatbot Knowledge Base ────────────────────────────────────────── @bp.route('/admin/knowledge') @login_required @supervisor_required def admin_knowledge(): entries = (SupportKnowledge.query .order_by(SupportKnowledge.sort_order.asc(), SupportKnowledge.id.asc()) .all()) groq_ready = bool(os.environ.get('GROQ_API_KEY')) return render_template('support/admin_knowledge.html', entries=entries, groq_ready=groq_ready) @bp.route('/admin/knowledge/new', methods=['GET', 'POST']) @login_required @supervisor_required def admin_knowledge_new(): from app.utils.forms import SupportKnowledgeForm form = SupportKnowledgeForm() if form.validate_on_submit(): entry = SupportKnowledge( title = form.title.data.strip(), content = form.content.data.strip(), sort_order = form.sort_order.data or 0, active = form.active.data, 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, entry.title[:60]) flash('Knowledge entry added. The chatbot will use it immediately.', 'success') return redirect(url_for('support.admin_knowledge')) return render_template('support/admin_knowledge_form.html', form=form, title='New Knowledge Entry') @bp.route('/admin/knowledge//edit', methods=['GET', 'POST']) @login_required @supervisor_required def admin_knowledge_edit(entry_id): from app.utils.forms import SupportKnowledgeForm entry = db.session.get(SupportKnowledge, entry_id) if entry is None: abort(404) form = SupportKnowledgeForm(obj=entry) if form.validate_on_submit(): entry.title = form.title.data.strip() entry.content = form.content.data.strip() entry.sort_order = form.sort_order.data or 0 entry.active = form.active.data entry.updated_at = now_eastern() db.session.commit() log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, entry.title[:60]) flash('Knowledge entry updated.', 'success') return redirect(url_for('support.admin_knowledge')) return render_template('support/admin_knowledge_form.html', form=form, title='Edit Knowledge Entry', entry=entry) @bp.route('/admin/knowledge//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] eid = entry.id db.session.delete(entry) db.session.commit() log_action(ACTION_DELETE, 'SupportKnowledge', eid, label) flash('Knowledge entry deleted.', 'success') return redirect(url_for('support.admin_knowledge')) 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()