35 KiB
TechDesk — IT Helpdesk System: Knowledge Base for Claude
This document is the authoritative reference for Claude when working on the TechDesk codebase. It captures the full architecture, design decisions, known pitfalls, and hard-won lessons from the development history of this application.
1. Project Overview
TechDesk is an internal IT helpdesk web application deployed at
tickets.ltservicesinc.com. It is built for LT Services Inc. and serves two
audiences:
- Employees — submit and track IT support tickets
- IT Staff / Admins — manage, assign, resolve, and report on tickets
Technology Stack
| Layer | Technology |
|---|---|
| Language | Python 3 |
| Web Framework | Flask |
| ORM | SQLAlchemy (via Flask-SQLAlchemy) |
| Database | MySQL (driver: PyMySQL) |
| Migrations | Alembic (via Flask-Migrate) |
| Templates | Jinja2 |
| Real-time | Flask-SocketIO (eventlet async mode) |
| WSGI Server | Gunicorn (single worker, eventlet worker class) |
| Reverse Proxy | nginx (port 7000 upstream, SSL via Certbot) |
| Auth | Flask-Login |
| Forms / CSRF | Flask-WTF |
| Rate Limiting | Flask-Limiter |
| Flask-Mail | |
| Background Jobs | APScheduler (BackgroundScheduler, daemon=True) |
| Rich Text Editor | TinyMCE 6 |
| AI Chatbot | Groq API (llama-3.3-70b-versatile) |
| HTML Sanitization | bleach |
| Static Files | Served directly by nginx from /home/it-ticket/myapp/app/static |
Deployment Path
/home/it-ticket/myapp/
├── app/
│ ├── __init__.py # App factory (create_app)
│ ├── models.py # All SQLAlchemy models
│ ├── routes/
│ │ ├── admin.py # /admin/* — IT staff and admin routes
│ │ ├── api.py # /api/* — JSON endpoints + SocketIO events
│ │ ├── auth.py # /auth/* — login, register, password reset
│ │ ├── chatbot.py # /chatbot/* — AI chatbot endpoint
│ │ └── tickets.py # / and /tickets/* — employee-facing routes
│ ├── services/
│ │ ├── email_ingestion_service.py
│ │ ├── log_service.py
│ │ ├── notification_service.py
│ │ ├── sla_service.py
│ │ └── validation_service.py
│ ├── templates/
│ │ ├── base.html
│ │ ├── admin/ # Admin panel templates
│ │ ├── auth/ # Login, register, password reset templates
│ │ └── tickets/ # Employee-facing ticket templates
│ └── static/
│ └── uploads/ # User-uploaded files (default; overridable via UPLOAD_FOLDER)
├── config/
│ └── config.py # DevelopmentConfig / ProductionConfig
├── migrations/
│ └── versions/ # Alembic migration scripts
├── gunicorn.conf.py
├── run.py # Entry point — calls eventlet.monkey_patch() first
└── .env # Secrets (not committed)
2. User Roles & Access Control
Three roles are defined in UserRole:
| Role | Constant | Access |
|---|---|---|
| Employee | employee |
Submit tickets, view own tickets, knowledge base, chatbot |
| IT Staff | it_staff |
Everything above + manage all tickets, assignment, bulk actions |
| Admin | admin |
Everything above + user management, system settings, ticket deletion, KB management |
Decorators (defined in admin.py)
@admin_required # role == 'admin' only
@it_required # role in ('it_staff', 'admin')
Both decorators must be applied after @login_required.
3. Database Models (app/models.py)
Core Models
| Model | Table | Key Notes |
|---|---|---|
User |
users |
Roles: employee / it_staff / admin. is_admin and is_it_staff are properties. |
Ticket |
tickets |
Central entity. Has generate_ticket_number() with FOR UPDATE row locking to prevent race conditions. |
Comment |
comments |
Bodies stored as sanitized HTML (rendered at write time via render_comment_body). |
Attachment |
attachments |
Tied to both ticket_id and comment_id. Physical files live in UPLOAD_FOLDER. |
Notification |
notifications |
Per-user; read/unread. Cascades delete with ticket. |
TicketHistory |
ticket_history |
Field-level audit trail. old_value/new_value are TEXT (widened in migration 001). |
ActivityLog |
activity_logs |
System-wide action log. user_id has ondelete='SET NULL'. |
KnowledgeBase |
knowledge_base |
Articles with TinyMCE body, tags, view count, publish toggle. |
KBAttachment |
kb_attachments |
Files attached to KB articles. Shared UPLOAD_FOLDER with ticket attachments. |
SystemSetting |
system_settings |
Key-value config store. Use SystemSetting.get(key) / SystemSetting.set(key, value). |
TicketLink |
ticket_links |
Symmetric bidirectional links between tickets (related / duplicate / follow_up). |
CannedResponse |
canned_responses |
Pre-written reply templates for IT staff. |
KBFeedback |
kb_feedback |
One thumbs up/down vote per user per article. |
PasswordResetToken |
password_reset_tokens |
SHA-256 hashed, 1-hour expiry, single-use. |
TicketTemplate |
ticket_templates |
Pre-filled ticket scaffolds selectable on the new ticket form. |
TicketSatisfaction |
ticket_satisfaction |
One survey per resolved ticket; token-authenticated survey URL. |
TicketWatcher |
ticket_watchers |
Users subscribed to updates on a ticket. Unique (ticket_id, user_id). Added migration 005. |
TimeEntry |
time_entries |
IT staff time log per ticket. Stores minutes + optional note. Added migration 005. |
Cascade Rules
Ticket has cascade='all, delete-orphan' on:
commentsattachmentsnotificationshistorylinks_as_source/links_as_target(viaTicketLink)satisfaction(viaTicketSatisfaction)watchers(viaTicketWatcher)time_entries(viaTimeEntry)
When deleting a ticket, physical files in UPLOAD_FOLDER must be removed
manually before the DB delete — SQLAlchemy cascades handle DB rows only.
4. Blueprint & Route Structure
| Blueprint | Prefix | File | Guard |
|---|---|---|---|
auth_bp |
(none) | routes/auth.py |
Public + @login_required per route |
tickets_bp |
(none) | routes/tickets.py |
@login_required per route |
admin_bp |
/admin |
routes/admin.py |
@login_required + @it_required or @admin_required |
api_bp |
/api |
routes/api.py |
@login_required per route |
chatbot_bp |
/chatbot |
routes/chatbot.py |
@login_required per route |
Key Routes Reference
Auth (auth.py)
GET/POST /login— rate limited: 10/min, 50/hrGET/POST /register— rate limited: 5/min, 20/hr; guarded byregistration_enabledSystemSettingGET /logoutGET/POST /profileGET/POST /forgot-password— rate limited: 5/hrGET/POST /reset-password/<token>— rate limited: 10/hrGET /avatar/<filename>— login required (prevents unauthenticated access)GET /logo/<filename>— public (used in login page)
Tickets (tickets.py)
GET /andGET /dashboard— role-aware: employees seedashboard_employee.html, IT staff seedashboard_it.htmlGET/POST /tickets/newGET/POST /tickets/behalf— IT staff only (file ticket on behalf of employee)GET /tickets— employee ticket list (own tickets only)GET/POST /tickets/<id>— ticket detail + comment submissionPOST /tickets/<id>/update— IT staff update (status, priority, assignment, etc.)POST /comments/<id>/deleteGET /attachments/<id>— file downloadGET /notifications/POST /notifications/mark-readGET /kb/GET /kb/<id>— knowledge basePOST /tickets/<id>/link/POST /tickets/<id>/unlink/<link_id>POST /tickets/<id>/reopenPOST /tickets/<id>/watch/POST /tickets/<id>/unwatch— AJAX; toggle watcher subscriptionPOST /tickets/<id>/log-time— IT staff only; AJAX-aware; logsTimeEntryGET /canned-responses— JSON endpoint for IT staff comment boxPOST /kb/<id>/feedbackGET/POST /survey/<token>— public (no login required)
Admin (admin.py)
GET /admin/— IT Operations Overview dashboardGET /admin/users/POST /admin/users/new/POST /admin/users/<id>/edit/POST /admin/users/<id>/deleteGET /admin/tickets— all tickets with search, filter, paginationPOST /admin/tickets/<id>/delete— admin only; deletes ticket + physical filesPOST /admin/tickets/bulk-action— actions: resolve, close, assign_me, assign_to, unassign, delete (delete is admin-only;assign_torequiresassign_to_idform field)GET /admin/tickets/export— CSV export with active filtersGET /admin/kb/GET/POST /admin/kb/new/GET/POST /admin/kb/<id>/edit/POST /admin/kb/<id>/delete/POST /admin/kb/<id>/publishPOST /admin/kb/upload-image— TinyMCE image uploadGET /admin/kb/files/<stored_name>— file serve (login required)GET/POST /admin/logs— activity log viewerGET/POST /admin/canned-responses/*POST /admin/tickets/bulk-actionGET/POST /admin/ticket-templates/*GET /admin/satisfaction— satisfaction reportGET/POST /admin/settings
API (api.py)
GET /api/notifications— JSON listGET /api/notifications/unread-countPOST /api/notifications/<id>/readPOST /api/notifications/mark-all-readGET /api/tickets/<id>/comments— JSON (used by real-time comment rendering)GET /api/stats/tickets— dashboard KPI statsGET /api/users/search— typeahead for ticket assignmentGET /api/tickets/search— typeahead for ticket linking- SocketIO events:
connect,disconnect,join_ticket,leave_ticket
Chatbot (chatbot.py)
POST /chatbot/message— rate limited: 20/min, 100/hr; calls Groq API
5. Services
log_service.py
log_action(user_id, action, entity_type=None, entity_id=None, details=None)
log_ticket_history(ticket, field_name, old_value, new_value, changed_by_id)
Critical rules:
- Neither function calls
db.session.commit(). The caller must commit. - On failure, only the failed log entry is
expunge()d — never calldb.session.rollback()inside these functions, as that would undo the parent operation. - Always call
log_actionbeforedb.session.commit(). log_ticket_historyrecords granular field-level changes on tickets.
Standard action strings used across the codebase:
| Action String | Where |
|---|---|
ticket_create |
New ticket submitted |
ticket_update |
Ticket fields changed |
comment_create |
Comment added |
comment_delete |
Comment deleted |
admin_user_create |
Admin creates a user |
admin_user_edit |
Admin edits a user |
admin_user_deactivate |
Admin deactivates a user |
admin_ticket_delete |
Admin deletes a ticket |
ticket_bulk_resolve |
Bulk resolve |
ticket_bulk_close |
Bulk close |
ticket_bulk_assign_me |
Bulk assign to self |
ticket_bulk_assign_to |
Bulk assign to specific user |
ticket_bulk_unassign |
Bulk unassign |
kb_create / kb_edit / kb_delete |
KB article CRUD |
kb_attachment_delete |
KB attachment removed |
time_log |
IT staff logs time on a ticket |
ticket_create_chatbot |
Ticket created via AI chatbot |
notification_service.py
Functions: notify_new_ticket, notify_status_change, notify_comment_added,
notify_assignment, send_satisfaction_survey, notify_watchers,
send_weekly_digest
Critical rule: Always call notification functions after db.session.commit().
Calling them before commit means they act on uncommitted state.
notify_watchers(ticket, event_title, event_message, exclude_user_id=None)— sends in-app notifications to allTicketWatcherrows for a ticket. Call after commit fromupdate_ticket. Passexclude_user_id=current_user.idso the actor doesn't notify themselves.send_weekly_digest(app)— APScheduler job; runs every Monday at 08:00 UTC viaCronTrigger. Sends a rich HTML email to all active IT staff / admin users withemail_notif=True. Summarises new, resolved, still-open, and overdue tickets for the past 7 days.
sla_service.py
set_due_date(ticket, app)— called at ticket creation; reads SLA hours fromSystemSettingcheck_sla_breaches(app)— runs every 30 minutes via APScheduler; creates notifications for overdue ticketsclear_sla_notification(ticket_id)— called when a ticket is resolved or closed
validation_service.py
validate_password(password, confirm)— returns error string or Nonevalidate_file(file, allowed_extensions)— magic-byte MIME validation (not just extension)render_comment_body(raw_text)— converts Markdown/plain text to sanitized HTML via bleach; called at write time, not display time
email_ingestion_service.py
check_inbound_email(app)— APScheduler job; polls IMAP mailbox and converts emails to tickets- Deduplication via
Message-IDheaders stored inSystemSetting - Unknown senders fall back to the system admin user account
6. Configuration & Environment
.env Keys
SECRET_KEY=
DB_USER=
DB_PASSWORD=
DB_HOST=
DB_PORT=3306
DB_NAME=it_tickets
MAIL_SERVER=
MAIL_PORT=587
MAIL_USE_TLS=True
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_DEFAULT_SENDER=
IT_DEPT_EMAIL=
APP_BASE_URL=https://tickets.ltservicesinc.com
UPLOAD_FOLDER= # Absolute path; defaults to app/static/uploads
GROQ_API_KEY=
GROQ_MODEL=llama-3.3-70b-versatile
ADMIN_EMAIL=
ADMIN_PASSWORD=
RATELIMIT_STORAGE_URI=memory://
SystemSetting Keys (runtime config, editable via Admin → Settings)
| Key | Default | Description |
|---|---|---|
registration_enabled |
true |
Allow self-registration |
survey_enabled |
true |
Send satisfaction survey on resolve |
app_timezone |
America/New_York |
Display timezone for all dates |
sla_critical_hours |
4 |
SLA breach threshold for CRITICAL |
sla_high_hours |
8 |
SLA breach threshold for HIGH |
sla_medium_hours |
48 |
SLA breach threshold for MEDIUM |
sla_low_hours |
120 |
SLA breach threshold for LOW |
app_name |
TechDesk |
Sidebar/page title branding |
app_subtitle |
IT Helpdesk System |
Sidebar subtitle |
company_name |
`` | Login/register page branding |
logo_stored_name |
`` | Uploaded logo filename |
logo_initials |
TD |
Fallback initials when no logo |
primary_color |
#2563eb |
CSS accent color (hex) |
email_ingestion_enabled |
0 |
Enable IMAP email-to-ticket |
email_ingestion_host/port/user/password/folder/move_to/interval |
various | IMAP settings |
7. Known Pitfalls & Hard Rules
These are non-negotiable lessons learned from production incidents and debugging sessions. Violating any of these will introduce bugs.
7.1 eventlet / Gunicorn Ordering
eventlet.monkey_patch()must run before the app is loaded.- It is called in
run.pyat module level beforecreate_app(). preload_app = Falseingunicorn.conf.pyis mandatory. Setting it toTruecauses the app to load in the master process beforemonkey_patch()runs, leaving real OS RLocks un-greened and triggering:"1 RLock(s) were not greened"
7.2 Flask-Limiter Storage
- Flask-Limiter does not support
mysql+pymysql://as a storage URI. - Always use
memory://(orredis://if Redis becomes available). - The
RATELIMIT_STORAGE_URIconfig key controls this.
7.3 SQLAlchemy Session & Generators
- Generators that access SQLAlchemy ORM objects execute lazily.
- If a generator is returned from a route and consumed outside the request context (after the session closes), it silently yields nothing.
- Always collect ORM results into a list within the session context before yielding or returning from a route. This was the cause of empty CSV exports.
7.4 db.create_all() and Alembic
db.create_all()is called increate_app()for first-run convenience.- If tables already exist (normal in production),
create_all()is a no-op. - Do not use
db.create_all()as a substitute for migrations. New columns added in migrations will not be created bycreate_all()on existing tables. - Alembic revision IDs must fit within
VARCHAR(64)inalembic_version(widened in migration 003). Do not use revision IDs longer than 64 characters. - Never break the migration chain: each migration's
Revises:must point to the previous revision ID exactly.
7.5 db.session.get() vs Model.query.get()
Model.query.get()is deprecated in SQLAlchemy 2.x.- Always use
db.session.get(Model, pk)for primary-key lookups. - For 404 handling:
db.session.get(Ticket, ticket_id) or abort(404)
7.6 CSRF on AJAX Requests
- All
fetch()/XMLHttpRequestPOST requests must include the CSRF token. - Source it from
<meta name="csrf-token" content="{{ csrf_token() }}">inbase.html. - Header name:
X-CSRFToken
7.7 WebSocket Emit Timing
- Never emit a SocketIO event inside a request handler before the HTTP→WS transport upgrade completes.
- Use
socketio.start_background_task()to defer emits that should not block the request cycle. - Emitting synchronously before upgrade causes disconnect storms.
7.8 @login_required on File-Serve Routes
- Routes that serve files via
send_from_directory(e.g.kb_serve_file,serve_avatar) must have@login_required. - Exception: Logo serving (
serve_logo) must be public because the login page renders the logo before authentication. @login_requiredon image-serve routes blocks<img>tag requests in some browser/session contexts. Test<img src="...">rendering explicitly after adding auth guards to file routes.
7.9 Notification Timing
notify_assignment(),notify_status_change(), and all notification functions must be called afterdb.session.commit().- Calling them before commit means they reference uncommitted state and may send emails/notifications for operations that subsequently fail.
7.10 regexp_replace on MySQL
- MySQL 8.0's
REGEXP_REPLACE(expr, pat, repl)accepts exactly 3 arguments. - The 4th
flagsargument ('g'for global) is a PostgreSQL/MariaDB extension. - MySQL applies global replacement by default — the
'g'flag is unnecessary and causes:(1583, "Incorrect parameters in the call to native function 'regexp_replace'") - Always write:
func.regexp_replace(Comment.body, r'<[^>]+>', '')— never:func.regexp_replace(Comment.body, r'<[^>]+>', '', 'g')
7.11 Chatbot Duplicate Ticket Prevention
- After a ticket is created via the chatbot,
chatHistoryon the frontend must be purged to prevent re-submission on the next message. - The server strips
"action": "create_ticket"blocks from history before sending to the Groq API to prevent the model from re-triggering creation.
7.12 Ticket Deletion — Physical File Cleanup
- SQLAlchemy
cascade='all, delete-orphan'removesAttachmentrows but does not delete files from disk. - Always iterate
ticket.attachments.all()andos.remove()eachattachment.stored_namefromUPLOAD_FOLDERbefore callingdb.session.delete(ticket). - Wrap each
os.remove()in try/except and log warnings for missing files (do not abort the delete if a file is already gone).
7.13 Bulk Actions — Delete vs Status Actions
- Bulk delete must early-return after its own commit so the resolve/close/assign/unassign loop does not run on already-deleted tickets.
- Bulk delete is admin-only even though the
bulk_ticket_actionroute is decorated with@it_required. Checkcurrent_user.is_admininside the route for the delete branch.
8. Comment Body Storage
- Comments are stored as sanitized HTML at write time, not display time.
render_comment_body(raw_text)invalidation_service.pyconverts Markdown/plain text to HTML and sanitizes via bleach.- When searching comment bodies in SQL, the stored HTML must be stripped before
LIKEmatching:func.regexp_replace(Comment.body, r'<[^>]+>', ''). - Migration 003 backfilled all existing plain-text comment bodies to HTML.
9. Real-time Comments (SocketIO)
- Clients join a ticket room on the detail page via
join_ticketevent. - New comments are submitted via AJAX (
fetchPOST) withX-CSRFTokenheader. - The server broadcasts the new comment to the room via SocketIO after commit.
- The
get_commentsAPI endpoint (/api/tickets/<id>/comments) returns comment HTML for rendering; it calls_resolve_mime_typeto handle NULL/octet-stream MIME types with extension-based fallback.
10. File Uploads
- All uploads (ticket attachments, comment images, KB files, avatars, logo)
land in the same
UPLOAD_FOLDERdirectory. - Files are stored with UUID-based
stored_namevalues (not the original filename) to prevent path traversal and collisions. validate_file()uses magic-byte inspection (not just extension) for MIME validation.- Allowed extensions:
png, jpg, jpeg, gif, pdf, doc, docx, txt, zip, log - Max upload size:
MAX_CONTENT_LENGTH(default 16 MB) - Per-comment attachment limits are enforced both client-side and server-side.
11. Branding & Theming
- App name, subtitle, logo, and primary color are all configurable via
Admin → Settingsand stored inSystemSetting. - Branding values are injected into every template via the
inject_globalscontext processor increate_app(). - The primary color is injected as a CSS variable override so the entire UI recolors without a code change.
- Logo is served via
GET /logo/<filename>— this route must remain public so the login page can render the logo before authentication.
12. Migrations
Migrations live in migrations/versions/. The chain is:
(base)
└── 001_widen_ticket_history_values — VARCHAR(200) → TEXT for old/new_value
└── 002_add_system_settings — creates system_settings table
└── 003_render_comments — backfills comment bodies to HTML;
widens alembic_version.version_num to VARCHAR(64)
└── 004_widen_system_setting_value — system_settings.value VARCHAR(500) → TEXT
└── 005_add_watchers_and_time_entries — creates ticket_watchers
and time_entries tables
Rules:
- Never modify an existing migration that has been applied to production.
- Always set
Revises:to the previous revision ID exactly. - Revision IDs must be ≤ 64 characters.
- Run
flask db upgradeafter deploying new migration files. db.create_all()increate_app()will not apply pending migrations — it only creates tables that do not exist at all.
13. Security Measures
| Concern | Implementation |
|---|---|
| CSRF | Flask-WTF CSRFProtect; AJAX uses X-CSRFToken header |
| Rate limiting | Flask-Limiter on login (10/min, 50/hr), register (5/min, 20/hr), forgot-password (5/hr), chatbot (20/min, 100/hr) |
| Password hashing | Werkzeug generate_password_hash / check_password_hash |
| Password reset tokens | SHA-256 hashed, 1-hour TTL, single-use |
| File upload validation | Magic-byte MIME inspection + extension allowlist |
| HTML sanitization | bleach on all comment bodies and KB article bodies |
| XSS in KB | bleach allowlist covers TinyMCE-produced tags; _sanitize_kb_body() in admin.py |
| XSS in JS | _esc() helper in base.html escapes all server-supplied strings before DOM insertion (notifications, toasts) |
| SQL injection | SQLAlchemy ORM parameterized queries throughout |
| Real client IP | ProxyFix middleware trusts one nginx hop; log_service._get_real_ip() reads X-Forwarded-For |
| Role enforcement | @admin_required / @it_required decorators; per-route checks where needed |
| Avatar/file access | All file-serve routes require @login_required (except logo) |
| HTTP security headers | @app.after_request hook in create_app() sets X-Frame-Options, X-Content-Type-Options, Referrer-Policy, X-XSS-Protection, Permissions-Policy via setdefault (never overrides app-set headers) |
| Session cookie security | ProductionConfig sets SESSION_COOKIE_SECURE=True, SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE='Lax' |
14. Activity Logging Reference
Every create, edit, and delete action must call log_action() before the
db.session.commit() that finalizes the operation.
For ticket field changes, also call log_ticket_history() for each changed
field.
Python logger calls (logger.info(...)) use structured bracket prefixes for
easy grep:
[TICKET DELETE]
[BULK DELETE]
[BULK ACTION]
[TICKET HISTORY]
[ACTIVITY]
[ACTIVITY LOG ERROR]
[SLA]
[EMAIL INGEST]
[SEED]
[KB IMAGE UPLOAD]
[TZ]
15. Deployment Checklist
After any code change:
# 1. Copy changed files to deployment path
cp <file> /home/it-ticket/myapp/<path>
# 2. If new migration files added:
cd /home/it-ticket/myapp && flask db upgrade
# 3. Restart Gunicorn
sudo systemctl restart gunicorn
# 4. Verify — check logs for errors
sudo journalctl -u gunicorn -n 50
tail -f /home/it-ticket/myapp/logs/gunicorn_error.log
nginx does not need to be restarted for Python code changes (it only proxies; static files are served directly).
16. Completed Features (Development History)
| Feature | Status |
|---|---|
| Core ticket CRUD (create, view, update, comment) | ✅ |
| Role-based access control (employee / IT staff / admin) | ✅ |
| Real-time comments via SocketIO | ✅ |
| Image upload in comments (clipboard paste + file picker) | ✅ |
| User avatar upload with disk cleanup | ✅ |
| Admin-controlled user registration toggle | ✅ |
| CSV export with active filter support | ✅ |
| Markdown rendering with bleach sanitization (stored at write time) | ✅ |
| Full branding customization (logo, name, colors) | ✅ |
| Knowledge Base with TinyMCE, attachments, feedback | ✅ |
| IT Operations Overview dashboard (KPIs, per-staff metrics) | ✅ |
| Canned responses for IT staff | ✅ |
| Ticket templates for common request types | ✅ |
| Ticket linking (related / duplicate / follow-up) | ✅ |
| SLA breach detection with APScheduler | ✅ |
| Satisfaction surveys on ticket resolution | ✅ |
| Email-to-ticket ingestion via IMAP | ✅ |
| IT staff notifications (in-app + individual email) on new ticket | ✅ |
| Browser tab title shows unread notification count | ✅ |
| AI chatbot via Groq (llama-3.3-70b-versatile) | ✅ |
| Self-service password reset with token-authenticated email | ✅ |
| Admin ticket deletion (single + bulk) | ✅ |
| Activity log viewer | ✅ |
| Configurable display timezone | ✅ |
| HTTP security headers (X-Frame-Options, CSP-adjacent, etc.) | ✅ |
| Session cookie security flags (Secure, HttpOnly, SameSite) | ✅ |
XSS protection in JS notification/toast HTML via _esc() |
✅ |
Reusable confirmModal() system replacing all confirm() dialogs |
✅ |
| Toast notifications repositioned to top-right (no FAB overlap) | ✅ |
| Page navigation progress bar (thin top bar on link clicks) | ✅ |
| Dark mode with localStorage persistence and anti-FOCT script | ✅ |
| AJAX save on IT Update Panel (no page reload) | ✅ |
Mobile-responsive tables via table-responsive wrappers |
✅ |
| Employee dashboard: status progress bar + urgency flags per ticket | ✅ |
| Linked tickets shown read-only to employees on ticket detail | ✅ |
| SLA status badge on ticket detail (On track / Overdue / Completed) | ✅ |
| Template preview panel on new ticket form | ✅ |
| Bulk assign to specific IT staff member | ✅ |
| Weekly IT digest email (APScheduler, Monday 08:00 UTC) | ✅ |
| Chatbot KB context injection (top matching articles in system prompt) | ✅ |
| Ticket watchers (subscribe/unwatch, in-app notifications on update) | ✅ |
| Time tracking (IT staff log minutes per ticket, AJAX form) | ✅ |
17. Declined / Rejected Features
| Feature | Reason |
|---|---|
| WebRTC / Remote desktop control | Architectural complexity too high; prefer integrating existing tools (e.g. AnyDesk, TeamViewer) over building from scratch |
Last updated: 2026-05-22
18. Notification Architecture
notify_new_ticket Fan-out (updated 2026-04-17)
When a ticket is created — via the web form, the chatbot, the "on behalf" flow,
or email ingestion — notify_new_ticket(ticket) delivers three tiers of alerts:
- Department alias email — one email to
IT_DEPT_EMAIL(the shared inbox). - Individual staff emails — one email per active
it_staff/adminuser whoseemail_notif == True. Consistent withnotify_comment_addedandnotify_assignment. - In-app notifications — one
Notificationrow per active IT staff / admin user, regardless ofemail_notifsetting, pushed via WebSocket. - Creator confirmation — one in-app notification to the ticket submitter.
Background Job Email Delivery (send_email)
send_email() spawns a background thread. The daemon mode depends on caller
context:
- Inside a web request →
daemon=True(process won't be held open by the thread after the response is sent). - Outside a request context (APScheduler job) →
daemon=False. Daemon threads are killed when the parent thread exits; since APScheduler job threads finish quickly, a daemon SMTP thread is terminated before delivery completes, causing silent email loss. Non-daemon threads survive until SMTP finishes.
The same logic applies to send_satisfaction_survey's inline thread spawn.
SocketIO Emit from Background Jobs
create_notification emits new_notification via SocketIO. The emit strategy
branches on has_request_context():
- Inside a web request →
socketio.start_background_task(_emit)(non-blocking, avoids racing the polling→WebSocket upgrade handshake). - Outside a request context →
socketio.emit()called directly. Usingstart_background_taskfrom a background job causes Flask-SocketIO to invoke theon_connecthandler internally, which referencescurrent_user. With no session attached, Flask-Login returnsNone→AttributeError.
19. Hard Rules (additions 2026-04-17)
7.14 inject_globals Context Processor and Background Rendering
render_template_string()andrender_template()trigger all registered context processors, includinginject_globalsinapp/__init__.py.inject_globalsaccessescurrent_user.is_authenticated. Outside a request context (e.g. APScheduler jobs callingnotify_new_ticket), Flask-Login resolvescurrent_usertoNone, causingAttributeError.- Rule: Always guard
current_useraccess in context processors withhas_request_context()before callingcurrent_user.is_authenticated:if has_request_context() and current_user.is_authenticated: - Corollary: Any service function that calls
render_template_string()orrender_template()from a background context (APScheduler, daemon threads, CLI commands) will trigger this processor. The guard ininject_globalsis the single correct fix — do not work around it at the call site.
7.15 SocketIO on_connect / on_disconnect Handlers and Sessionless Contexts
- The
on_connectandon_disconnectSocketIO event handlers inapi.pyreferencecurrent_user. Whensocketio.emit()orstart_background_taskis called without a session-backed request context, these handlers may be invoked withcurrent_user = None. - Rule: All SocketIO event handlers that reference
current_usermust guard withif current_user and current_user.is_authenticated:(not justif current_user.is_authenticated:).
7.16 SystemSetting.value Column Width
SystemSetting.valuewasVARCHAR(500). Theemail_ingested_message_idskey accumulates comma-separated Message-IDs; Outlook Message-IDs are ~100 characters each, so 6+ processed messages overflow the column.- Widened to
TEXTin migration 004 (004_widen_system_setting_value). - Rule: Never use
VARCHARfor anySystemSettingvalue that can accumulate list data or free-form strings of unbounded length. UseText.
7.17 Diagnosis Protocol — Root Cause Before Fix
When an error is reported:
- Read the full traceback, not just the exception message. Add
exc_info=Trueto the catchinglogger.error()call if the traceback is being swallowed. - Trace the complete call chain from the error site back to the originating caller — do not assume the fix belongs at the first plausible location.
- Grep every file in the call chain for the symbol that is
Noneor missing before writing any code. - Write the fix, verify syntax (
ast.parse), and assert the changed lines are present before packaging for deployment. - Never apply a patch to a symptom site (e.g. adding a guard to
api.py) when the root cause is upstream (e.g.inject_globalscrashing duringrender_template_string). Symptom-site patches mask errors without resolving them and accumulate technical debt.
20. Migration History (updated)
| Revision ID | Description |
|---|---|
001_widen_ticket_history_values |
Widen ticket_history.old_value / new_value from VARCHAR(200) to TEXT |
002_add_system_settings |
Create system_settings table |
003_render_comments |
Backfill comment bodies to HTML; widen alembic_version.version_num to VARCHAR(64) |
004_widen_system_setting_value |
Widen system_settings.value from VARCHAR(500) to TEXT |
005_add_watchers_and_time_entries |
Create ticket_watchers (UniqueConstraint + index) and time_entries (index) tables |
21. Browser Tab Notification Counter (added 2026-04-17)
Overview
When the user has TechDesk open in a background tab, the browser tab title displays the current unread notification count as a prefix so they can see pending work without switching back.
Examples:
(3) Dashboard — TechDesk← 3 unread notificationsDashboard — TechDesk← no unread notifications
Implementation (app/templates/base.html)
<title> tag — given id="page-title-text" so JavaScript can read the
server-rendered base title without hardcoding it:
<title id="page-title-text">{% block title %}...{% endblock %} — {{ branding.app_name }}</title>
updateDocumentTitle(unreadCount) — new JS function that sets
document.title to "(n) <base title>" when n > 0, or restores the
original title when n === 0.
_baseDocTitle — captured once at page load from the <title> element so
page-specific titles (e.g. "Ticket Detail", "Dashboard") are preserved exactly.
updateBadge() — extended to call updateDocumentTitle(next) on every
badge change, covering all update paths:
- Real-time WebSocket
new_notificationevent - Opening the notification panel (
loadNotificationsrecalculates count) - Clicking an individual notification to mark it read
- "Mark all read" button
IIFE on page load — reads the server-rendered badge count and calls
updateDocumentTitle() immediately, so the title is correct even before any
WebSocket event fires.
Key Design Decisions
- No new routes, API calls, or Python changes — purely a frontend enhancement.
_baseDocTitleis captured from the DOM, not hardcoded, so it works correctly for every page that extendsbase.htmlwithout any per-template changes.- The count in the title stays in sync with the bell badge — a single
updateBadge()call updates both, so they can never diverge.