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/') @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/', 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/', 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/') @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//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//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//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'))