Files
LT_Janitorial_Quality_Control/app/routes/support.py
T

835 lines
37 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, get_inspector_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. \
See ENROLLING MORE PEOPLE below for the form.
=== ENROLLING MORE PEOPLE (adding users) ===
To add colleagues to JQC, use the JQC Enrollment Form:
https://jqc.ltservicesinc.com/enrollment
It is also linked from the About Us page in the portal ("Enroll More People"). No login \
is needed to fill it in, so it can be forwarded to anyone who needs to be set up.
On the form you list each person (name, job title, email and the role they need), tick \
what each of them should be able to do, and say who should get the mobile app. After \
submitting, the requester receives a confirmation email with a reference number, and \
each person listed then receives their own email invitation to choose a username and \
password.
When a customer asks how to add a user, get the app, or give someone inspecting access, \
give them this URL. It is the correct link — do not alter it or invent another one.
=== 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 everything above, INCLUDING the "ADDITIONAL KNOWLEDGE" section when \
one is present — that section is curated by the JQC team and is authoritative. If it \
answers the question, use it. If you are not sure or the app may differ, say so \
honestly rather than guessing — and suggest "Submit to Support".
- When the knowledge above contains a link (URL), email address or exact wording, quote \
it EXACTLY as written. Repeating something given to you here is not inventing — do it \
freely. Never alter a URL, shorten it, or replace it with a description.
- NEVER invent specific staff names, contract prices, cleaning schedules, phone numbers, \
facility data, or scores that are NOT given above. 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?'},
]
#: Extra chips shown to a Customer Inspector, whose questions are about doing
#: the work rather than reading the results. Appended to FAQS, not replacing
#: them — they still care about scores and issues.
INSPECTOR_FAQS = [
{'icon': 'bi-clipboard-plus', 'text': 'How do I start an inspection on the iPad?'},
{'icon': 'bi-wifi-off', 'text': 'What happens if I lose signal during an inspection?'},
{'icon': 'bi-flag', 'text': 'How do I flag an issue while inspecting?'},
{'icon': 'bi-search', 'text': "Why can't I see a form for this facility?"},
]
# 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 _is_customer_side(user):
"""True for both customer-side roles — Director and Customer Inspector.
The AI assistant and the ticket flow are for the CUSTOMER organisation, and
a Customer Inspector is part of it: they work at the customer's facilities
and have the same questions about scores, issues and the app. This is one
of the few places where User.CUSTOMER_ROLES is the right test; every
capability/scoping decision below still branches per role (see
_support_facilities and _role_addendum), which is what keeps rule 89
intact — the two roles get the same DOOR, not the same answers.
"""
return getattr(user, 'is_customer_account', False)
def _support_facilities(user):
"""The facilities this user may pick on a support ticket.
Directors are scoped by CustomerAssignment, Customer Inspectors by
InspectorAssignment — reusing the customer helper for both would silently
return nothing for an inspector (it returns None for any non-'customer'
role) and the facility dropdown would come up empty.
"""
if getattr(user, 'is_inspector', False):
fids = get_inspector_scope(user) or []
else:
fids = get_customer_scope(user) or []
if not fids:
return []
return (Facility.query
.filter(Facility.id.in_(fids), Facility.active == True)
.order_by(Facility.name).all())
#: Appended to the system prompt for a Customer Inspector. The base prompt is
#: written for the read-mostly portal customer and explicitly tells the model
#: NOT to describe staff actions; without this the assistant would deny a
#: Customer Inspector the very things they are employed to do.
_INSPECTOR_ADDENDUM = """\
=== ABOUT THE PERSON YOU ARE TALKING TO: CUSTOMER INSPECTOR ===
This user works FOR the customer but holds an inspecting role in JQC, limited to \
the contracts they have been assigned. This section OVERRIDES the "only describe \
what a customer can do" restriction above, for this user only.
Everything above about the portal still applies to their assigned facilities. IN \
ADDITION, they can:
- Conduct inspections themselves — start one on the web (Inspections → New \
Inspection) or in the JQC iPad app, fill in the checklist form, add photos, and \
submit it.
- Use the iPad app OFFLINE: inspections and photos are stored on the device and \
sync automatically when back online.
- Flag an issue during an inspection, and log new issues at their facilities.
- Assign an issue to an inspector working on the SAME contract (their own \
colleagues, or the provider's inspectors) — never to anyone outside it.
- Update an issue's status, add comments, and set "Handled By" \
(Janitorial Staff / Facility Staff / External Vendor) from the iPad.
- Work from Scheduled Inspections assigned to them: confirm receipt of the \
request, then Start it when they are on site.
They CANNOT: verify or close out issues (the provider's admin/director does that), \
manage users, create or edit inspection forms, change the notification matrix, or \
see anything outside their assigned contracts. If they ask for one of those, say \
who to ask instead — their own Customer Director, or the provider's team via \
"Submit to Support".
Note on forms: the inspection forms they can choose from are the shared standard \
forms plus any built specifically for their contract. A form built for a different \
customer will never appear.
"""
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
#: The curated knowledge is spliced in immediately BEFORE this heading, not
#: appended to the end of the prompt. The rules under it say "ground answers in
#: everything above", so knowledge appended after them was, by the prompt's own
#: instruction, out of scope — which is exactly why admin KB entries appeared to
#: be ignored. Keep this marker in sync with the heading in _SYSTEM_PROMPT.
_STYLE_MARKER = '=== STYLE & RULES ==='
def _system_prompt_for(user):
"""Base prompt plus the addendum for this user's role, then the KB.
Kept separate from _system_prompt_with_kb() so the curated knowledge base
still lands at the same marker regardless of role.
"""
prompt = _system_prompt_with_kb()
if getattr(user, 'is_external_inspector', False):
prompt += _INSPECTOR_ADDENDUM
return prompt
def _system_prompt_with_kb():
"""Return the base system prompt with all ACTIVE admin knowledge entries
(phase38) spliced in, so staff can curate the chatbot's knowledge without
code changes. Best-effort — a KB failure never breaks the chat."""
try:
entries = (SupportKnowledge.query
.filter_by(active=True)
.order_by(SupportKnowledge.sort_order.asc(), SupportKnowledge.id.asc())
.all())
if not entries:
logger.info('SUPPORT | KB | no active entries — base prompt only')
return _SYSTEM_PROMPT
parts = ['=== ADDITIONAL KNOWLEDGE (curated by the JQC team; authoritative — '
'prefer it over general guesses, and quote any link in it exactly) ===']
total = 0
used = 0
for e in entries:
block = f"\n\nTopic: {e.title}\n{e.content.strip()}"
if total + len(block) > _KB_MAX_CHARS:
logger.warning('SUPPORT | KB | %d of %d entries dropped — %d char cap '
'reached', len(entries) - used, len(entries), _KB_MAX_CHARS)
break
parts.append(block)
total += len(block)
used += 1
kb_block = ''.join(parts)
idx = _SYSTEM_PROMPT.find(_STYLE_MARKER)
if idx == -1: # marker renamed — fall back to append
logger.warning('SUPPORT | KB | style marker not found; appending at end')
prompt = f'{_SYSTEM_PROMPT}\n\n{kb_block}'
else:
prompt = (f'{_SYSTEM_PROMPT[:idx]}{kb_block}\n\n{_SYSTEM_PROMPT[idx:]}')
logger.info('SUPPORT | KB | %d/%d entries injected (%d chars), prompt=%d chars',
used, len(entries), total, len(prompt))
return prompt
except Exception as exc:
logger.warning('SUPPORT | knowledge-base load failed: %s', exc)
return _SYSTEM_PROMPT
# ── Customer chat page ────────────────────────────────────────────────────────
@bp.route('/chat')
@login_required
def chat():
if not _is_customer_side(current_user):
return redirect(url_for('support.admin_tickets'))
facilities = _support_facilities(current_user)
# 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'))
faqs = (FAQS + INSPECTOR_FAQS) if current_user.is_external_inspector else FAQS
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 not _is_customer_side(current_user):
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_for(current_user)}]
# 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 not _is_customer_side(current_user):
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 not _is_customer_side(current_user):
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 not _is_customer_side(current_user):
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 user — by whichever assignment table
# their role is scoped through.
cids = [f.id for f in _support_facilities(current_user)]
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 not _is_customer_side(current_user):
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 not _is_customer_side(current_user):
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/preview')
@login_required
@supervisor_required
def admin_knowledge_preview():
"""Show the exact system prompt the chatbot receives, knowledge included.
Added after admin entries appeared to be ignored: without this there is no
way to tell "my entry never reached the prompt" from "the model saw it and
chose not to use it". Read-only, builds nothing of its own — it calls the
same _system_prompt_with_kb() the chat endpoint calls.
"""
prompt = _system_prompt_with_kb()
active_count = SupportKnowledge.query.filter_by(active=True).count()
total_count = SupportKnowledge.query.count()
return render_template('support/admin_knowledge_preview.html',
prompt=prompt,
active_count=active_count,
total_count=total_count,
kb_included='=== ADDITIONAL KNOWLEDGE' in prompt,
kb_cap=_KB_MAX_CHARS)
@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()