804 lines
34 KiB
Python
804 lines
34 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, 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__)
|
||
|
||
|
||
# ── Outbound PII redaction ────────────────────────────────────────────────────
|
||
# Groq is a THIRD PARTY. Customers routinely paste contact details (their own,
|
||
# or a coworker's) into a support question, and none of that needs 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 is still
|
||
# stored verbatim in support_chat_messages, so the customer's own conversation
|
||
# history reads normally in the app and staff see what was actually said.
|
||
#
|
||
# Best-effort by design: over-redacting a support question costs nothing, while
|
||
# under-redacting leaks a real address. Order matters — the 13–19 digit card
|
||
# pattern runs before the phone pattern so a card number is not partly consumed
|
||
# as a phone number first.
|
||
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
|
||
|
||
# ── 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.
|
||
- Ground answers in everything above, INCLUDING the "ADDITIONAL KNOWLEDGE" section when \
|
||
one is present — that section is curated by the provider's team and is authoritative. \
|
||
If it answers the question, use it.
|
||
- 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, 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?'},
|
||
{'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?"},
|
||
]
|
||
|
||
|
||
#: Groq model used when GROQ_MODEL is unset. Verified available Aug 2026.
|
||
#: Groq RETIRES models without notice, and when the configured one disappears
|
||
#: every question fails with the generic "problem reaching the AI assistant"
|
||
#: reply — invisible until a customer complains. That is exactly how
|
||
#: llama-3.3-70b-versatile took the chat down. See the error handler in
|
||
#: chat_message(): it names the model and says to set GROQ_MODEL, which fixes
|
||
#: it with an env change and a restart — no deploy.
|
||
_DEFAULT_GROQ_MODEL = 'openai/gpt-oss-120b'
|
||
|
||
|
||
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 _system_prompt_for) — 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.
|
||
|
||
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.
|
||
"""
|
||
|
||
|
||
#: The curated knowledge is spliced in immediately BEFORE this heading, not
|
||
#: appended after it. 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 = 'Rules:'
|
||
|
||
|
||
def _system_prompt_for(user):
|
||
"""Base prompt + curated knowledge, plus the addendum for this user's role.
|
||
|
||
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 Groq system prompt with active knowledge entries spliced in.
|
||
|
||
Best-effort — a knowledge-base 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 provider 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.body or "").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)
|
||
|
||
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)
|
||
|
||
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=chat_session,
|
||
db_history=db_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
|
||
|
||
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_for(current_user)}]
|
||
# Redact before the text leaves the app for Groq. The unredacted
|
||
# originals are persisted below, so nothing is lost in-app.
|
||
for m in prior[-20:]:
|
||
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', _DEFAULT_GROQ_MODEL)
|
||
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:
|
||
# Always name the model — a bare "Groq error" gives whoever reads the
|
||
# log nothing to act on, and a retired model is the most likely cause
|
||
# of a total outage here.
|
||
_model = locals().get('model') or os.environ.get('GROQ_MODEL', _DEFAULT_GROQ_MODEL)
|
||
if 'model_not_found' in str(exc) or 'does not exist' in str(exc):
|
||
logger.error(
|
||
'SUPPORT | Groq model %r is not available on this account — '
|
||
'the assistant is DOWN for every user. Set GROQ_MODEL to a '
|
||
'current model (see https://console.groq.com/docs/models). '
|
||
'Underlying error: %s', _model, exc)
|
||
else:
|
||
logger.error('SUPPORT | Groq error (model=%r): %s', _model, 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 not _is_customer_side(current_user):
|
||
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 not _is_customer_side(current_user):
|
||
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 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 the 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)
|
||
|
||
|
||
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():
|
||
# Same order the chat prompt uses, so the admin list shows the real
|
||
# priority rather than a different one.
|
||
entries = (SupportKnowledge.query
|
||
.order_by(SupportKnowledge.sort_order.asc(),
|
||
SupportKnowledge.id.asc()).all())
|
||
return render_template('support/admin_knowledge.html', entries=entries)
|
||
|
||
|
||
@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)
|
||
|
||
|
||
def _parse_sort_order(raw, fallback=0):
|
||
"""Coerce a submitted sort_order to a sane int.
|
||
|
||
The column is NOT NULL, so a blank or non-numeric field must not reach the
|
||
DB. Clamped to 0..9999 to match the range ST validates, and falls back to
|
||
the existing value on edit so a blank field means "leave it alone" rather
|
||
than silently resetting the entry to the top.
|
||
"""
|
||
raw = (raw or '').strip()
|
||
if not raw:
|
||
return fallback
|
||
try:
|
||
return max(0, min(9999, int(raw)))
|
||
except (TypeError, ValueError):
|
||
return fallback
|
||
|
||
|
||
@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,
|
||
sort_order = _parse_sort_order(request.form.get('sort_order')),
|
||
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.sort_order = _parse_sort_order(request.form.get('sort_order'),
|
||
entry.sort_order)
|
||
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'))
|