Self Service Portal - edmozley/freeitsm GitHub Wiki

Self-Service Portal

Folder: self-service/ ยท API: api/self-service/ (21 endpoints)

The portal your customers see. They raise tickets, follow and reply to them, download what you've sent, read your Knowledge base, and order from a request catalogue โ€” without emailing anyone or phoning the desk.

The portal deliberately looks and behaves like the rest of FreeITSM: the same nav buttons (inbox.css primitives), the same theme tokens, the same toast. Someone moving between the portal and the analyst side should not feel they've changed product.

Four places to go โ€” Dashboard, My Tickets, Knowledge, Help. Raising a ticket and requesting something are not in the nav: they're things you do, so they're buttons on the dashboard.

Requesters are a separate identity space from analysts: they live in users, sign in against $_SESSION['ss_user_id'], and the two sessions coexist in one browser without colliding.

The portal can be the front door. By default opening FreeITSM goes to the analyst login, but System โ†’ Branding โ†’ Landing page can point it here instead โ€” with a per-analyst override so analysts do not pay an extra click. See Landing page.

The dashboard's status panel can also show the incidents behind an outage under What is happening, each with the services it affects and a collapsed timeline of updates. Only updates staff have marked external are ever shown, and it is off until an administrator turns it on โ€” see Service Status.

Pages in this section

Page What it covers
Portal Help Centre Publishing knowledge articles to customers, the three visibility levels, ticket deflection, popular articles
Portal Request Catalogue Offering forms as orderable requests, and what happens to a submission
Portal privacy What customers see of their own ticket โ€” forwards, CCs and third-party replies
Self-Service Developer Guide ๐Ÿ“ Every file, what it does, and the rules to follow when extending the portal

๐Ÿ“ The files, at a glance

Colour key: ๐Ÿ–ฅ๏ธ page ยท ๐Ÿ”Œ API ยท ๐Ÿงฉ shared chrome ยท โš™๏ธ shared logic ยท ๐ŸŽจ assets ยท ๐ŸŒ i18n

๐Ÿ–ฅ๏ธ Pages (self-service/)

๐ŸŽจ File Purpose
๐Ÿ–ฅ๏ธ index.php Dashboard โ€” action buttons, status cards, recent tickets, system status, popular articles
๐Ÿ–ฅ๏ธ tickets.php My Tickets โ€” the two-pane view: list left, conversation right, reply docked at the bottom
๐Ÿ–ฅ๏ธ new-ticket.php The compose screen โ€” full-height rich-text editor with a settings panel down the right
๐Ÿ–ฅ๏ธ ticket.php โš ๏ธ Redirect only. Superseded by tickets.php; kept because ?id= links are already out in the wild
๐Ÿ–ฅ๏ธ catalogue.php Request Something โ€” the request catalogue (reached from the dashboard, not the nav)
๐Ÿ–ฅ๏ธ help-centre.php Knowledge โ€” searchable articles; also the single article reader (?id=)
๐Ÿ–ฅ๏ธ help.php Help โ€” the guide to the portal itself (not the knowledge base)
๐Ÿ–ฅ๏ธ login.php Sign in โ€” email-first SSO router, provider buttons, local break-glass, MFA challenge
๐Ÿ–ฅ๏ธ register.php Sign up โ€” email-confirmed; can be switched off entirely
๐Ÿ–ฅ๏ธ verify-email.php The confirmation-link landing page that actually sets the password
๐Ÿ–ฅ๏ธ logout.php Ends the portal session (and the IdP session, for SSO)

๐Ÿงฉ Shared chrome (self-service/includes/)

๐ŸŽจ File Purpose
๐Ÿงฉ header.php <head>, theme, brand bar and the nav โ€” one array. Adding a page is one entry here
๐Ÿงฉ footer.php Closes the layout, ships i18n to JS, emits window.PAGE and the page's own script
๐Ÿงฉ auth.php The guard โ€” every signed-in page includes it
๐Ÿงฉ user-menu.php Avatar, dropdown, My Account / MFA / password modals

โš ๏ธ $pageScripts is a nowdoc. PHP tags inside it are not executed and reach the browser verbatim, killing the whole script block. Page-specific values go through $pageData โ†’ window.PAGE. See the developer guide.

๐Ÿ”Œ API (api/self-service/)

