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/') @login_required def conversation_detail(session_id): if current_user.role != 'customer': abort(403) session = db.session.get(SupportChatSession, session_id) if session is None or session.customer_id != current_user.id: abort(404) messages = session.messages.order_by(SupportChatMessage.created_at.asc()).all() return render_template('support/conversation_detail.html', session=session, messages=messages) # ── Submit support ticket ───────────────────────────────────────────────────── @bp.route('/tickets', methods=['POST']) @login_required def submit_ticket(): if current_user.role != 'customer': abort(403) subject = request.form.get('subject', '').strip() body = request.form.get('body', '').strip() facility_id = request.form.get('facility_id', type=int) if not subject or not body: flash('Please fill in both subject and description.', 'warning') return redirect(url_for('support.chat')) # Validate facility belongs to this customer cids = get_customer_scope(current_user) or [] if facility_id and facility_id not in cids: facility_id = None ticket = SupportTicket( customer_id = current_user.id, facility_id = facility_id, subject = subject, body = body, status = 'open', created_at = now_eastern(), ) db.session.add(ticket) db.session.commit() log_action(ACTION_CREATE, 'SupportTicket', ticket.id, f'#{ticket.id}: {subject[:60]}', f'customer={current_user.username}') _notify_admins_new_ticket(ticket) flash('Your message has been submitted. Our team will get back to you soon.', 'success') return redirect(url_for('support.my_tickets')) # ── Customer: my tickets list ───────────────────────────────────────────────── @bp.route('/my-tickets') @login_required def my_tickets(): if current_user.role != 'customer': abort(403) tickets = (SupportTicket.query .filter_by(customer_id=current_user.id) .order_by(SupportTicket.created_at.desc()) .all()) return render_template('support/my_tickets.html', tickets=tickets) # ── Customer: ticket detail ─────────────────────────────────────────────────── @bp.route('/my-tickets/', methods=['GET', 'POST']) @login_required def my_ticket_detail(ticket_id): if current_user.role != 'customer': abort(403) ticket = db.session.get(SupportTicket, ticket_id) if ticket is None or ticket.customer_id != current_user.id: abort(404) if request.method == 'POST': if ticket.status == 'closed': flash('This ticket is closed and cannot receive new replies.', 'warning') return redirect(url_for('support.my_ticket_detail', ticket_id=ticket_id)) body = request.form.get('body', '').strip() if not body: flash('Reply cannot be empty.', 'warning') return redirect(url_for('support.my_ticket_detail', ticket_id=ticket_id)) reply = SupportTicketReply( ticket_id = ticket.id, user_id = current_user.id, body = body, created_at = now_eastern(), ) db.session.add(reply) # Reopen if it was answered so admin sees there's a follow-up if ticket.status == 'answered': ticket.status = 'open' db.session.commit() log_action(ACTION_CREATE, 'SupportTicketReply', reply.id, f'ticket #{ticket.id}', f'customer reply by {current_user.username}') _notify_admins_customer_reply(ticket, reply) flash('Your reply has been sent.', 'success') return redirect(url_for('support.my_ticket_detail', ticket_id=ticket_id)) replies = ticket.replies.order_by(SupportTicketReply.created_at.asc()).all() return render_template('support/my_ticket_detail.html', ticket=ticket, replies=replies) def _notify_admins_new_ticket(ticket): """Create in-app notifications and send emails to all active admin users.""" admins = User.query.filter_by(role='admin', active=True).all() if not admins: return customer_label = ticket.customer.display_name if ticket.customer else 'Unknown' facility_label = ticket.facility.name if ticket.facility else 'N/A' link = url_for('support.admin_ticket_detail', ticket_id=ticket.id) title = f'New support ticket #{ticket.id} from {customer_label}' body = (f'Subject: {ticket.subject}\n' f'Facility: {facility_label}\n\n' f'{ticket.body[:300]}{"…" if len(ticket.body) > 300 else ""}') for admin in admins: notify( recipient = admin, title = title, body = body, link = link, send_email = True, ) db.session.commit() # ── Admin: ticket list ──────────────────────────────────────────────────────── @bp.route('/admin/tickets') @login_required @supervisor_required def admin_tickets(): status_filter = request.args.get('status', '') page = request.args.get('page', 1, type=int) q = SupportTicket.query.order_by(SupportTicket.created_at.desc()) if status_filter: q = q.filter(SupportTicket.status == status_filter) tickets = q.paginate(page=page, per_page=25, error_out=False) return render_template('support/admin_tickets.html', tickets=tickets, status_filter=status_filter) # ── Admin: ticket detail + reply ────────────────────────────────────────────── @bp.route('/admin/tickets/', methods=['GET', 'POST']) @login_required @supervisor_required def admin_ticket_detail(ticket_id): ticket = db.session.get(SupportTicket, ticket_id) if ticket is None: abort(404) if request.method == 'POST': action = request.form.get('action') if action == 'reply': body = request.form.get('body', '').strip() if not body: flash('Reply cannot be empty.', 'warning') return redirect(url_for('support.admin_ticket_detail', ticket_id=ticket_id)) reply = SupportTicketReply( ticket_id = ticket.id, user_id = current_user.id, body = body, created_at = now_eastern(), ) db.session.add(reply) # Auto-advance status to answered if still open if ticket.status == 'open': ticket.status = 'answered' db.session.commit() log_action(ACTION_UPDATE, 'SupportTicket', ticket.id, f'#{ticket.id}: {ticket.subject[:60]}', f'reply added by {current_user.username}') _notify_customer_reply(ticket, reply) flash('Reply sent.', 'success') elif action == 'status': new_status = request.form.get('status', '') if new_status in ('open', 'answered', 'closed'): ticket.status = new_status db.session.commit() log_action(ACTION_UPDATE, 'SupportTicket', ticket.id, f'#{ticket.id}: {ticket.subject[:60]}', f'status={new_status}') flash(f'Ticket marked as {new_status}.', 'success') return redirect(url_for('support.admin_ticket_detail', ticket_id=ticket_id)) replies = ticket.replies.order_by(SupportTicketReply.created_at.asc()).all() return render_template('support/admin_ticket_detail.html', ticket=ticket, replies=replies) # ── Admin: view saved chat conversations (read-only) ────────────────────────── @bp.route('/admin/conversations') @login_required @supervisor_required def admin_conversations(): page = request.args.get('page', 1, type=int) sessions = (SupportChatSession.query .order_by(SupportChatSession.updated_at.desc()) .paginate(page=page, per_page=25, error_out=False)) return render_template('support/admin_conversations.html', sessions=sessions) @bp.route('/admin/conversations/') @login_required @supervisor_required def admin_conversation_detail(session_id): session = db.session.get(SupportChatSession, session_id) if session is None: abort(404) messages = session.messages.order_by(SupportChatMessage.created_at.asc()).all() return render_template('support/admin_conversation_detail.html', session=session, messages=messages) # ── Admin: AI chatbot Knowledge Base ────────────────────────────────────────── @bp.route('/admin/knowledge') @login_required @supervisor_required def admin_knowledge(): entries = (SupportKnowledge.query .order_by(SupportKnowledge.sort_order.asc(), SupportKnowledge.id.asc()) .all()) groq_ready = bool(os.environ.get('GROQ_API_KEY')) return render_template('support/admin_knowledge.html', entries=entries, groq_ready=groq_ready) @bp.route('/admin/knowledge/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//edit', methods=['GET', 'POST']) @login_required @supervisor_required def admin_knowledge_edit(entry_id): from app.utils.forms import SupportKnowledgeForm entry = db.session.get(SupportKnowledge, entry_id) if entry is None: abort(404) form = SupportKnowledgeForm(obj=entry) if form.validate_on_submit(): entry.title = form.title.data.strip() entry.content = form.content.data.strip() entry.sort_order = form.sort_order.data or 0 entry.active = form.active.data entry.updated_at = now_eastern() db.session.commit() log_action(ACTION_UPDATE, 'SupportKnowledge', entry.id, entry.title[:60]) flash('Knowledge entry updated.', 'success') return redirect(url_for('support.admin_knowledge')) return render_template('support/admin_knowledge_form.html', form=form, title='Edit Knowledge Entry', entry=entry) @bp.route('/admin/knowledge//delete', methods=['POST']) @login_required @supervisor_required def admin_knowledge_delete(entry_id): entry = db.session.get(SupportKnowledge, entry_id) if entry is None: abort(404) label = entry.title[:60] eid = entry.id db.session.delete(entry) db.session.commit() log_action(ACTION_DELETE, 'SupportKnowledge', eid, label) flash('Knowledge entry deleted.', 'success') return redirect(url_for('support.admin_knowledge')) def _notify_customer_reply(ticket, reply): """Create an in-app notification and send an email to the customer.""" if not ticket.customer: return admin_name = reply.author.display_name if reply.author else 'Support Team' title = f'Reply to your support request #{ticket.id}' body = (f'{admin_name} replied to your ticket "{ticket.subject}":\n\n' f'{reply.body[:400]}{"…" if len(reply.body) > 400 else ""}') link = url_for('support.my_ticket_detail', ticket_id=ticket.id) notify( recipient = ticket.customer, title = title, body = body, link = link, send_email = True, ) db.session.commit() def _notify_admins_customer_reply(ticket, reply): """Notify admins when a customer adds a follow-up reply to their ticket.""" admins = User.query.filter_by(role='admin', active=True).all() if not admins: return customer_label = ticket.customer.display_name if ticket.customer else 'Unknown' link = url_for('support.admin_ticket_detail', ticket_id=ticket.id) title = f'Customer reply on ticket #{ticket.id} from {customer_label}' body = (f'Re: {ticket.subject}\n\n' f'{reply.body[:400]}{"…" if len(reply.body) > 400 else ""}') for admin in admins: notify( recipient = admin, title = title, body = body, link = link, send_email = True, ) db.session.commit()