Files
LT_Janitorial_Quality_Control/app/routes/support.py
T

680 lines
29 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__)
# ── 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, editing templates, managing users, \
notification matrix, etc.) from their customer login. One important exception: a person \
who works for the customer CAN be enrolled with an inspecting role of their own and \
then conduct inspections in the app — see USING THE APP TO INSPECT below.
=== 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 (0100%). Interpretation:
- 90%+ = Excellent, 8089% = Good, 7079% = 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.
=== USING THE APP TO INSPECT (client staff, contract administrators, inspectors) ===
Q: Can a client/customer, contract administrator, or inspector use the JQC app to \
conduct inspections?
A: Yes. A customer's employee can use the JQC app not only for cleaning inspections but \
also to carry out preventive maintenance on the property and to submit service requests \
to third parties. They download the app, complete the enrollment form, and they are \
ready to go.
Such a person is enrolled with an inspecting role of their own (rather than the \
read-only customer portal role), scoped to that customer's own contracts and facilities. \
Enrollment is the JQC Enrollment Form, reachable from the About Us page ("Enroll More \
People"), where you list each person, their role, and what they should be able to do. \
Each person then receives an email invitation to set up their own username and password.
=== 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?'},
{'icon': 'bi-phone', 'text': 'Can our own staff use the JQC app to conduct inspections?'},
]
# 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/<int:session_id>')
@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/<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)
# ── 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/<int:session_id>')
@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/<int:entry_id>/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/<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]
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()