743 lines
33 KiB
Python
743 lines
33 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
|
||
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 (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.
|
||
|
||
=== 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?'},
|
||
]
|
||
|
||
|
||
# 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
|
||
|
||
|
||
#: 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_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 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/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()
|