Area Files
Auth login.php, verify_login_otp.php, register.php
Tickets create_ticket.php, get_tickets.php, get_ticket_detail.php, reply_ticket.php, get_attachment.php
Dashboard get_dashboard.php, get_mailboxes.php
Recordings upload_recording.php (pending upload), get_recording.php (range-aware streaming)
Knowledge get_knowledge_articles.php (list, search, sort=popular), get_knowledge_article.php (one article)
Catalogue get_catalogue.php, get_catalogue_form.php, submit_catalogue_form.php
Account get_profile.php, update_profile.php, change_password.php, save_theme.php

โš™๏ธ Shared logic outside the portal folder

๐ŸŽจ File Why the portal needs it
โš™๏ธ includes/portal_visibility.php Whether a message on the ticket was for the requester โ€” see Portal privacy
โš™๏ธ includes/ticket_reply.php Reopening a finished ticket when the customer comes back (shared with email/WhatsApp/web chat)
โš™๏ธ includes/knowledge/portal_reader.php ๐Ÿ”‘ The one definition of what a requester may read โ€” portalKnowledgeScope(), portalUserTenantId(), portalArticlePreview(). Every portal surface that lists or opens an article goes through it
โš™๏ธ includes/knowledge/audience.php The internal / customer / public ladder that scope reads at
โš™๏ธ includes/tenancy.php resolveTenantForNewUser(), knowledgeTenantFilterForCompany()
โš™๏ธ includes/self_service_mailboxes.php ๐Ÿ”‘ selfServiceMailboxes() โ€” the one definition of which mailboxes a requester may raise a ticket through. The compose picker lists it and create_ticket.php re-checks the posted id against it (#172)
โš™๏ธ includes/html_sanitise.php Server-side allow-list for what a customer writes (the compose editor's HTML)
โš™๏ธ includes/ticket_recordings.php ๐Ÿ”‘ claimPendingRecordings() โ€” moving a pending screen recording onto a ticket, and the ownership guard. Shared by create + reply
๐ŸŽจ assets/js/screen-recorder.js The screen recorder itself, shared by the compose screen and the reply composer
๐Ÿงฉ self-service/includes/record-modal.php The recording dialog's markup (the script binds to its ids)
โš™๏ธ includes/theme.php Theme::active() โ€” reads users.theme_preference for portal users
โš™๏ธ includes/services/forms.php FormsService::submitForm() โ€” the catalogue submits through it
๐ŸŽจ assets/css/self-service.css All shared portal styling, fully theme-tokenised
๐ŸŽจ assets/js/safe-html.js Cleans untrusted HTML โ€” email bodies and article bodies
๐ŸŒ lang/en/self-service.php + lang/pt-BR/self-service.php Every string. Both, in the same commit

Authentication

  • Registration is off by default and, when on, confirmed by email: submitting the form sets no password and signs nobody in โ€” a one-time link is sent to the address, and the password is applied only when that link is opened. This closes an account-takeover hole: many users rows are auto-created without a password by inbound email, web chat or ticket creation, and the old "claim a passwordless account" flow let anyone who knew such an address take it over. The switch lives in System โ†’ Security (self_service_registration_enabled); while off, the register page redirects to login and the create an account link is hidden, so only accounts an admin creates can use the portal. Sending the confirmation email requires a configured mailbox โ€” registration fails closed with a clear message if none is set โ€” and a genuinely new account is likewise only created when the link is opened, never on form submit.
  • Separate session key โ€” $_SESSION['ss_user_id'], so a requester and an analyst can be signed in at once.
  • MFA (TOTP) โ€” same libraries as the analyst side, optional per user, secrets encrypted at rest.
  • Single sign-on โ€” the same identity providers as the analyst login, with an email-first router, provider buttons and a local break-glass. See Single Sign-On.

Multi-company (MSP)

A provider can be owned by a client company, and on a multi-company install the login becomes email-first only (provider buttons up front would leak every client's IdP): email domain โ†’ company โ†’ that company's IdP(s). Invisible at N=1.

Requesters also belong to a company (users.tenant_id), which decides where their portal tickets are filed and which knowledge articles they see. Set it in Tickets โ†’ Users; it's pre-filled from the email domain where that's unambiguous, and freemail is never guessed. Without it a portal ticket goes to triage โ€” exactly as an email from an unrecognised domain does.

New accounts are pre-filled by resolveTenantForNewUser() wherever a requester comes into being โ€” registration, SSO JIT provisioning, admin create and REST POST /users. An SSO provider pinned to a company outranks the email domain, since the provider vouches for whoever it signs in. The company picker in Tickets โ†’ Users is hidden at N=1 and only offers companies the analyst can reach โ€” and save_user.php re-checks server-side, so the dropdown is a convenience, not the guard. The company is deliberately not re-derived at ticket time, so editing a company's registered domains can never silently re-file people who already exist. The Database Verify run that first adds users.tenant_id also performs a one-time back-fill from each existing requester's email domain โ€” only on that run, so a later deliberate choice is never overwritten.

Your own contact details

My Account also lets a requester maintain their own job title, office, phone and mobile, alongside their preferred name. Department, employee ID and manager are deliberately withheld โ€” nobody chooses their own approver, a payroll number is an identity claim, and department is the service desk's to maintain. Where a directory owns the record the four are shown but read-only, with a note saying to ask the IT team.

System โ†’ Portal profile decides which of the four the portal offers (all of them until somebody changes it). Its second setting, off by default, lets people who came from a CardDAV address book change theirs too: their change is sent to the address book first and saved in FreeITSM only once it is accepted, and the form tells them it goes there.

People imported from an address book can sign in to the portal with a password like anyone else - an address book is where their details come from, not a way to sign in. (Before 2.1.0 they were wrongly told to use single sign-on.)

Signing out asks first, in the same dialog the rest of FreeITSM uses.

Full treatment, including the endpoint's guards: Contact details and its developer guide.

Appearance

Each requester picks Light or Dark from My Account โ†’ Appearance. It applies immediately and is stored on their own account (users.theme_preference) โ€” analysts' preferences live in a table keyed by analyst id, which portal users don't have, so the portal needed its own.

The sign-in, register and confirmation pages are light only, deliberately: the palette lives on the user record, so before sign-in there is nothing to read it from.

Dashboard (index.php)

Four bands, top to bottom:

  1. Action buttons โ€” New ticket and Request. Moved out of the nav because they're actions, not destinations. The explanatory line is a tooltip, not body text: it was the only thing forcing these wider than a status card.
  2. Status cards โ€” one per active non-closed ticket status, plus a Total. Generated from your configured statuses, so the count varies per install; the grid is auto-fill so however many you have, they fit on one row at a readable size.
  3. Recent tickets and live system status, side by side. Status comes from Service Status, so someone can see their issue is a known outage before raising anything.
  4. Popular articles โ€” the six most-read articles published to customers, ordered by view_count. Clicking one opens it in help-centre.php?id=; there is deliberately no second article viewer.

The article fetch is not awaited with the rest of the dashboard. Knowledge is a nice extra โ€” a slow knowledge base must not hold up the tickets the page exists for.

All three grids (.portal-actions, .summary-cards, .article-grid) use auto-fill tracks so they line up down the page rather than each choosing its own column width.

Compose (new-ticket.php)

Laid out like writing an email, not like filling in a form:

  • Subject across the top
  • A full-height TinyMCE editor filling the page โ€” bold, italics, lists and links. The toolbar is deliberately short: what people need to describe a problem, not fonts and colours
  • A settings panel down the right for everything you merely set โ€” mailbox, priority, attachments, screen recording

Full height is a flex chain, not a calc(100vh - โ€ฆ) magic number โ€” that was the first attempt and it was wrong at every window size. Every ancestor in the chain needs min-height: 0, or a flex child refuses to shrink below its content.

โš ๏ธ The layout rule is body.portal-app .portal-layout, not bare .portal-layout. The bare selector loses on specificity to the base rule and the padding silently comes back.

Article deflection: as the subject is typed, matching articles appear beneath it โ€” the answer arrives before the ticket does. It only appears on a real match, never blocks Submit, and records nothing about what was typed. See Portal Help Centre.

Editor HTML is passed through sanitiseUserHtml() (includes/html_sanitise.php) server-side. <img> is deliberately not on the allow-list: a remote image in a customer-authored body would beacon the reading analyst's IP address and read time.

The ticket is filed against the requester's company; if they have none it goes to triage.

Which mailboxes the picker offers (#172). On a multi-company install: the mailboxes pinned to the requester's company, then shared intake (a mailbox with no company) โ€” the same rule as the analyst side's Send replies from picker (api/tickets/get_sendable_mailboxes.php). A requester with no company sees only shared intake, matching where their ticket goes. On a single-company install every active mailbox is offered, as before.

  • Re-checked on submit. create_ticket.php drops a mailbox_id the picker would not have offered (the ticket is still raised, with no mailbox), so a hand-posted id cannot put one client's ticket on another client's support address โ€” which is where the analyst's replies would then have come from.
  • Nothing to offer โ†’ no field. When the list is empty the field is hidden and stops being required; before, a required picker with no options would have blocked the form outright. Such a ticket has no mailbox, so analysts answer it in the portal rather than by email โ€” the same as on an install with no mailboxes at all.
  • Before 3.5.0 the portal listed every active mailbox to every requester.

This is confidential โ€” a switch in the settings panel for personal tickets (health, a complaint). A confidential ticket is kept from managers; the service desk still sees it. See Confidential tickets.

My Tickets (tickets.php)

Two panes, close to an email client: the list stays on screen on the left, the conversation loads on the right, and the reply box is docked at the bottom of the conversation.

  • A status filter dropdown built from the statuses actually present in that person's tickets โ€” not a fixed Open/Closed pair
  • The thread shows who said what, with the requester's own messages picked out. Internal notes are always hidden
  • Replying โ€” with attachments and screen recordings (the same recorder the compose screen uses; see Screen Recordings). A recording is enough on its own: "here, look at what happens" is a complete answer, so no covering sentence is required. The reply is filed on the ticket and appears in the analyst's inbox. No email goes back to the requester for their own words, so this works with no mailbox configured at all. A portal reply is stored with is_initial = 0 and inherits the thread's mailbox_id, so the analyst's next outbound reply still goes out from the address the requester has been talking to โ€” and because outbound replies quote the whole thread, a portal reply is carried into the analyst's next email automatically. A reply to a ticket the requester doesn't own โ€” or a deleted one โ€” gets the same flat not found as any other portal endpoint
  • Attach's explanatory line is the button's tooltip, not standing text โ€” same treatment as the dashboard action buttons
  • Attachments both ways โ€” files an analyst sent are downloadable; images and PDFs preview inline. Reply uploads use the same base64 payload shape and tickets/attachments/{floor(id/1000)}/{email_id}/ convention as inbound mail, so the analyst's existing get_attachment.php serves them unchanged. Downloads come from the portal's own get_attachment.php โ€” the analyst endpoint's twin, differing only in the authorisation question (did this requester raise this ticket, resolved through the attachment's email rather than trusting the URL). It serves nosniff โ€” images, audio, video and PDFs inline, everything else as a download โ€” with a realpath() containment check before reading, and inline attachments (embedded signature logos, pasted screenshots) are left out of the list so a spacer.gif doesn't appear under every message
  • Replying can reopen a finished ticket (Tickets โ†’ Settings โ†’ General)
  • ๐Ÿ‘ Who has seen this ticket โ€” a panel under the heading, shown by default: anyone other than the requester who has opened it in the portal (a manager, say), with when and how often. The service desk is never listed. seen_by from get_ticket_detail.php; see Who has seen a ticket
  • ๐Ÿ”’ Mark confidential at the top of a ticket makes it confidential; a padlock marks confidential tickets in the list. One way only โ€” api/self-service/mark_confidential.php can raise but never lower, because a requester could otherwise be talked into un-marking a grievance by the manager it is about. See Confidential tickets
  • ๐Ÿ‘ฅ Team tickets โ€” a tab shown only to portal managers: the tickets of the people they manage, each with a banner saying whose it is and whether they may reply or close. Every ticket endpoint answers through portalTicketAccess(), so a team ticket's attachments, documents and recordings follow the same rule, and a confidential one is hidden or a stub. ?view=team, api/self-service/get_team_tickets.php
  • Confirmations use the app-wide toast (assets/js/toast.js), the same one the analyst side uses
  • ๐Ÿ”’ A ticket can also hold correspondence that isn't the requester's โ€” forwards to suppliers, copied-in colleagues, their replies. Portal privacy decides how much of that they see, and defaults to hiding it

๐Ÿชค Two traps live in this file. Selection precedence. loadTickets() used to auto-select the first ticket un-awaited, racing the select that sendReply() does โ€” so sending a reply threw you back to your first ticket. Order is now ssSelected || window.PAGE.ticketId || first, and it is awaited. Class prefix. These classes are tk-, not ss-. user-menu.php emits its <style> block after the page's and owns the ss- prefix โ€” an .ss-msg there won the cascade and rendered the whole thread blank.

Screen Recordings

End users record their screen with no plugin, using the browser's native getDisplayMedia() + MediaRecorder. Offered in two places:

  • Raising a ticket (new-ticket.php) โ€” in the settings panel
  • Replying to one (tickets.php) โ€” beside Send and Attach

iOS Safari has no getDisplayMedia at all, so the button is hidden there rather than failing confusingly (ScreenRecorder.isSupported()).

๐Ÿ“ One component, two screens

Recording on reply needed the same ~250 lines as recording on create. Rather than copy them:

๐ŸŽจ File Role
๐ŸŽจ assets/js/screen-recorder.js The behaviour โ€” capture, MIME negotiation, countdown, preview, upload. Exposes ScreenRecorder.{init,open,close,isSupported,hasUnclaimed,formatDuration}
๐Ÿงฉ self-service/includes/record-modal.php The markup. The script binds to its element ids, so include it once per page
๐ŸŽจ assets/css/self-service.css .rec-modal and friends, plus .tk-recording for playback in a thread
โš™๏ธ includes/ticket_recordings.php Claiming a pending upload โ€” see below
๐Ÿงฉ self-service/includes/footer.php Loads the script when the page sets $needsRecorder = true

The seam between module and page is a single onClaimed callback: the module owns the modal and the capture, the page owns what a claimed recording attaches to (a new ticket, or a reply). Everything else is identical, so it lives in one place.

โš ๏ธ ScreenRecorder.init() is called at parse time by the page block, so the script must load before it โ€” which is why footer.php emits it above $pageScripts rather than the page loading it itself.

User flow

  1. Record screen โ€” a modal opens (.rec-modal). It was an inline panel; expanding it shoved the rest of the form around and left the video preview no room. The modal has its own Cancel
  2. Tick Include microphone audio to narrate (off by default for privacy). The mic is a separate browser permission โ€” denying it falls back to no-mic rather than aborting a recording they already picked a screen for
  3. Start โ†’ pick a tab / window / screen
  4. Demonstrate the issue (max 5 minutes, live countdown)
  5. Stop (or the browser's own Stop sharing bar)
  6. Preview inline, re-record if needed
  7. Use this to upload โ€” it appears as a ๐ŸŽฅ chip alongside any attached files
  8. Send

The two opposite behaviours, both deliberate

Action What happens to an unclaimed recording Why
Cancel / close the modal Discarded They explicitly said no; leaving something attached would be a surprise
Send / Submit Blocked, modal reopens They were heading onward and would have lost it silently

Forgetting Use this used to drop the recording without a word (it lived in JS memory and never reached the server). Both screens now share the guard via ScreenRecorder.hasUnclaimed().

Format negotiation

MediaRecorder.isTypeSupported() is probed in order: MP4/H.264 variants first, then WebM/VP9, VP8, plain WebM. Chrome/Edge 134+ produce real MP4 in-browser; Firefox and older Chrome fall back to WebM. The analyst's <video> plays either, so no server-side transcode is needed.

Storage and lifecycle

Files live on disk under recordings/{ticket_id}/ โ€” never as DB blobs; ticket_recordings holds metadata only. 50MB cap, MIME whitelist with a magic-byte sniff.

A recording must be uploaded before the thing it belongs to exists โ€” people record, watch it back, and re-record until happy, and every attempt has to leave the browser. So uploads are pending โ†’ claimed:

  1. Upload โ†’ recordings/pending/{uuid}.{ext}, row with ticket_id = NULL and recorded_by_user_id
  2. Held by the page as a list of ids
  3. Claimed when the ticket or the reply is written โ€” claimPendingRecordings() in includes/ticket_recordings.php, shared by create_ticket.php and reply_ticket.php

๐Ÿ”‘ THE GUARD, and why it lives in exactly one function. A row is claimable only while ticket_id IS NULL AND recorded_by_user_id is the same user. The first half stops a recording being lifted off an existing ticket; the second stops one user claiming another's pending upload. Recording ids are sequential and trivially guessable, so this is the only thing standing between them โ€” and it is applied in the query, so someone else's id simply selects nothing.

Claiming runs after the commit in both callers: file moves are not transactional, and a recording that fails to move must never cost someone the reply they just wrote. A move that fails still claims the row โ€” a broken recording attached to its ticket is visible and recoverable, whereas one orphaned in pending/ is never found again.

Orphaned pending rows sit there if a form is abandoned; a garbage-collect cron is a follow-up.

Which message a recording belongs to

ticket_recordings.email_id โ€” the message it was recorded with. NULL means the ticket's opening message, which is how every recording behaved before replies could carry one, so existing rows are already correct and there is no backfill.

The portal buckets recordings by email_id and renders each against its own message, so a video attached to reply #7 appears there rather than floating at the top of the thread as though it arrived with the original report.

The FK is ON DELETE SET NULL, not CASCADE: if a message is ever removed the video should fall back to the ticket, not be destroyed with it.

โš ๏ธ The analyst reading pane (api/tickets/get_email_detail.php โ†’ buildRecordingsStrip() in inbox.js) still returns every recording on the ticket for every message, ignoring email_id. Nothing is hidden from analysts โ€” the safe direction โ€” but a reply's video shows on the original message too. Making it precise is a small follow-up.

Streaming + auth

get_recording.php does dual auth (an analyst, or the ticket's owner) and supports HTTP Range, so <video> can seek without downloading the whole file. Direct access to recordings/ is blocked by .htaccess; everything goes through the PHP endpoint.

Knowledge, catalogue and privacy

Each has its own page โ€” see the table at the top.

Training (training.php, course.php)

Portal users can be given LMS courses and take them here, with the same player and the same marking the analyst side uses. Outstanding training also appears on the dashboard, above the ticket counts.

The Training nav item is shown only to somebody who actually has a course โ€” the first use of the nav's cap mechanism. Full detail in Training for Portal Users.

๐Ÿ”ด The analyst app and the portal share one PHP session, so anybody signed into both has two identities at once. Every LMS call from the portal carries ?as=portal for exactly that reason. See the developer guide.

Help (help.php)

The portal's own end-user guide โ€” deliberately separate from the analyst-facing Tickets help page, and separate from Knowledge (which is your knowledge base, not instructions for the portal).

Laid out like every other help page in FreeITSM: full width, an On this page contents list down the side, and the main panel using the full width of its column. Nine sections:

  1. Welcome โ€” what the portal is for
  2. Signing in โ€” the three ways an account comes to exist (self-register, claiming an account created by an inbound email, IT pre-creating one), all confirmed by email, plus the forgotten-password flow
  3. Finding an answer yourself โ€” searching Knowledge, and the articles offered while you type a subject
  4. Requesting something โ€” the request catalogue
  5. Raising a ticket โ€” mailbox, subject, priority, description, formatting, attachments
  6. Recording your screen โ€” the walkthrough including the Use this / Discard guard and the iOS Safari gap
  7. Viewing your tickets โ€” the two-pane view, the status filter, replying
  8. Account & security โ€” preferred name, password, MFA, and a heads-up that a CSAT survey may arrive on close
  9. Tips for a fast resolution โ€” one issue per ticket, reproduction steps, what you've already tried

End-user tone throughout, no analyst concepts.

โš ๏ธ help.php builds its section list before header.php boots i18n. Store translation keys in that array and resolve them with t() inside the render loop โ€” calling t() while building the array is a fatal.

User avatar menu and My Account

An initials circle in the header opens a dropdown: My Account, MFA setup, password change, sign out โ€” plus Analyst console for a requester whose email also belongs to an active analyst. That opens auth/login.php in a new tab, which goes straight on to the console when they are already signed in as an analyst. It used to open /, which honours the install's front-door setting and so reopened the portal on an install whose front door is the portal (#169). My Account holds:

  • Preferred name โ€” "Ed" rather than "Ed Mozley", used when the system greets them
  • Password change โ€” validates the current one, minimum 8 characters
  • MFA โ€” enable/disable TOTP
  • Appearance โ€” Light or Dark (see above)

Key tables

Table Purpose
users Requesters โ€” shared with Tickets. password_hash NULL = auto-created by inbound email and not yet claimed. Also carries tenant_id (their company) and theme_preference (their palette)
user_verification_tokens Pending email-confirmed registrations: hashed token (SHA-256), hashed password, 24-hour single use
user_sso_identities Requester โ†” identity-provider links (the mirror of analyst_sso_identities)
ticket_recordings Screen recording metadata; files on disk under recordings/{ticket_id}/. email_id says which message it came with (NULL = the opening one); ticket_id IS NULL means still pending
tickets / emails The portal writes tickets and messages here like any other channel โ€” a portal reply is an emails row with direction = 'Portal'
form_submissions Catalogue submissions. submitted_by_user_id is the requester (separate from submitted_by, which is an analyst)
knowledge_articles Knowledge reads these at the customer audience level; view_count orders the dashboard's popular list

See also

โš ๏ธ **GitHub.com Fallback** โš ๏ธ