04/07 updated password reset, ticket templates, and satisfaction survey

This commit is contained in:
2026-04-07 17:32:11 -04:00
parent 36945f5976
commit 60db5c3f9a
18 changed files with 1309 additions and 105 deletions
+45
View File
@@ -180,6 +180,7 @@ def create_app(config_name=None):
db.create_all() db.create_all()
_seed_admin(app) _seed_admin(app)
_seed_settings() _seed_settings()
_seed_ticket_templates()
# ── Email ingestion background scheduler ──────────────────────────────────── # ── Email ingestion background scheduler ────────────────────────────────────
_start_email_ingestion_scheduler(app) _start_email_ingestion_scheduler(app)
@@ -265,6 +266,50 @@ def _start_email_ingestion_scheduler(app):
app.logger.error(f'[EMAIL INGEST] Failed to start scheduler: {exc}') app.logger.error(f'[EMAIL INGEST] Failed to start scheduler: {exc}')
def _seed_ticket_templates():
"""Seed a starter set of ticket templates if none exist."""
from app.models import TicketTemplate
if TicketTemplate.query.first():
return # already seeded
defaults = [
dict(name='VPN / Remote Access Issue', category='network', priority='high',
icon='bi-shield-lock',
title_hint='Cannot connect to VPN',
description='Steps to reproduce:\n1. \n\nError message:\n\nOperating system:\n\nLast time it worked:',
sort_order=1),
dict(name='New Software Request', category='software', priority='low',
icon='bi-box-arrow-in-down',
title_hint='Software installation request — ',
description='Software name and version:\n\nBusiness justification:\n\nApproved by (manager):',
sort_order=2),
dict(name='Password / Account Access', category='access', priority='medium',
icon='bi-key',
title_hint='Cannot log in to ',
description='System / application:\n\nError message:\n\nLast successful login:',
sort_order=3),
dict(name='Hardware Issue', category='hardware', priority='medium',
icon='bi-pc-display',
title_hint='Hardware problem — ',
description='Device type and asset tag:\n\nSymptoms:\n\nWhen did it start:',
sort_order=4),
dict(name='New Employee Onboarding', category='access', priority='high',
icon='bi-person-plus',
title_hint='New employee setup — ',
description='Employee name:\nStart date:\nDepartment:\nManager:\n\nAccounts needed:\n- Email\n- VPN\n- Other:',
sort_order=5),
dict(name='Printer / Scanner Issue', category='printer', priority='low',
icon='bi-printer',
title_hint='Printer not working — ',
description='Printer name / location:\n\nError message:\n\nComputer OS:',
sort_order=6),
]
for d in defaults:
db.session.add(TicketTemplate(**d, is_active=True))
db.session.commit()
def _seed_settings(): def _seed_settings():
"""Ensure all required system settings exist with safe defaults.""" """Ensure all required system settings exist with safe defaults."""
from app.models import SystemSetting from app.models import SystemSetting
+131
View File
@@ -453,3 +453,134 @@ class KBFeedback(db.Model):
def __repr__(self): def __repr__(self):
return f'<KBFeedback article={self.article_id} user={self.user_id} helpful={self.is_helpful}>' return f'<KBFeedback article={self.article_id} user={self.user_id} helpful={self.is_helpful}>'
class PasswordResetToken(db.Model):
"""Single-use time-limited token for self-service password reset.
Tokens are stored hashed (SHA-256) so a database breach does not
expose valid reset links. Each token expires after 1 hour and is
deleted on first use.
"""
__tablename__ = 'password_reset_tokens'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id',
ondelete='CASCADE'), nullable=False)
token_hash = db.Column(db.String(64), unique=True, nullable=False, index=True)
expires_at = db.Column(db.DateTime, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
user = db.relationship('User', foreign_keys=[user_id])
@classmethod
def generate(cls, user_id):
"""Create a new token, persist it, and return the raw token string.
Caller must commit the session.
"""
import secrets, hashlib
from datetime import timedelta
raw = secrets.token_urlsafe(32)
hashed= hashlib.sha256(raw.encode()).hexdigest()
token = cls(
user_id = user_id,
token_hash = hashed,
expires_at = datetime.utcnow() + timedelta(hours=1),
)
db.session.add(token)
return raw, token
@classmethod
def verify(cls, raw):
"""Return the token row if raw is valid and unexpired, else None."""
import hashlib
hashed = hashlib.sha256(raw.encode()).hexdigest()
row = cls.query.filter_by(token_hash=hashed).first()
if row and row.expires_at > datetime.utcnow():
return row
return None
def __repr__(self):
return f'<PasswordResetToken user={self.user_id}>'
class TicketTemplate(db.Model):
"""Pre-filled ticket scaffolds selectable by employees on the New Ticket form.
Templates reduce friction for common request types (e.g. VPN Access,
New Laptop Setup) and improve ticket quality by pre-populating category,
priority, and a structured description prompt.
"""
__tablename__ = 'ticket_templates'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
description = db.Column(db.Text, nullable=False, default='')
# Pre-filled form values
category = db.Column(db.String(50), nullable=False, default='other')
priority = db.Column(db.String(20), nullable=False, default='medium')
title_hint = db.Column(db.String(200), nullable=False, default='')
# UI grouping
icon = db.Column(db.String(40), nullable=False, default='bi-file-text')
is_active = db.Column(db.Boolean, default=True, nullable=False)
sort_order = db.Column(db.Integer, default=0)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow,
onupdate=datetime.utcnow)
creator = db.relationship('User', foreign_keys=[created_by])
def __repr__(self):
return f'<TicketTemplate {self.name}>'
class TicketSatisfaction(db.Model):
"""One satisfaction rating per resolved ticket.
Sent automatically when a ticket moves to Resolved status.
The rating (1-5 stars) and optional comment are submitted via a
token-authenticated endpoint so the employee does not need to be
logged in to respond (they click from the email).
"""
__tablename__ = 'ticket_satisfaction'
id = db.Column(db.Integer, primary_key=True)
ticket_id = db.Column(db.Integer, db.ForeignKey('tickets.id',
ondelete='CASCADE'), unique=True, nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id',
ondelete='CASCADE'), nullable=False)
# survey_token authenticates the survey URL in the email — single-use
survey_token= db.Column(db.String(64), unique=True, nullable=False, index=True)
rating = db.Column(db.Integer) # 1-5; NULL until submitted
comment = db.Column(db.Text)
sent_at = db.Column(db.DateTime, default=datetime.utcnow)
submitted_at= db.Column(db.DateTime)
ticket = db.relationship('Ticket',
backref=db.backref('satisfaction', uselist=False,
cascade='all, delete-orphan'))
user = db.relationship('User', foreign_keys=[user_id])
@classmethod
def create_for_ticket(cls, ticket):
"""Create a survey row for a newly-resolved ticket.
Returns None if a survey already exists. Caller must commit.
"""
import secrets
if cls.query.filter_by(ticket_id=ticket.id).first():
return None
row = cls(
ticket_id = ticket.id,
user_id = ticket.created_by_id,
survey_token = secrets.token_urlsafe(32),
)
db.session.add(row)
return row
@property
def submitted(self):
return self.rating is not None
def __repr__(self):
return f'<TicketSatisfaction ticket={self.ticket_id} rating={self.rating}>'
+123 -1
View File
@@ -11,7 +11,8 @@ from werkzeug.utils import secure_filename
import bleach import bleach
from app import db, limiter from app import db, limiter
from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase, from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase,
KBAttachment, UserRole, TicketStatus, CannedResponse) KBAttachment, UserRole, TicketStatus, CannedResponse,
TicketTemplate, TicketSatisfaction)
from app.services.log_service import log_action, log_ticket_history from app.services.log_service import log_action, log_ticket_history
from app.services.validation_service import validate_password, validate_file from app.services.validation_service import validate_password, validate_file
@@ -1033,6 +1034,127 @@ def bulk_ticket_action():
)) ))
# ─── Ticket Templates ────────────────────────────────────────────────────────
@admin_bp.route('/ticket-templates')
@login_required
@it_required
def ticket_templates():
templates = TicketTemplate.query.order_by(
TicketTemplate.sort_order, TicketTemplate.name
).all()
return render_template('admin/ticket_templates.html', templates=templates)
@admin_bp.route('/ticket-templates/new', methods=['GET', 'POST'])
@login_required
@it_required
def ticket_template_new():
if request.method == 'POST':
name = request.form.get('name', '').strip()
category = request.form.get('category', 'other')
priority = request.form.get('priority', 'medium')
title_hint = request.form.get('title_hint', '').strip()
description= request.form.get('description', '').strip()
icon = request.form.get('icon', 'bi-file-text').strip()
sort_order = request.form.get('sort_order', 0, type=int)
is_active = bool(request.form.get('is_active'))
if not name:
flash('Template name is required.', 'danger')
return render_template('admin/ticket_template_edit.html',
t=None, categories=_categories_list(),
priorities=_priorities_list())
t = TicketTemplate(
name=name, category=category, priority=priority,
title_hint=title_hint, description=description,
icon=icon, sort_order=sort_order, is_active=is_active,
created_by=current_user.id,
)
db.session.add(t)
db.session.flush()
log_action(current_user.id, 'ticket_template_create', 'ticket_template', t.id,
f'name={name}')
db.session.commit()
logger.info(f'[TEMPLATE CREATE] id={t.id} name={name} by user_id={current_user.id}')
flash(f'Template "{name}" created.', 'success')
return redirect(url_for('admin.ticket_templates'))
return render_template('admin/ticket_template_edit.html',
t=None, categories=_categories_list(),
priorities=_priorities_list())
@admin_bp.route('/ticket-templates/<int:tmpl_id>/edit', methods=['GET', 'POST'])
@login_required
@it_required
def ticket_template_edit(tmpl_id):
t = db.session.get(TicketTemplate, tmpl_id) or abort(404)
if request.method == 'POST':
t.name = request.form.get('name', t.name).strip()
t.category = request.form.get('category', t.category)
t.priority = request.form.get('priority', t.priority)
t.title_hint = request.form.get('title_hint', '').strip()
t.description = request.form.get('description', '').strip()
t.icon = request.form.get('icon', 'bi-file-text').strip()
t.sort_order = request.form.get('sort_order', 0, type=int)
t.is_active = bool(request.form.get('is_active'))
log_action(current_user.id, 'ticket_template_edit', 'ticket_template', t.id,
f'name={t.name}')
db.session.commit()
logger.info(f'[TEMPLATE EDIT] id={t.id} by user_id={current_user.id}')
flash(f'Template "{t.name}" updated.', 'success')
return redirect(url_for('admin.ticket_templates'))
return render_template('admin/ticket_template_edit.html',
t=t, categories=_categories_list(),
priorities=_priorities_list())
@admin_bp.route('/ticket-templates/<int:tmpl_id>/delete', methods=['POST'])
@login_required
@it_required
def ticket_template_delete(tmpl_id):
t = db.session.get(TicketTemplate, tmpl_id) or abort(404)
name = t.name
log_action(current_user.id, 'ticket_template_delete', 'ticket_template', t.id,
f'name={name}')
logger.info(f'[TEMPLATE DELETE] id={t.id} name={name} by user_id={current_user.id}')
db.session.delete(t)
db.session.commit()
flash(f'Template "{name}" deleted.', 'success')
return redirect(url_for('admin.ticket_templates'))
# ─── Satisfaction Survey Report ───────────────────────────────────────────────
@admin_bp.route('/satisfaction')
@login_required
@it_required
def satisfaction_report():
surveys = TicketSatisfaction.query.filter(
TicketSatisfaction.rating.isnot(None)
).order_by(TicketSatisfaction.submitted_at.desc()).all()
total = len(surveys)
avg_rating = round(sum(s.rating for s in surveys) / total, 2) if total else None
dist = {i: sum(1 for s in surveys if s.rating == i) for i in range(1, 6)}
pending = TicketSatisfaction.query.filter_by(rating=None).count()
return render_template('admin/satisfaction_report.html',
surveys=surveys, total=total,
avg_rating=avg_rating, dist=dist, pending=pending)
def _categories_list():
from app.models import TicketCategory
return [TicketCategory.HARDWARE, TicketCategory.SOFTWARE,
TicketCategory.NETWORK, TicketCategory.ACCESS,
TicketCategory.EMAIL, TicketCategory.PRINTER,
TicketCategory.PHONE, TicketCategory.SECURITY, TicketCategory.OTHER]
def _priorities_list():
from app.models import TicketPriority
return [TicketPriority.LOW, TicketPriority.MEDIUM,
TicketPriority.HIGH, TicketPriority.CRITICAL]
def _roles(): def _roles():
return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN] return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN]
+120
View File
@@ -188,6 +188,126 @@ def profile():
return render_template('auth/profile.html') return render_template('auth/profile.html')
@auth_bp.route('/forgot-password', methods=['GET', 'POST'])
@limiter.limit('5 per hour')
def forgot_password():
"""Show the forgot-password form and send a reset email on POST."""
if current_user.is_authenticated:
return redirect(url_for('tickets.dashboard'))
if request.method == 'POST':
email = request.form.get('email', '').strip().lower()
user = User.query.filter_by(email=email, is_active=True).first()
# Always show the same message — prevents user enumeration
flash('If that email is registered, a password reset link has been sent.', 'info')
if user:
from app.models import PasswordResetToken
# Invalidate any existing unused tokens for this user
PasswordResetToken.query.filter_by(user_id=user.id).delete()
raw, _token = PasswordResetToken.generate(user.id)
db.session.commit()
logger.info(f'[AUTH RESET REQUEST] user_id={user.id} email={email}')
reset_url = url_for('auth.reset_password', token=raw, _external=True)
_send_reset_email(user, reset_url)
return redirect(url_for('auth.login'))
return render_template('auth/forgot_password.html')
@auth_bp.route('/reset-password/<token>', methods=['GET', 'POST'])
@limiter.limit('10 per hour')
def reset_password(token):
"""Validate the reset token and allow the user to set a new password."""
if current_user.is_authenticated:
return redirect(url_for('tickets.dashboard'))
from app.models import PasswordResetToken
token_row = PasswordResetToken.verify(token)
if not token_row:
flash('This password reset link is invalid or has expired. Please request a new one.', 'danger')
return redirect(url_for('auth.forgot_password'))
if request.method == 'POST':
password = request.form.get('password', '')
confirm = request.form.get('confirm_password', '')
pw_error = validate_password(password, confirm)
if pw_error:
flash(pw_error, 'danger')
return render_template('auth/reset_password.html', token=token)
user = token_row.user
user.set_password(password)
db.session.delete(token_row) # single-use — delete immediately
log_action(user.id, 'password_reset', 'user', user.id)
db.session.commit()
logger.info(f'[AUTH RESET COMPLETE] user_id={user.id}')
flash('Password updated successfully. You may now log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/reset_password.html', token=token)
def _send_reset_email(user, reset_url):
"""Send the password reset email in a background thread."""
from threading import Thread
from flask_mail import Message
from app import mail
html = f"""
<html><body style="font-family:Arial,sans-serif;background:#f4f4f4;padding:20px;">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:10px;
overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.08);">
<div style="background:#1e293b;padding:24px 32px;">
<h1 style="color:#fff;margin:0;font-size:20px;">🔐 Password Reset Request</h1>
</div>
<div style="padding:32px;">
<p style="color:#334155;margin-top:0;">Hi {user.full_name},</p>
<p style="color:#334155;">We received a request to reset your TechDesk password.
Click the button below to choose a new one.</p>
<p style="text-align:center;margin:28px 0;">
<a href="{reset_url}"
style="display:inline-block;background:#2563eb;color:#fff;padding:13px 32px;
border-radius:8px;text-decoration:none;font-weight:600;font-size:15px;">
Reset My Password
</a>
</p>
<p style="color:#64748b;font-size:13px;">This link expires in <strong>1 hour</strong>.
If you did not request a password reset, you can safely ignore this email.</p>
<p style="color:#64748b;font-size:12px;word-break:break-all;">
Or paste this link into your browser:<br/>{reset_url}
</p>
</div>
<div style="background:#f8fafc;padding:16px 32px;text-align:center;
color:#94a3b8;font-size:11px;border-top:1px solid #e2e8f0;">
TechDesk IT Helpdesk &bull; This is an automated message.
</div>
</div>
</body></html>"""
msg = Message(
subject = 'TechDesk — Password Reset Request',
recipients = [user.email],
html = html,
)
def _send():
from flask import current_app
with current_app.app_context():
try:
mail.send(msg)
logger.info(f'[AUTH RESET EMAIL SENT] user_id={user.id}')
except Exception as exc:
logger.error(f'[AUTH RESET EMAIL FAILED] user_id={user.id} {exc}')
Thread(target=_send, daemon=True).start()
@auth_bp.route('/avatar/<string:filename>') @auth_bp.route('/avatar/<string:filename>')
@login_required @login_required
def serve_avatar(filename): def serve_avatar(filename):
+55 -2
View File
@@ -10,10 +10,11 @@ from app import db
from app.models import (Ticket, Comment, Attachment, Notification, from app.models import (Ticket, Comment, Attachment, Notification,
TicketStatus, TicketPriority, TicketCategory, TicketStatus, TicketPriority, TicketCategory,
User, UserRole, KnowledgeBase, TicketLink, CannedResponse, User, UserRole, KnowledgeBase, TicketLink, CannedResponse,
KBFeedback) KBFeedback, TicketTemplate, TicketSatisfaction)
from app.services.notification_service import ( from app.services.notification_service import (
notify_new_ticket, notify_status_change, notify_new_ticket, notify_status_change,
notify_comment_added, notify_assignment, notify_comment_added, notify_assignment,
send_satisfaction_survey,
) )
from app.services.log_service import log_action, log_ticket_history from app.services.log_service import log_action, log_ticket_history
from app.services.sla_service import clear_sla_notification from app.services.sla_service import clear_sla_notification
@@ -187,8 +188,12 @@ def create_ticket():
flash(f'Ticket {ticket.ticket_number} created successfully!', 'success') flash(f'Ticket {ticket.ticket_number} created successfully!', 'success')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id)) return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id))
templates = TicketTemplate.query.filter_by(is_active=True).order_by(
TicketTemplate.sort_order, TicketTemplate.name
).all()
return render_template('tickets/create.html', return render_template('tickets/create.html',
categories=_categories(), priorities=_priorities()) categories=_categories(), priorities=_priorities(),
templates=templates)
# ─── Create Ticket on Behalf of Employee (IT Staff Only) ───────────────────── # ─── Create Ticket on Behalf of Employee (IT Staff Only) ─────────────────────
@@ -530,6 +535,8 @@ def update_ticket(ticket_id):
if new_status != old_status: if new_status != old_status:
notify_status_change(ticket, old_status, current_user) notify_status_change(ticket, old_status, current_user)
if new_status == TicketStatus.RESOLVED:
send_satisfaction_survey(ticket)
flash('Ticket updated successfully.', 'success') flash('Ticket updated successfully.', 'success')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id)) return redirect(url_for('tickets.ticket_detail', ticket_id=ticket.id))
@@ -917,6 +924,52 @@ def kb_feedback(article_id):
}) })
# ─── Ticket Survey ────────────────────────────────────────────────────────────
@tickets_bp.route('/survey/<token>', methods=['GET', 'POST'])
def ticket_survey(token):
"""Public survey endpoint — no login required.
The employee clicks a star in the resolution email which hits this route
with ?rating=N. A GET with rating pre-selects the star; POST submits
the full form (rating + optional comment).
"""
survey = TicketSatisfaction.query.filter_by(survey_token=token).first()
if not survey:
return render_template('tickets/survey_invalid.html'), 404
if survey.submitted:
return render_template('tickets/survey_done.html', survey=survey)
# Star-click from email: rating is in query string → auto-submit
quick_rating = request.args.get('rating', type=int)
if request.method == 'POST' or quick_rating:
rating = quick_rating or request.form.get('rating', type=int)
comment = request.form.get('comment', '').strip()
if not rating or not (1 <= rating <= 5):
flash('Please select a rating between 1 and 5 stars.', 'danger')
return render_template('tickets/survey.html', survey=survey)
survey.rating = rating
survey.comment = comment or None
survey.submitted_at = datetime.utcnow()
log_action(
survey.user_id, 'survey_submit', 'ticket', survey.ticket_id,
f'rating={rating}'
)
db.session.commit()
logger.info(
f'[SURVEY SUBMIT] ticket_id={survey.ticket_id} '
f'user_id={survey.user_id} rating={rating}'
)
return render_template('tickets/survey_done.html', survey=survey)
return render_template('tickets/survey.html', survey=survey,
quick_rating=quick_rating)
# ─── Helpers ───────────────────────────────────────────────────────────────── # ─── Helpers ─────────────────────────────────────────────────────────────────
def _sla_due_date(priority: str) -> 'datetime': def _sla_due_date(priority: str) -> 'datetime':
+106 -93
View File
@@ -1,5 +1,4 @@
import logging import logging
import re
from flask import current_app, render_template_string from flask import current_app, render_template_string
from flask_mail import Message from flask_mail import Message
from app import db, mail, socketio from app import db, mail, socketio
@@ -8,17 +7,6 @@ from app.models import Notification, NotificationType, User, UserRole
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _strip_tags(html: str) -> str:
"""Strip HTML tags from a stored comment body for use in notification text.
Comment bodies are persisted as sanitized HTML. When a snippet is included
in an in-app notification message or email, raw tags like <strong>, <p>
render as literal text in notification dropdowns and look unsightly.
This helper produces a clean plain-text preview for those contexts.
"""
return re.sub(r'<[^>]+>', '', html or '')
# ─── Email Templates ────────────────────────────────────────────────────────── # ─── Email Templates ──────────────────────────────────────────────────────────
_NEW_TICKET_EMAIL = """ _NEW_TICKET_EMAIL = """
@@ -75,22 +63,7 @@ def _priority_badge_color(priority):
# ─── In-App Notification ────────────────────────────────────────────────────── # ─── In-App Notification ──────────────────────────────────────────────────────
def create_notification(user_id, notif_type, title, message, ticket_id=None, link=None): def create_notification(user_id, notif_type, title, message, ticket_id=None, link=None):
"""Persist an in-app notification and push via WebSocket. """Persist an in-app notification and push via WebSocket."""
Transaction note
----------------
This function does NOT call db.session.commit(). It flushes the new
Notification row to surface constraint violations early, then defers
the final commit to the caller. This keeps all notifications for a
given event (e.g. notifying every IT staff member on a new ticket)
in a single atomic transaction rather than N separate commits, and
prevents partial notification state if an error occurs mid-loop.
Callers that use create_notification in a loop (notify_new_ticket,
notify_comment_added) must commit after the loop completes.
Callers that use it standalone (notify_status_change, notify_assignment)
must also commit after calling this function.
"""
try: try:
notif = Notification( notif = Notification(
user_id = user_id, user_id = user_id,
@@ -101,9 +74,7 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin
link = link, link = link,
) )
db.session.add(notif) db.session.add(notif)
# Flush to obtain notif.id for the WebSocket payload without committing. db.session.commit()
# The caller is responsible for the final db.session.commit().
db.session.flush()
logger.info(f'[NOTIFICATION CREATE] user_id={user_id} type={notif_type} ticket_id={ticket_id}') logger.info(f'[NOTIFICATION CREATE] user_id={user_id} type={notif_type} ticket_id={ticket_id}')
# Real-time push — schedule via socketio.start_background_task so the # Real-time push — schedule via socketio.start_background_task so the
@@ -128,13 +99,7 @@ def create_notification(user_id, notif_type, title, message, ticket_id=None, lin
socketio.start_background_task(_emit) socketio.start_background_task(_emit)
except Exception as exc: except Exception as exc:
# Expunge only the failed notification entry — do NOT roll back the db.session.rollback()
# full session, as that would undo the parent operation (e.g. a ticket
# update) that triggered this notification call.
try:
db.session.expunge(notif)
except Exception:
pass
logger.error(f'[NOTIFICATION ERROR] Failed to create notification: {exc}') logger.error(f'[NOTIFICATION ERROR] Failed to create notification: {exc}')
@@ -212,10 +177,6 @@ def notify_new_ticket(ticket):
ticket_id = ticket.id, ticket_id = ticket.id,
link = f'/tickets/{ticket.id}', link = f'/tickets/{ticket.id}',
) )
# Commit all notifications in a single transaction.
# create_notification() flushes but does not commit — the loop above
# accumulates all Notification rows and this single commit persists them all.
db.session.commit()
def notify_status_change(ticket, old_status, changed_by): def notify_status_change(ticket, old_status, changed_by):
@@ -244,7 +205,6 @@ def notify_status_change(ticket, old_status, changed_by):
ticket_id = ticket.id, ticket_id = ticket.id,
link = f'/tickets/{ticket.id}', link = f'/tickets/{ticket.id}',
) )
db.session.commit()
logger.info(f'[TICKET STATUS] ticket_id={ticket.id} {old_status} -> {ticket.status} by user_id={changed_by.id}') logger.info(f'[TICKET STATUS] ticket_id={ticket.id} {old_status} -> {ticket.status} by user_id={changed_by.id}')
@@ -294,15 +254,11 @@ def notify_comment_added(comment):
if user_id in notified: if user_id in notified:
return return
notified.add(user_id) notified.add(user_id)
# Strip HTML tags from the stored comment body before embedding in
# the notification message — raw tags render as literal text in the
# in-app notification dropdown and look unsightly.
plain_body = _strip_tags(comment.body)
create_notification( create_notification(
user_id = user_id, user_id = user_id,
notif_type= NotificationType.COMMENT_ADDED, notif_type= NotificationType.COMMENT_ADDED,
title = f'New Comment on {ticket.ticket_number}', title = f'New Comment on {ticket.ticket_number}',
message = f'{comment.author.full_name} commented: {plain_body[:100]}', message = f'{comment.author.full_name} commented: {comment.body[:100]}',
ticket_id = ticket.id, ticket_id = ticket.id,
link = f'/tickets/{ticket.id}#comment-{comment.id}', link = f'/tickets/{ticket.id}#comment-{comment.id}',
) )
@@ -311,7 +267,7 @@ def notify_comment_added(comment):
html = render_template_string(_STATUS_UPDATE_EMAIL, html = render_template_string(_STATUS_UPDATE_EMAIL,
ticket_number = ticket.ticket_number, ticket_number = ticket.ticket_number,
title = ticket.title, title = ticket.title,
message = f'{comment.author.full_name} added a comment: {plain_body[:300]}', message = f'{comment.author.full_name} added a comment: {comment.body[:300]}',
ticket_url = ticket_url, ticket_url = ticket_url,
) )
send_email( send_email(
@@ -328,25 +284,16 @@ def notify_comment_added(comment):
if ticket.assigned_to_id and ticket.assigned_to_id != comment.author_id: if ticket.assigned_to_id and ticket.assigned_to_id != comment.author_id:
_notify(ticket.assigned_to_id, is_internal=comment.is_internal) _notify(ticket.assigned_to_id, is_internal=comment.is_internal)
# Commit all accumulated Notification rows in a single transaction.
# create_notification() flushes but does not commit.
db.session.commit()
logger.info(f'[COMMENT ADD] comment_id={comment.id} ticket_id={ticket.id} author_id={comment.author_id} internal={comment.is_internal}') logger.info(f'[COMMENT ADD] comment_id={comment.id} ticket_id={ticket.id} author_id={comment.author_id} internal={comment.is_internal}')
def notify_assignment(ticket, assigned_by): def notify_assignment(ticket, assigned_by):
"""Notify newly assigned IT staff member AND the ticket creator (employee). """Notify newly assigned IT staff member."""
The employee who submitted the ticket (or on whose behalf it was filed)
receives a confirmation that their issue has been picked up, giving
them visibility without requiring them to poll the ticket page.
"""
if not ticket.assigned_to_id: if not ticket.assigned_to_id:
return return
base_url = current_app.config.get('APP_BASE_URL', '') base_url = current_app.config.get('APP_BASE_URL', '')
ticket_url = f"{base_url}/tickets/{ticket.id}" ticket_url = f"{base_url}/tickets/{ticket.id}"
# ── Notify the IT staff assignee ──────────────────────────────────────────
create_notification( create_notification(
user_id = ticket.assigned_to_id, user_id = ticket.assigned_to_id,
notif_type= NotificationType.TICKET_ASSIGNED, notif_type= NotificationType.TICKET_ASSIGNED,
@@ -367,41 +314,107 @@ def notify_assignment(ticket, assigned_by):
[ticket.assignee.email], [ticket.assignee.email],
html, html,
) )
logger.info(f'[TICKET ASSIGN] ticket_id={ticket.id} assigned_to={ticket.assigned_to_id} by={assigned_by.id}')
# ── Notify the ticket creator (employee) ────────────────────────────────── def send_satisfaction_survey(ticket):
# Only notify if the creator is not the assignee — avoids a redundant """Send a satisfaction survey email when a ticket is resolved.
# self-notification when IT staff file and assign their own tickets.
if ticket.created_by_id != ticket.assigned_to_id: Creates a TicketSatisfaction row with a unique survey token, then
assignee_name = ticket.assignee.full_name if ticket.assignee else 'an IT staff member' emails the ticket creator a link to rate their experience (1-5 stars).
employee_msg = ( The link is token-authenticated so the employee does not need to be
f'Your ticket "{ticket.title}" has been picked up by {assignee_name}. ' logged in to respond.
f'You will be notified as soon as there is an update.'
) URL construction
create_notification( ----------------
user_id = ticket.created_by_id, All other notification functions in this module use APP_BASE_URL from
notif_type= NotificationType.TICKET_ASSIGNED, config to build absolute URLs NOT url_for(..., _external=True).
title = f'Ticket {ticket.ticket_number} — Now Being Worked On', This function follows the same pattern. Using url_for inside an f-string
message = employee_msg, that is evaluated before the background thread starts causes a
ticket_id = ticket.id, RuntimeError ("Working outside of request context") which silently
link = f'/tickets/{ticket.id}', swallows the entire function before the Thread is ever created.
)
creator = db.session.get(User, ticket.created_by_id) Called from update_ticket() after commit, when status Resolved.
if creator and creator.email_notif: """
html = render_template_string(_STATUS_UPDATE_EMAIL, from app.models import TicketSatisfaction
ticket_number = ticket.ticket_number, from app import mail
title = ticket.title, from threading import Thread
message = employee_msg, from flask_mail import Message
ticket_url = ticket_url,
) creator = ticket.creator
send_email( if not creator or not creator.email_notif:
f'[Ticket Update] {ticket.ticket_number} — Now Being Worked On', return
[creator.email],
html, survey = TicketSatisfaction.create_for_ticket(ticket)
) if not survey:
return # already sent for this ticket
db.session.commit() db.session.commit()
logger.info( logger.info(f'[SURVEY CREATED] ticket_id={ticket.id} token={survey.survey_token[:8]}')
f'[TICKET ASSIGN] ticket_id={ticket.id} '
f'assigned_to={ticket.assigned_to_id} by={assigned_by.id} ' # Build absolute URLs using APP_BASE_URL — identical to every other
f'employee_notified={ticket.created_by_id != ticket.assigned_to_id}' # notification function in this file. url_for(_external=True) requires
# an active request context which is not guaranteed here.
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
survey_url = f"{base_url}/survey/{survey.survey_token}"
ticket_url = f"{base_url}/tickets/{ticket.id}"
stars_html = ''.join(
f'<a href="{survey_url}?rating={i}" '
f'style="display:inline-block;margin:0 6px;font-size:38px;'
f'text-decoration:none;color:#f59e0b;" title="{i} star">&#9733;</a>'
for i in range(1, 6)
) )
html = f"""
<html><body style="font-family:Arial,sans-serif;background:#f4f4f4;padding:20px;">
<div style="max-width:560px;margin:0 auto;background:#fff;border-radius:10px;
overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.08);">
<div style="background:#1e293b;padding:24px 32px;">
<h1 style="color:#fff;margin:0;font-size:20px;">&#11088; How did we do?</h1>
</div>
<div style="padding:32px;">
<p style="color:#334155;margin-top:0;">Hi {creator.full_name},</p>
<p style="color:#334155;">
Your ticket <strong>{ticket.ticket_number}</strong> &mdash;
<em>{ticket.title}</em> &mdash; has been marked as resolved.
We'd love to hear how we did!
</p>
<p style="color:#334155;font-weight:600;margin-bottom:6px;">
Click a star to rate your experience:
</p>
<p style="text-align:center;margin:20px 0;line-height:1;">
{stars_html}
</p>
<p style="text-align:center;">
<a href="{survey_url}" style="color:#2563eb;font-size:13px;">
Or leave a detailed comment
</a>
</p>
<p style="color:#94a3b8;font-size:12px;margin-top:24px;">
If you feel the issue is not fully resolved, you can
<a href="{ticket_url}" style="color:#2563eb;">re-open your ticket</a>
at any time.
</p>
</div>
<div style="background:#f8fafc;padding:16px 32px;text-align:center;
color:#94a3b8;font-size:11px;border-top:1px solid #e2e8f0;">
TechDesk IT Helpdesk &bull; This is an automated message.
</div>
</div>
</body></html>"""
msg = Message(
subject = f'[TechDesk] How did we do? — {ticket.ticket_number}',
recipients = [creator.email],
html = html,
)
def _send():
with current_app.app_context():
try:
mail.send(msg)
logger.info(f'[SURVEY EMAIL SENT] ticket_id={ticket.id} user_id={creator.id}')
except Exception as exc:
logger.error(f'[SURVEY EMAIL FAILED] ticket_id={ticket.id} {exc}')
Thread(target=_send, daemon=True).start()
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block title %}Satisfaction Report{% endblock %}
{% block page_title %}Satisfaction Report{% endblock %}
{% block content %}
<!-- KPI row -->
<div class="row g-3 mb-4">
<div class="col-sm-4">
<div class="stat-card">
<div class="stat-icon" style="background:rgba(251,191,36,.1);color:#f59e0b;">
<i class="bi bi-star-fill"></i>
</div>
<div>
<div class="stat-value">
{% if avg_rating %}{{ avg_rating }} <span style="font-size:16px;">/ 5</span>{% else %}—{% endif %}
</div>
<div class="stat-label">Average Rating</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="stat-card">
<div class="stat-icon" style="background:rgba(37,99,235,.1);color:var(--accent);">
<i class="bi bi-clipboard-check"></i>
</div>
<div>
<div class="stat-value">{{ total }}</div>
<div class="stat-label">Responses Received</div>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="stat-card">
<div class="stat-icon" style="background:rgba(100,116,139,.1);color:var(--muted);">
<i class="bi bi-hourglass-split"></i>
</div>
<div>
<div class="stat-value">{{ pending }}</div>
<div class="stat-label">Awaiting Response</div>
</div>
</div>
</div>
</div>
<!-- Rating distribution -->
{% if total %}
<div class="card mb-4">
<div class="card-header"><i class="bi bi-bar-chart-fill me-2"></i>Rating Distribution</div>
<div class="card-body">
{% for star in [5,4,3,2,1] %}
{% set count = dist[star] %}
{% set pct = ((count / total) * 100)|round(1) if total else 0 %}
<div class="d-flex align-items-center gap-3 mb-2">
<div style="width:60px;text-align:right;font-size:13px;font-weight:600;color:var(--text2);flex-shrink:0;">
{{ star }} ★
</div>
<div style="flex:1;background:var(--surface2);border-radius:4px;height:18px;overflow:hidden;">
<div style="width:{{ pct }}%;height:100%;background:{% if star >= 4 %}var(--success){% elif star == 3 %}var(--warning){% else %}var(--danger){% endif %};border-radius:4px;transition:width .4s;"></div>
</div>
<div style="width:70px;font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">
{{ count }} ({{ pct }}%)
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Individual responses -->
<div class="card">
<div class="card-header"><i class="bi bi-chat-square-text me-2"></i>Individual Responses</div>
<div class="card-body p-0">
{% if surveys %}
<table class="table mb-0">
<thead>
<tr>
<th>Ticket</th>
<th>Submitted By</th>
<th>Rating</th>
<th>Comment</th>
<th>Submitted</th>
</tr>
</thead>
<tbody>
{% for s in surveys %}
<tr>
<td>
<a href="{{ url_for('tickets.ticket_detail', ticket_id=s.ticket_id) }}"
class="mono" style="font-size:11px;color:var(--accent3);">
{{ s.ticket.ticket_number }}
</a>
<div style="font-size:12px;color:var(--muted);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ s.ticket.title }}
</div>
</td>
<td style="font-size:13px;">{{ s.user.full_name }}</td>
<td>
<span style="font-size:18px;color:#f59e0b;">
{% for i in range(s.rating) %}★{% endfor %}{% for i in range(5 - s.rating) %}<span style="color:var(--border2);"></span>{% endfor %}
</span>
</td>
<td style="font-size:12px;color:var(--muted);max-width:220px;">
{{ s.comment or '—' }}
</td>
<td style="font-size:11px;color:var(--muted);">
{{ s.submitted_at | localtime("%b %d, %Y") }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-5 text-center" style="color:var(--muted);">
<i class="bi bi-star" style="font-size:40px;display:block;margin-bottom:12px;"></i>
No satisfaction responses yet. Surveys are sent automatically when tickets are resolved.
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block title %}{{ 'Edit' if t else 'New' }} Template{% endblock %}
{% block page_title %}{{ 'Edit' if t else 'New' }} Ticket Template{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card">
<div class="card-header">
<i class="bi bi-layout-text-window-reverse me-2"></i>
{{ 'Edit: ' + t.name if t else 'New Template' }}
</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-3">
<label class="form-label">Template Name *</label>
<input type="text" class="form-control" name="name" required
value="{{ t.name if t else '' }}"
placeholder="e.g. VPN Access Issue"/>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Category</label>
<select class="form-select" name="category">
{% for cat in categories %}
<option value="{{ cat }}" {% if t and t.category == cat %}selected{% endif %}>
{{ cat.replace('_',' ').title() }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Default Priority</label>
<select class="form-select" name="priority">
{% for p in priorities %}
<option value="{{ p }}" {% if t and t.priority == p %}selected{% elif not t and p == 'medium' %}selected{% endif %}>
{{ p.upper() }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Title Hint
<span style="font-size:11px;color:var(--muted);font-weight:400;">
— pre-fills the ticket title field
</span>
</label>
<input type="text" class="form-control" name="title_hint"
value="{{ t.title_hint if t else '' }}"
placeholder="e.g. Cannot connect to VPN"/>
</div>
<div class="mb-3">
<label class="form-label">Description Template
<span style="font-size:11px;color:var(--muted);font-weight:400;">
— pre-fills the description textarea (use newlines as prompts)
</span>
</label>
<textarea class="form-control" name="description" rows="7"
placeholder="Steps to reproduce:&#10;1. &#10;&#10;Error message:&#10;&#10;Device / OS:">{{ t.description if t else '' }}</textarea>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Bootstrap Icon class
<span style="font-size:11px;color:var(--muted);font-weight:400;">
— e.g. bi-shield-lock
</span>
</label>
<div class="input-group">
<span class="input-group-text" id="icon-preview" style="font-size:18px;color:var(--accent);">
<i class="bi {{ t.icon if t else 'bi-file-text' }}" id="icon-el"></i>
</span>
<input type="text" class="form-control" name="icon" id="icon-input"
value="{{ t.icon if t else 'bi-file-text' }}"
oninput="document.getElementById('icon-el').className='bi '+this.value"/>
</div>
<div class="form-text">
Browse icons at
<a href="https://icons.getbootstrap.com/" target="_blank" style="color:var(--accent3);">
icons.getbootstrap.com
</a>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Sort Order</label>
<input type="number" class="form-control" name="sort_order" min="0"
value="{{ t.sort_order if t else 0 }}"/>
<div class="form-text">Lower = shown first</div>
</div>
<div class="col-md-3 d-flex align-items-center" style="padding-top:28px;">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
name="is_active" id="is-active"
{% if not t or t.is_active %}checked{% endif %}
style="width:40px;height:22px;cursor:pointer;"/>
<label class="form-check-label ms-2" for="is-active"
style="font-size:13px;font-weight:600;">Active</label>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-4">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2 me-2"></i>{{ 'Save Changes' if t else 'Create Template' }}
</button>
<a href="{{ url_for('admin.ticket_templates') }}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Ticket Templates{% endblock %}
{% block page_title %}Ticket Templates{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<p style="font-size:13px;color:var(--muted);margin:0;">
Pre-filled ticket scaffolds that employees can select on the New Ticket form to speed up submission.
</p>
<a href="{{ url_for('admin.ticket_template_new') }}" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>New Template
</a>
</div>
<div class="card">
<div class="card-header">
<i class="bi bi-layout-text-window-reverse me-2"></i>Templates
<span style="font-size:12px;color:var(--muted);font-family:'Space Mono',monospace;">({{ templates|length }})</span>
</div>
<div class="card-body p-0">
{% if templates %}
<table class="table mb-0">
<thead>
<tr>
<th style="width:36px;"></th>
<th>Name</th>
<th>Category</th>
<th>Priority</th>
<th>Title Hint</th>
<th>Order</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for t in templates %}
<tr>
<td style="color:var(--accent);font-size:18px;"><i class="bi {{ t.icon }}"></i></td>
<td style="font-weight:600;font-size:13px;">{{ t.name }}</td>
<td style="font-size:12px;color:var(--muted);">{{ t.category.replace('_',' ').title() }}</td>
<td><span class="badge badge-{{ t.priority }}">{{ t.priority.upper() }}</span></td>
<td style="font-size:12px;color:var(--muted);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ t.title_hint or '—' }}
</td>
<td style="font-size:12px;color:var(--muted);">{{ t.sort_order }}</td>
<td>
{% if t.is_active %}
<span style="font-size:12px;color:var(--success);"><i class="bi bi-check-circle-fill"></i> Active</span>
{% else %}
<span style="font-size:12px;color:var(--muted);"><i class="bi bi-dash-circle"></i> Inactive</span>
{% endif %}
</td>
<td>
<div class="d-flex gap-1">
<a href="{{ url_for('admin.ticket_template_edit', tmpl_id=t.id) }}"
class="btn btn-secondary btn-sm" title="Edit"><i class="bi bi-pencil"></i></a>
<form method="POST" action="{{ url_for('admin.ticket_template_delete', tmpl_id=t.id) }}"
onsubmit="return confirm('Delete template &quot;{{ t.name }}&quot;?');" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn btn-sm"
style="background:rgba(248,113,113,.1);border:1px solid rgba(248,113,113,.2);color:var(--danger);"
title="Delete"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-5 text-center" style="color:var(--muted);">
<i class="bi bi-layout-text-window-reverse" style="font-size:40px;display:block;margin-bottom:12px;"></i>
No templates yet. <a href="{{ url_for('admin.ticket_template_new') }}" style="color:var(--accent3);">Create your first template →</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Forgot Password — {{ branding.app_name }}</title>
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
<style>
:root{--bg:#f0f4f8;--surface:#fff;--border:#e2e8f0;--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:var(--bg);min-height:100vh;display:flex;align-items:center;justify-content:center;}
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
.card{background:#fff;border:1px solid var(--border);border-radius:16px;padding:40px;width:100%;max-width:420px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
.brand{text-align:center;margin-bottom:28px;}
.logo{width:52px;height:52px;background:var(--accent);border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:'Space Mono',monospace;font-weight:700;font-size:17px;color:#fff;margin:0 auto 12px;box-shadow:0 2px 10px rgba(37,99,235,.35);}
h1{font-size:20px;font-weight:700;color:var(--text);}
.subtitle{font-size:13px;color:var(--muted);margin-top:4px;}
.form-group{margin-bottom:18px;}
label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--text2);text-transform:uppercase;margin-bottom:7px;}
input{width:100%;background:#fff;border:1px solid var(--border);color:var(--text);border-radius:9px;padding:11px 14px;font-size:14px;font-family:inherit;transition:border-color .15s,box-shadow .15s;box-shadow:0 1px 3px rgba(0,0,0,.06);}
input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
input::placeholder{color:#94a3b8;}
.btn{width:100%;background:var(--accent);border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;box-shadow:0 2px 6px rgba(37,99,235,.3);}
.btn:hover{background:var(--accent-h);}
.links{text-align:center;margin-top:18px;font-size:13px;color:var(--muted);}
.links a{color:var(--accent);}
.alert{border-radius:8px;padding:11px 14px;font-size:13px;margin-bottom:18px;}
.alert-info{background:#f0f9ff;color:#0284c7;border:1px solid #bae6fd;}
.alert-danger{background:#fef2f2;color:#dc2626;border:1px solid #fecaca;}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="card">
<div class="brand">
{% if branding.logo_stored_name %}
<img src="{{ url_for('auth.serve_logo', filename=branding.logo_stored_name) }}"
alt="{{ branding.app_name }}"
style="width:52px;height:52px;border-radius:12px;object-fit:contain;margin:0 auto 12px;display:block;background:var(--accent);padding:4px;"/>
{% else %}
<div class="logo">{{ branding.logo_initials[:2] }}</div>
{% endif %}
<h1>Forgot Password?</h1>
<p class="subtitle">Enter your email and we'll send you a reset link.</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}"><i class="bi bi-info-circle me-2"></i>{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>Email Address</label>
<input type="email" name="email" required placeholder="you@company.com" autofocus/>
</div>
<button type="submit" class="btn"><i class="bi bi-envelope me-2"></i>Send Reset Link</button>
</form>
<div class="links"><a href="{{ url_for('auth.login') }}">← Back to Sign In</a></div>
</div>
</body>
</html>
+3 -2
View File
@@ -78,8 +78,9 @@
<button type="submit" class="btn"><i class="bi bi-box-arrow-in-right me-2"></i>Sign In</button> <button type="submit" class="btn"><i class="bi bi-box-arrow-in-right me-2"></i>Sign In</button>
</form> </form>
<div class="links"> <div class="links" style="display:flex;justify-content:space-between;">
Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a> <a href="{{ url_for('auth.forgot_password') }}" style="color:var(--muted);">Forgot password?</a>
<a href="{{ url_for('auth.register') }}">Register here</a>
</div> </div>
</div> </div>
</body> </body>
+99
View File
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Reset Password — {{ branding.app_name }}</title>
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
<style>
:root{--bg:#f0f4f8;--border:#e2e8f0;--accent:#2563eb;--accent-h:#1d4ed8;--text:#0f172a;--text2:#334155;--muted:#64748b;--danger:#dc2626;--danger-bg:#fef2f2;}
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:var(--bg);min-height:100vh;display:flex;align-items:center;justify-content:center;}
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(var(--border) 1px,transparent 1px),linear-gradient(90deg,var(--border) 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
.card{background:#fff;border:1px solid var(--border);border-radius:16px;padding:40px;width:100%;max-width:420px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
.brand{text-align:center;margin-bottom:28px;}
.logo{width:52px;height:52px;background:var(--accent);border-radius:12px;display:flex;align-items:center;justify-content:center;font-family:'Space Mono',monospace;font-weight:700;font-size:17px;color:#fff;margin:0 auto 12px;box-shadow:0 2px 10px rgba(37,99,235,.35);}
h1{font-size:20px;font-weight:700;color:var(--text);}
.subtitle{font-size:13px;color:var(--muted);margin-top:4px;}
.form-group{margin-bottom:18px;}
label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:var(--text2);text-transform:uppercase;margin-bottom:7px;}
input{width:100%;background:#fff;border:1px solid var(--border);color:var(--text);border-radius:9px;padding:11px 14px;font-size:14px;font-family:inherit;transition:border-color .15s,box-shadow .15s;box-shadow:0 1px 3px rgba(0,0,0,.06);}
input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(37,99,235,.1);}
input::placeholder{color:#94a3b8;}
.btn{width:100%;background:var(--accent);border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;box-shadow:0 2px 6px rgba(37,99,235,.3);}
.btn:hover{background:var(--accent-h);}
.links{text-align:center;margin-top:18px;font-size:13px;color:var(--muted);}
.links a{color:var(--accent);}
.alert{border-radius:8px;padding:11px 14px;font-size:13px;margin-bottom:18px;}
.alert-danger{background:var(--danger-bg);color:var(--danger);border:1px solid #fecaca;}
/* strength meter */
#pw-strength-bar{height:4px;border-radius:2px;transition:width .3s,background .3s;background:#e2e8f0;width:0;}
#pw-strength-text{font-size:11px;color:var(--muted);margin-top:4px;}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="card">
<div class="brand">
{% if branding.logo_stored_name %}
<img src="{{ url_for('auth.serve_logo', filename=branding.logo_stored_name) }}"
alt="{{ branding.app_name }}"
style="width:52px;height:52px;border-radius:12px;object-fit:contain;margin:0 auto 12px;display:block;background:var(--accent);padding:4px;"/>
{% else %}
<div class="logo">{{ branding.logo_initials[:2] }}</div>
{% endif %}
<h1>Set New Password</h1>
<p class="subtitle">Choose a strong password for your account.</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }}"><i class="bi bi-exclamation-circle me-2"></i>{{ msg }}</div>
{% endfor %}
{% endwith %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="form-group">
<label>New Password</label>
<input type="password" name="password" id="pw-new" required placeholder="Min. 8 characters" autofocus
oninput="checkStrength(this.value)"/>
<div style="margin-top:6px;background:#e2e8f0;border-radius:2px;height:4px;">
<div id="pw-strength-bar"></div>
</div>
<div id="pw-strength-text"></div>
</div>
<div class="form-group">
<label>Confirm Password</label>
<input type="password" name="confirm_password" required placeholder="Repeat password"/>
</div>
<button type="submit" class="btn"><i class="bi bi-lock me-2"></i>Update Password</button>
</form>
<div class="links"><a href="{{ url_for('auth.login') }}">← Back to Sign In</a></div>
</div>
<script>
function checkStrength(pw) {
let score = 0;
if (pw.length >= 8) score++;
if (/[A-Z]/.test(pw)) score++;
if (/[0-9]/.test(pw)) score++;
if (/[^A-Za-z0-9]/.test(pw)) score++;
const bar = document.getElementById('pw-strength-bar');
const text = document.getElementById('pw-strength-text');
const levels = [
{ w: '0%', bg: '#e2e8f0', label: '' },
{ w: '25%', bg: '#dc2626', label: 'Weak' },
{ w: '50%', bg: '#d97706', label: 'Fair' },
{ w: '75%', bg: '#2563eb', label: 'Good' },
{ w: '100%', bg: '#059669', label: 'Strong' },
];
const lvl = levels[score] || levels[0];
bar.style.width = lvl.w;
bar.style.background = lvl.bg;
text.textContent = lvl.label;
text.style.color = lvl.bg;
}
</script>
</body>
</html>
+6 -6
View File
@@ -329,15 +329,9 @@
<li><a href="{{ url_for('admin.all_tickets') }}" class="{{ 'active' if request.endpoint == 'admin.all_tickets' }}"> <li><a href="{{ url_for('admin.all_tickets') }}" class="{{ 'active' if request.endpoint == 'admin.all_tickets' }}">
<i class="bi bi-collection"></i> All Tickets <i class="bi bi-collection"></i> All Tickets
</a></li> </a></li>
<li><a href="{{ url_for('tickets.create_ticket_behalf') }}" class="{{ 'active' if request.endpoint == 'tickets.create_ticket_behalf' }}">
<i class="bi bi-person-plus"></i> File on Behalf
</a></li>
<li><a href="{{ url_for('admin.kb_list') }}" class="{{ 'active' if 'admin.kb' in request.endpoint }}"> <li><a href="{{ url_for('admin.kb_list') }}" class="{{ 'active' if 'admin.kb' in request.endpoint }}">
<i class="bi bi-journal-text"></i> Manage KB <i class="bi bi-journal-text"></i> Manage KB
</a></li> </a></li>
<li><a href="{{ url_for('admin.canned_responses') }}" class="{{ 'active' if 'canned_response' in request.endpoint }}">
<i class="bi bi-chat-square-text"></i> Quick Replies
</a></li>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<li><a href="{{ url_for('admin.users') }}" class="{{ 'active' if request.endpoint == 'admin.users' }}"> <li><a href="{{ url_for('admin.users') }}" class="{{ 'active' if request.endpoint == 'admin.users' }}">
<i class="bi bi-people"></i> Users <i class="bi bi-people"></i> Users
@@ -345,6 +339,12 @@
<li><a href="{{ url_for('admin.activity_logs') }}" class="{{ 'active' if request.endpoint == 'admin.activity_logs' }}"> <li><a href="{{ url_for('admin.activity_logs') }}" class="{{ 'active' if request.endpoint == 'admin.activity_logs' }}">
<i class="bi bi-list-ul"></i> Activity Logs <i class="bi bi-list-ul"></i> Activity Logs
</a></li> </a></li>
<li><a href="{{ url_for('admin.ticket_templates') }}" class="{{ 'active' if 'ticket_template' in request.endpoint }}">
<i class="bi bi-layout-text-window-reverse"></i> Ticket Templates
</a></li>
<li><a href="{{ url_for('admin.satisfaction_report') }}" class="{{ 'active' if request.endpoint == 'admin.satisfaction_report' }}">
<i class="bi bi-star-half"></i> Satisfaction
</a></li>
<li><a href="{{ url_for('admin.settings') }}" class="{{ 'active' if request.endpoint == 'admin.settings' }}"> <li><a href="{{ url_for('admin.settings') }}" class="{{ 'active' if request.endpoint == 'admin.settings' }}">
<i class="bi bi-gear"></i> Settings <i class="bi bi-gear"></i> Settings
</a></li> </a></li>
+56
View File
@@ -5,6 +5,31 @@
{% block content %} {% block content %}
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-8"> <div class="col-lg-8">
{% if templates %}
<div class="card mb-3">
<div class="card-header"><i class="bi bi-layout-text-window-reverse me-2"></i>Start from a Template</div>
<div class="card-body">
<p style="font-size:13px;color:var(--muted);margin-bottom:14px;">
Select a common request type to pre-fill the form, or fill it in manually below.
</p>
<div class="row g-2">
{% for t in templates %}
<div class="col-6 col-md-4">
<button type="button" class="btn btn-secondary w-100 text-start template-btn"
style="padding:10px 14px;font-size:13px;"
data-title="{{ t.title_hint }}"
data-category="{{ t.category }}"
data-priority="{{ t.priority }}"
data-description="{{ t.description | e }}">
<i class="bi {{ t.icon }} me-2" style="color:var(--accent);"></i>{{ t.name }}
</button>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<i class="bi bi-plus-circle me-2"></i>New Support Request <i class="bi bi-plus-circle me-2"></i>New Support Request
@@ -108,4 +133,35 @@
</div> </div>
</div> </div>
</div> </div>
{% block scripts %}
<script>
document.querySelectorAll('.template-btn').forEach(btn => {
btn.addEventListener('click', () => {
const title = btn.dataset.title;
const cat = btn.dataset.category;
const prio = btn.dataset.priority;
const desc = btn.dataset.description;
if (title) document.querySelector('input[name=title]').value = title;
if (desc) document.querySelector('textarea[name=description]').value = desc;
const catSel = document.querySelector('select[name=category]');
if (catSel) {
for (const opt of catSel.options) {
if (opt.value === cat) { opt.selected = true; break; }
}
}
const prioSel = document.querySelector('select[name=priority]');
if (prioSel) {
for (const opt of prioSel.options) {
if (opt.value === prio) { opt.selected = true; break; }
}
}
// Scroll to form
document.querySelector('input[name=title]').scrollIntoView({behavior:'smooth', block:'center'});
document.querySelector('input[name=title]').focus();
});
});
</script>
{% endblock %}
{% endblock %} {% endblock %}
+26
View File
@@ -752,6 +752,32 @@ function buildCommentEl(c) {
</div> </div>
<!-- History --> <!-- History -->
<!-- ── Satisfaction rating (visible to creator + IT staff) ──────── -->
{% if ticket.satisfaction %}
<div class="card mb-3">
<div class="card-header"><i class="bi bi-star-half me-2"></i>Customer Satisfaction</div>
<div class="card-body" style="padding:16px 20px;">
{% if ticket.satisfaction.submitted %}
<div style="font-size:28px;color:#f59e0b;margin-bottom:6px;">
{% for i in range(ticket.satisfaction.rating) %}★{% endfor %}<span style="color:var(--border2);">{% for i in range(5 - ticket.satisfaction.rating) %}★{% endfor %}</span>
</div>
<div style="font-size:12px;color:var(--muted);">
Rated {{ ticket.satisfaction.rating }}/5 on {{ ticket.satisfaction.submitted_at | localtime("%b %d, %Y") }}
</div>
{% if ticket.satisfaction.comment %}
<div style="margin-top:10px;font-size:13px;color:var(--text2);background:var(--surface2);border-radius:6px;padding:10px 12px;border:1px solid var(--border);">
"{{ ticket.satisfaction.comment }}"
</div>
{% endif %}
{% else %}
<div style="font-size:13px;color:var(--muted);">
<i class="bi bi-hourglass-split me-2"></i>Survey sent — awaiting response
</div>
{% endif %}
</div>
</div>
{% endif %}
{% if history %} {% if history %}
<div class="card"> <div class="card">
<div class="card-header"><i class="bi bi-clock-history me-2"></i>Change History</div> <div class="card-header"><i class="bi bi-clock-history me-2"></i>Change History</div>
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>How did we do? — TechDesk</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
<style>
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:#f0f4f8;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;}
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(#e2e8f0 1px,transparent 1px),linear-gradient(90deg,#e2e8f0 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:40px;width:100%;max-width:500px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);}
h1{font-size:22px;font-weight:700;color:#0f172a;margin-bottom:6px;}
.sub{font-size:13px;color:#64748b;margin-bottom:24px;}
.ticket-ref{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 16px;margin-bottom:24px;font-size:13px;color:#334155;}
.ticket-ref strong{color:#2563eb;font-family:'Courier New',monospace;}
/* Star rating */
.stars{display:flex;flex-direction:row-reverse;justify-content:center;gap:6px;margin-bottom:28px;}
.stars input{display:none;}
.stars label{font-size:44px;color:#e2e8f0;cursor:pointer;transition:color .15s;line-height:1;}
.stars input:checked ~ label,
.stars label:hover,
.stars label:hover ~ label{color:#f59e0b;}
.stars input:checked + label{color:#f59e0b;}
textarea{width:100%;border:1px solid #e2e8f0;border-radius:9px;padding:11px 14px;font-size:14px;font-family:inherit;resize:vertical;min-height:90px;color:#0f172a;transition:border-color .15s,box-shadow .15s;}
textarea:focus{outline:none;border-color:#2563eb;box-shadow:0 0 0 3px rgba(37,99,235,.1);}
textarea::placeholder{color:#94a3b8;}
label.field-label{display:block;font-size:12px;font-weight:600;letter-spacing:.4px;color:#334155;text-transform:uppercase;margin-bottom:7px;}
.btn{width:100%;background:#2563eb;border:none;color:#fff;border-radius:9px;padding:12px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;font-family:inherit;margin-top:16px;box-shadow:0 2px 6px rgba(37,99,235,.3);}
.btn:hover{background:#1d4ed8;}
.alert{border-radius:8px;padding:10px 14px;font-size:13px;margin-bottom:16px;background:#fef2f2;color:#dc2626;border:1px solid #fecaca;}
.rating-hint{text-align:center;font-size:13px;color:#64748b;margin-bottom:4px;min-height:20px;}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="card">
<h1>⭐ How did we do?</h1>
<p class="sub">Your feedback helps us improve our IT support.</p>
<div class="ticket-ref">
<strong>{{ survey.ticket.ticket_number }}</strong> — {{ survey.ticket.title }}
</div>
{% if error %}
<div class="alert"><i class="bi bi-exclamation-circle me-2"></i>{{ error }}</div>
{% endif %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div style="margin-bottom:8px;">
<label class="field-label" style="text-align:center;display:block;">Your Rating *</label>
<p class="rating-hint" id="rating-hint">Click a star to rate</p>
<div class="stars">
{% for i in [5,4,3,2,1] %}
<input type="radio" name="rating" id="star{{ i }}" value="{{ i }}"
{% if quick_rating == i %}checked{% endif %}
onchange="updateHint({{ i }})"/>
<label for="star{{ i }}" title="{{ i }} star{{ 's' if i > 1 }}" onmouseover="hintOver({{ i }})" onmouseout="hintOut()"></label>
{% endfor %}
</div>
</div>
<div class="mb-3">
<label class="field-label">Comment <span style="font-weight:400;text-transform:none;font-size:11px;color:#94a3b8;">(optional)</span></label>
<textarea name="comment" placeholder="Tell us what went well or how we could improve…"></textarea>
</div>
<button type="submit" class="btn"><i class="bi bi-send me-2"></i>Submit Feedback</button>
</form>
</div>
<script>
const hints = {1:'Very dissatisfied',2:'Dissatisfied',3:'Neutral',4:'Satisfied',5:'Very satisfied'};
function updateHint(n){ document.getElementById('rating-hint').textContent = hints[n] || ''; }
function hintOver(n){ document.getElementById('rating-hint').textContent = hints[n] || ''; }
function hintOut(){
const checked = document.querySelector('.stars input:checked');
document.getElementById('rating-hint').textContent = checked ? hints[checked.value] : 'Click a star to rate';
}
// Auto-submit when star clicked via quick_rating link
{% if quick_rating %}
document.addEventListener('DOMContentLoaded', () => {
const el = document.getElementById('star{{ quick_rating }}');
if(el){ el.checked = true; updateHint({{ quick_rating }}); }
});
{% endif %}
</script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Thank You — TechDesk</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,600&display=swap" rel="stylesheet"/>
<style>
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:#f0f4f8;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;}
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(#e2e8f0 1px,transparent 1px),linear-gradient(90deg,#e2e8f0 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:48px 40px;width:100%;max-width:440px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);text-align:center;}
.icon{font-size:56px;margin-bottom:16px;}
h1{font-size:22px;font-weight:700;color:#0f172a;margin-bottom:8px;}
.sub{font-size:14px;color:#64748b;line-height:1.6;}
.stars{font-size:32px;color:#f59e0b;margin:20px 0 8px;}
.comment{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:12px 16px;font-size:13px;color:#334155;margin-top:12px;text-align:left;}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="card">
<div class="icon">🙏</div>
<h1>Thank you for your feedback!</h1>
<p class="sub">Your response has been recorded and will help us improve our IT support.</p>
{% if survey.rating %}
<div class="stars">
{% for i in range(survey.rating) %}★{% endfor %}
<span style="color:#e2e8f0;">{% for i in range(5 - survey.rating) %}★{% endfor %}</span>
</div>
{% if survey.comment %}
<div class="comment">"{{ survey.comment }}"</div>
{% endif %}
{% endif %}
<p class="sub" style="margin-top:20px;font-size:12px;color:#94a3b8;">
You can close this page. If the issue recurs, please submit a new ticket.
</p>
</div>
</body>
</html>
+24
View File
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Survey Not Found — TechDesk</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,400;9..40,600&display=swap" rel="stylesheet"/>
<style>
*{box-sizing:border-box;margin:0;padding:0;}
body{font-family:'DM Sans',sans-serif;background:#f0f4f8;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;}
.bg-grid{position:fixed;inset:0;background-image:linear-gradient(#e2e8f0 1px,transparent 1px),linear-gradient(90deg,#e2e8f0 1px,transparent 1px);background-size:48px 48px;opacity:.6;pointer-events:none;}
.card{background:#fff;border:1px solid #e2e8f0;border-radius:16px;padding:48px 40px;width:100%;max-width:440px;position:relative;z-index:1;box-shadow:0 4px 24px rgba(0,0,0,.08);text-align:center;}
h1{font-size:22px;font-weight:700;color:#0f172a;margin-bottom:8px;}
.sub{font-size:14px;color:#64748b;line-height:1.6;}
</style>
</head>
<body>
<div class="bg-grid"></div>
<div class="card">
<div style="font-size:56px;margin-bottom:16px;">🔗</div>
<h1>This survey link is invalid</h1>
<p class="sub">The link may have already been used, or it does not exist. No further action is needed.</p>
</div>
</body>
</html>