diff --git a/app/__init__.py b/app/__init__.py index 19644c5..f8ce9d2 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -116,13 +116,19 @@ def create_app(config_name='default'): pv_count = Issue.query.filter_by( status='pending_verification' ).count() + # Open support tickets — admin/director only + open_support = 0 + if current_user.role in ('admin', 'director'): + from app.models.support import SupportTicket + open_support = SupportTicket.query.filter_by(status='open').count() return { 'unread_notification_count': unread, 'pending_verification_count': pv_count, + 'open_support_tickets_count': open_support, } except Exception: pass - return {'unread_notification_count': 0, 'pending_verification_count': 0} + return {'unread_notification_count': 0, 'pending_verification_count': 0, 'open_support_tickets_count': 0} os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) @@ -133,6 +139,7 @@ def create_app(config_name='default'): from app.routes import projects # Phase 1/2 — Project management from app.routes import customers # Phase 5 — Customer management from app.routes import scheduled_reports # Phase 6 — Scheduled reports + from app.routes import support # Support chat + admin tickets app.register_blueprint(auth.bp) app.register_blueprint(dashboard.bp) @@ -146,6 +153,7 @@ def create_app(config_name='default'): app.register_blueprint(projects.bp) app.register_blueprint(customers.bp) app.register_blueprint(scheduled_reports.bp) + app.register_blueprint(support.bp) # ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ─────────────────── # The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed. diff --git a/app/models/support.py b/app/models/support.py new file mode 100644 index 0000000..f2b89c4 --- /dev/null +++ b/app/models/support.py @@ -0,0 +1,41 @@ +from app import db +from app.utils.time_utils import now_eastern + + +class SupportTicket(db.Model): + __tablename__ = 'support_tickets' + + id = db.Column(db.Integer, primary_key=True) + customer_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True) + facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id', ondelete='SET NULL'), nullable=True) + subject = db.Column(db.String(200), nullable=False) + body = db.Column(db.Text, nullable=False) + status = db.Column(db.String(20), nullable=False, default='open') # open / answered / closed + created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + + customer = db.relationship('User', foreign_keys=[customer_id], backref='support_tickets') + facility = db.relationship('Facility', foreign_keys=[facility_id], backref='support_tickets') + replies = db.relationship( + 'SupportTicketReply', backref='ticket', + cascade='all, delete-orphan', + order_by='SupportTicketReply.created_at', + lazy='dynamic', + ) + + def __repr__(self): + return f'' + + +class SupportTicketReply(db.Model): + __tablename__ = 'support_ticket_replies' + + id = db.Column(db.Integer, primary_key=True) + ticket_id = db.Column(db.Integer, db.ForeignKey('support_tickets.id', ondelete='CASCADE'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True) + body = db.Column(db.Text, nullable=False) + created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + + author = db.relationship('User', foreign_keys=[user_id]) + + def __repr__(self): + return f'' diff --git a/app/routes/support.py b/app/routes/support.py new file mode 100644 index 0000000..26955b1 --- /dev/null +++ b/app/routes/support.py @@ -0,0 +1,338 @@ +import os +import logging +import threading + +from flask import (Blueprint, render_template, redirect, url_for, + flash, request, current_app, jsonify, abort) +from flask_login import login_required, current_user +from flask_mail import Message + +from app import db, mail +from app.models.support import SupportTicket, SupportTicketReply +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 +from app.utils.time_utils import now_eastern + +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. + +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. +- 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.\ +""" + +# 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?'}, +] + + +# ── 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 [] + + groq_ready = bool(os.environ.get('GROQ_API_KEY')) + return render_template('support/chat.html', + faqs=FAQS, + facilities=facilities, + groq_ready=groq_ready) + + +# ── 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 + + 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 {} + history = data.get('messages', []) # list of {role, content} dicts + user_message = data.get('message', '').strip() + + if not user_message: + return jsonify({'error': 'Empty message'}), 400 + + try: + from groq import Groq + client = Groq(api_key=api_key) + + messages = [{'role': 'system', 'content': _SYSTEM_PROMPT}] + # Append prior conversation (cap at last 20 turns to control token usage) + for m in history[-20:]: + if m.get('role') in ('user', 'assistant') and m.get('content'): + messages.append({'role': m['role'], 'content': m['content']}) + messages.append({'role': 'user', 'content': 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() + return jsonify({'reply': reply}) + + except Exception as exc: + logger.error('SUPPORT | Groq error: %s', exc) + return jsonify({'reply': ( + "I ran into a problem reaching the AI assistant. " + "Please try again, or use **Submit to Support** to contact our team." + )}) + + +# ── 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.chat')) + + +def _notify_admins_new_ticket(ticket): + """Send email notification to all active admin users in a background thread.""" + admins = User.query.filter_by(role='admin', active=True).all() + if not admins: + return + + base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/') + ticket_url = f'{base_url}{url_for("support.admin_ticket_detail", ticket_id=ticket.id)}' + facility_label = ticket.facility.name if ticket.facility else 'N/A' + customer_label = ticket.customer.display_name if ticket.customer else 'Unknown' + + subject_line = f'[JQC Support] New ticket #{ticket.id}: {ticket.subject}' + html_body = f"""\ + + + +

New Support Ticket #{ticket.id}

+

From: {customer_label}

+

Facility: {facility_label}

+

Subject: {ticket.subject}

+
+

{ticket.body}

+
+

+ + View & Reply + +

+

JQC Support System — automated notification.

+ +""" + + def _send(): + try: + with current_app.app_context(): + for admin in admins: + msg = Message( + subject = subject_line, + recipients = [admin.email], + html = html_body, + sender = current_app.config.get('MAIL_DEFAULT_SENDER'), + ) + mail.send(msg) + except Exception as exc: + logger.error('SUPPORT | email notification failed: %s', exc) + + threading.Thread(target=_send, daemon=True).start() + + +# ── 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): + """Email the customer when an admin replies to their ticket.""" + if not ticket.customer or not ticket.customer.email: + return + + base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/') + chat_url = f'{base_url}{url_for("support.chat")}' + admin_name = reply.author.display_name if reply.author else 'Support Team' + + html_body = f"""\ + + + +

Reply to Your Support Request

+

Hi {ticket.customer.display_name},

+

{admin_name} replied to your support ticket + #{ticket.id}: {ticket.subject}:

+
{reply.body}
+

+ + View Support Chat + +

+

JQC Support System — automated notification.

+ +""" + + def _send(): + try: + with current_app.app_context(): + msg = Message( + subject = f'[JQC Support] Reply to #{ticket.id}: {ticket.subject}', + recipients = [ticket.customer.email], + html = html_body, + sender = current_app.config.get('MAIL_DEFAULT_SENDER'), + ) + mail.send(msg) + except Exception as exc: + logger.error('SUPPORT | customer reply email failed: %s', exc) + + threading.Thread(target=_send, daemon=True).start() diff --git a/app/templates/base.html b/app/templates/base.html index 5dff644..0763836 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -165,6 +165,25 @@ Customers {% endif %} + {% if current_user.role in ['admin', 'director'] %} + + {% endif %} + {% if current_user.role == 'customer' %} + + {% endif %} {% if current_user.role == 'admin' %}