تیکتینگ — سامانه پشتیبانی کیمیا (Kimia Support System): a lightweight ticketing and project management app built with Laravel 12, Blade and Bootstrap 5.
- Two languages: Persian (default) and English — PHP translation files in
lang/faandlang/en. - Two themes that follow the language: RTL theme for Persian, LTR theme for English.
- Per-user calendar (Jalali or Gregorian), independent of the language.
- Three roles: admin, developer, customer. The app is a tenant system of developers: the admin is a supervisor — manages users and projects and reads all tickets, sprints and transactions, but never writes tickets, followups, sprints or payments (no New ticket button, no bot login).
- Tickets with status, priority (Jira colours), type, sprint, story points, estimates, time logged, costs, due date, rich-text content (TinyMCE with inline image upload) and attachments (size limit per file set by the admin on Settings, 30 MB by default). On desktop the ticket form has a second Save button under Planning and estimates (no long scroll to save a cost); on mobile the columns stack, so there is only the one at the bottom.
- Delivered (تحویل شده) status comes after Done: the work is handed over (old / archived / paid tickets), so
Done keeps only the new finished work. Delivered counts as Done everywhere (
TicketStatus::isDone()/doneValues()): costs on the transactions page, bills, closed (rating, no customer reply), dashboard Done card and sprint progress. Done → Delivered keeps the date it was done (resolved_at). It sends no SMS. - Full ticket history (every edit is saved as a revision) and soft delete. Customers do not see the history of price, time and story points (cost, estimated cost, estimated / logged time, story points, done story points); an edit with only those changes is hidden from them. Status, sprint, assignee and the rest stay visible.
- Followups: a ticket is a conversation. Staff and customers reply (rich text + attachments) below the ticket. The ticket then waits for a reply from the other side: a red badge next to each folder's badge, a Waiting for my reply folder, a toast after login and an I read it button. A staff reply has a checkbox (on by default) to decide if the customer must answer. Customers cannot reply to closed tickets.
- Rating: on a closed ticket (done, delivered, cancelled, rejected) a customer gives 1–5 stars and an optional comment. One rating per ticket; staff can only read it.
- Sprints per project, with a Days column (end − start + 1, weekends included: 5th → 12th = 8 days). Dates of two sprints of the same project should not overlap (the next sprint starts the day after the previous one ends; gaps are fine). This is only a warning: the overlapping dates are red on the sprints page (end of the earlier sprint and start of the later one), and saving shows a warning. On New sprint, the number and the start date are filled for the chosen project: start = latest end date of its sprints + 1 day (weekends included; empty when no sprint has an end date). Changing the project fills them again, but a value typed by hand stays.
- Top-bar project: every page follows the project picked in the top bar — tickets, sprints, dashboard, transactions, bills and payment vouchers. Filter lists show only that project's things (sprints, people, customers). Payments without a project of the project's customers are shown too. All projects shows everything.
- Ticket folders ("cartables") with badges, one search page with many filters, and custom menus.
More filters opens the rest of the filters; the button then says Fewer filters. A cost filter is a listbox:
All, Undefined (no cost,
cost=none), Defined (any cost,0too,cost=any) or Range — only Range shows From / To (both ends included; only From = cost ≥ From, only To = cost ≤ To).0is a real cost, not "undefined": a ticket without a cost is never in a range. A small ✎ next to Tickets opens a modal to reorder the built-in folders (drag or ↑ ↓) and show / hide each one, saved per user, with Reset to default. Default = the owner's choice (TicketMenus::DEFAULT_ORDER/DEFAULT_HIDDEN): All first; Delivered right after Done (before Current sprint); Open, Testing, Cancelled, Rejected, Assigned to me, Unassigned, Reported by me hidden. Custom menus are not in it. - Current sprint folder (اسپرینت حاضر), also an option of the sprint filter: tickets in any status of every sprint whose dates include today (both ends count). Overlapping sprints → tickets of all of them. The sprint status is not used. A current sprint is green everywhere (tickets grid and page, sprints page and project page rows, dashboard, sprint dropdowns).
- Bulk actions (developers): check tickets on the current page (the top checkbox checks the whole page), then change status, sprint, assignee, priority, type or cost (one fixed cost for many small tickets; an empty box removes the cost, 0 is a real cost of 0), or delete them in one step. Every change goes into the history. Only delete asks for confirmation.
- Row actions (developers): an Actions column (last, hidden by default — tick it in the column chooser) with a
small coloured icon per action, close together (name in the tooltip): set cost (empty = no cost, 0 = 0), set sprint
(sprints of the ticket's project) and change status (same as the bulk status change: history, Done SMS; when the page shows only some statuses — e.g. the Done
folder — and the new status is not one of them, the page reloads, so the ticket leaves the list). Each opens a small modal and saves by AJAX (same
tickets.bulkroute, JSON answer): the row's cost / sprint / status and the totals row change without a reload. New actions = a button, a field in the modal, a key inROW_ACTIONS. - Every grid has a column chooser; the choice is saved on the server. The tickets grid keeps one choice per
folder (e.g. Backlog without cost, Done with cost). Some folders have default columns
(
TicketController::FOLDER_COLUMNS, the owner's choice); other folders without the user's own choice use the search page choice. Grids with money orHH:MMcolumns have a totals row (sums of all filtered records, not only the current page). - Transactions: accepted customer payments; each done ticket with a cost is shown as a
cost next to them. Totals (payments, costs, remaining) and a per-project summary. Customers see theirs read-only.
Projects are picked in a listbox with check marks (
<x-multi-select>, a search box when the list is long). - Bills (صورتحسابها): a developer issues a bill to a customer of a project. Items are done tickets that are not
on another bill — with or without a cost (a ticket without a cost is listed with amount 0, and a manual item
such as "Cost of phase 1" carries the price of those tickets) — and/or manual items (title, amount, details —
e.g. monthly support) that are saved as done tickets of the project, so they are costs on the transactions page too.
The body of such a ticket has the details (if any) and a relative link to the bill (
/bills/1001, no domain, so it still works if the domain changes). A sprint picker ticks all done tickets of a sprint at once. The ticket list has pages (25 per page) and a search box; ticks are kept across pages ("Select all" ticks every ticket of the search). Bill number starts at 1001. Checkboxes tell the customer by SMS (template 24562,[param1]= bill number) and/or a colourful email. Paid / partly paid / unpaid: the accepted payments of a customer pay their bills oldest first. - Payment vouchers (no payment gateway yet): the customer pays to the developer's card number / IBAN (set in the developer's profile) and registers the voucher: amount (default = the whole debt now, can be less or more), pay date, pay time, tracking number, optional bill. It is pending until a developer accepts it (or declines, edits, deletes it). Developers can also register a payment for the customer (accepted at once). Only accepted payments count on the transactions page.
- SMS through msgway.com (template based). Every SMS is written to an outbox
(
sms_messages) and sent by the queue worker, so a slow or broken gateway never slows down the site. Failed sends are retried after 1, 5, 10 and 30 minutes, then marked failed; OTP codes only once after 1 minute.- customer creates a ticket or writes a followup → every developer of the project (template 24564: project name, ticket number);
- developer reply that waits for the customer → the customer (template 24561: ticket number);
- a ticket's status changes to Done (any way: edit, status button, bulk) → the customer (template 24610:
[param1]= ticket number; not again for Delivered). A bulk change to Done sends one SMS per customer with all ticket numbers separated by commas (1001,1002). - The text of each template is in the
message_templatestable (read from msgway'stemplate/get;php artisan sms:templatesreads them again, a new template is read after its first send). - An SMS to a customer is also a toast: the developer whose action sends it sees SMS to + the text, and the customer (when online at that moment) sees the text. SMS to staff and OTP codes make no toast.
- Toasts (like Filament notifications): a card at the top corner with an icon, a title, a text, an optional link and a bar
for the time left (it stops on hover). The messages after an action (Saved, warnings) are toasts too; form errors stay
above the form. The server saves them per user (
Toast::to()); a page shows them on load and asks for new ones every 5 seconds. A toast not shown in 2 minutes is dropped. JavaScript:window.toast({...}). - Forgot password: one box takes the email or the mobile number (an unknown one shows "not found").
Email → a reset link (60 minutes) from
no-reply@peppasoft.com. Mobile → a 6-digit code by SMS (template 3), valid 2 minutes; after a 1:50 countdown the user can ask again: the 2nd request sends the same code by SMS, the 3rd one by a voice call. Limits: 5 requests per email / mobile in 12 h, 10 per IP in 24 h. - Public profile pictures: when an admin or developer saves a user (or staff save their own profile) and the
user has an email but no picture, the app looks for a public one on Gravatar, then Libravatar, then
unavatar.io (free, no key). The first picture found is saved; after 10 seconds it gives up. Not after "remove photo",
and not when a customer saves their own profile.
AVATAR_LOOKUP=falseturns it off (tests). - Light, colourful themes: soft gradients for backgrounds and cards, solid colours for buttons.
- Money is always a whole number in every currency:
7,000,000, never7,000,000.00. - Latin / numeric fields (email, mobile, password, URL, code, amounts, times, dates) are always LTR, also in the RTL theme.
- Bot API (JSON) for AI bots: create, search / list and read tickets. A developer logs in the bot with a one-time link (no password in the chat) and links the bot to one project; tokens last 30 days. See API.md.
- Full access API (JSON,
/api/full) for scripts and AI assistants of a person: one key (made and rotated in Profile → Full access API key) reaches all the user's projects. Create / read / update / delete tickets, create / read / update projects. Unlike a bot token, no project is locked:project(id, code or name) is sent in the payload. See FULL-API.md.
Design and phases: PLAN.md · PHASES.md · Bot API: API.md · Full access API: FULL-API.md
- PHP 8.3+ with
pdo_mysql(orpdo_sqlite),mbstring,intl,gd,fileinfo,zip - Composer 2, Node 20+
- MariaDB 10.6+ / MySQL 8 (SQLite works for local development)
composer install
cp .env.example .env
php artisan key:generate
# set DB_* in .env (default is SQLite: database/database.sqlite)
php artisan migrate --seed # SEED_DEMO=true adds demo data (default in local env)
php artisan storage:link
npm install
npm run build # also copies TinyMCE into public/vendor/tinymce
php artisan serveDefault login: admin@ticketing.local / the SEED_PASSWORD value (password if not set).
Demo users (when demo data is seeded): dev1@, dev2@, customer1@, customer2@, customer3@ticketing.local.
You can sign in with the email or the mobile number. The password field has an eye button to show / hide the password (the open eye blinks, looks around and follows the mouse).
php artisan test| Topic | Where |
|---|---|
| Roles and access rules | app/Policies, app/Models/User.php (scopeCustomersOf, accessibleProjectIds) |
| Top-bar project switcher | app/Support/ProjectContext.php (scopeIds(); payments: Payment::scopeInProjects()) |
| Listbox with check marks | resources/views/components/multi-select.blade.php, initMultiSelects() in resources/js/app.js |
| Ticket filters, folders, badges | app/Support/TicketFilter.php, app/Support/TicketMenus.php |
| Current sprint (by dates), green marker | Sprint::scopeCurrent(), Sprint::isCurrent(), resources/views/components/sprint.blade.php (<x-sprint>) |
| New sprint defaults (number, start date per project) | SprintController::newSprintDefaults(), initSprintForm() in resources/js/app.js |
| Order / show-hide of built-in folders | TicketMenus::applySettings(), FolderSettingsController, users.folder_settings, resources/views/partials/folders-modal.blade.php, initFolders() in resources/js/app.js |
| Site settings (admin), attachment size limit | app/Models/Setting.php (get() / put(), cached), settings table, Admin\SettingController, app/Support/Uploads.php (maxMb(), serverMaxMb(), maxRule()) |
| Bulk actions on tickets | TicketController::bulk(), bulk bar in resources/views/tickets/index.blade.php, initBulk() in resources/js/app.js |
| Row actions (Actions column, modal + AJAX) | TicketController::ROW_ACTIONS, JSON answer of bulk(), #row-action-modal in tickets/index.blade.php, initRowActions() in app.js |
| Followups, "waiting for reply", I read it | app/Http/Controllers/FollowupController.php, TicketService::addFollowup(), tickets.awaiting_reply |
| Rating (stars) of closed tickets | app/Http/Controllers/CommentController.php, ticket_comments table, TicketPolicy::comment() |
| SMS (msgway client, outbox, queue job, retries) | app/Sms/MsgwayClient.php, app/Sms/Sms.php (Sms::send()), app/Jobs/SendSms.php, sms_messages table, template IDs in config/sms.php |
| Toasts (server + JS, polling) | app/Support/Toast.php (to(), forPage(), pull()), user_toasts table, ToastController (GET /toasts), initToasts() / window.toast() in app.js, .app-toast in app.css |
| SMS template texts, SMS toasts | message_templates table, MessageTemplate::render(), MsgwayClient::template(), sms:templates command in routes/console.php, Sms::send(..., to:) |
| SMS about tickets | app/Listeners/SendTicketSms.php (on TicketCreated, FollowupPosted and TicketDone; TicketService::batch() groups the Done tickets of a bulk action) |
| Forgot password (email link + SMS code / call) | app/Http/Controllers/PasswordResetController.php, password_otps table, app/Mail/PasswordResetLink.php, resources/views/auth/{forgot,otp,new-password}.blade.php, initCountdown() in app.js |
| Bills, paid status | app/Http/Controllers/BillController.php, app/Models/Bill.php, BillItem.php, app/Support/Bills.php (allocate(), debtOf()), app/Mail/BillIssued.php, initBillForm() in app.js |
| Payment vouchers (pending / accepted / declined) | app/Http/Controllers/PaymentController.php, app/Policies/PaymentPolicy.php, resources/views/payments/* |
| Public profile pictures by email | app/Support/PublicAvatar.php, findPublicAvatar() in Concerns/SavesAvatar.php, ProfileController::update() |
| Mail layout (colourful, inline styles) | resources/views/mail/* |
| History and soft delete | app/Services/TicketService.php, ticket_revisions table; customer view: RevisionPresenter::STAFF_ONLY / visibleChanges() |
Ticket number (id × 100 + 2 random digits) |
app/Models/Ticket.php |
| Jalali / Gregorian dates | app/Support/Dates.php, resources/views/components/date-input.blade.php |
| Grid column chooser + totals row | app/Support/Grid.php, resources/views/components/grid-columns.blade.php, resources/views/partials/grid-totals.blade.php |
| Transactions (payments + ticket costs) | app/Support/Transactions.php, app/Http/Controllers/TransactionController.php, PaymentController.php, app/Policies/PaymentPolicy.php |
| Themes | resources/css/theme-rtl.css, resources/css/theme-ltr.css, shared app.css |
| Bot API (login link, tokens, tickets JSON) | routes/api.php, app/Http/Controllers/Api/*, app/Http/Controllers/ApiAuthController.php (web page of the link), app/Http/Middleware/AuthenticateApiToken.php, app/Models/ApiToken.php, ApiAuthRequest.php — docs in API.md |
Full access API (key, /api/full) |
routes/api.php (full group), app/Http/Controllers/Api/Full/* (extends the bot TicketController), app/Http/Middleware/AuthenticateFullApiKey.php, app/Models/FullApiKey.php, app/Support/ProjectLookup.php, ProfileController::rotateFullKey() — docs in FULL-API.md |
The app runs on two servers with the same code. Build once, then copy the same archive to both.
| Server | URL | Notes |
|---|---|---|
| deb10 (LAN) | http://ticketing.localkimia.com |
hosts file → 192.168.1.10, PHP 8.4 |
| waybill VPS | https://ticketing.kimiasoft.ir |
130.185.76.10 behind ArvanCloud, Apache mod_php |
Served at http://ticketing.localkimia.com (LAN, hosts file → 192.168.1.10).
- Code:
/var/www/ticketing, ownerwww-data,.envischmod 600and only on the server. - nginx vhost: deploy/nginx-ticketing.conf (raises PHP upload limits for this site only).
- Database: MariaDB
ticketing.
Background jobs (SMS and mail) need the queue worker: systemd unit
deploy/ticketing-queue.service (systemctl enable --now ticketing-queue).
deb10 has no MSGWAY_API_KEY (SMS rows end as failed with "not set") and MAIL_MAILER=log, so it never texts or mails real people.
Update steps:
npm run build
tar czf /tmp/ticketing.tgz --exclude=.git --exclude=node_modules --exclude=./vendor --exclude='*.md' --exclude=reference-docs \
--exclude=storage/logs --exclude=.env --exclude='*.sqlite' --exclude='bootstrap/cache/*.php' \
--exclude=public/storage --exclude=public/hot --exclude='storage/app/public/*' \
--exclude='storage/app/private/*' --exclude='storage/framework/sessions/*' --exclude='storage/framework/views/*' .
scp /tmp/ticketing.tgz deb10:/tmp/
ssh deb10 'cd /var/www/ticketing && tar xzf /tmp/ticketing.tgz \
&& composer install --no-dev --optimize-autoloader \
&& php artisan migrate --force && php artisan optimize \
&& chown -R www-data:www-data . && systemctl reload php8.4-fpm && php artisan queue:restart'- Public DNS → ArvanCloud CDN → origin
130.185.76.10(SSH:ssh waybill, port 38010). - Apache mod_php 8.4 serves
public/directly (no PHP-FPM, no systemd unit, no port). Vhost: deploy/apache-ticketing.kimiasoft.ir.conf — both:80and:443(self-signed origin cert in/etc/ssl/{certs,private}/ticketing.kimiasoft.ir.*, the CDN does not verify it). - Code:
/var/www/ticketing, ownerwww-data,.envchmod 600. MariaDB database and userticketing. .envhasTRUSTED_PROXIES=*(real visitor IP for the login rate limit) andSESSION_SECURE_COOKIE=true; anhttps://APP_URLmakes every generated URL https.- No demo data. Admin
admin@ticketing.local; password inE:wwwmyLan�edentials.md. - Queue worker: systemd unit
ticketing-queue(same file as deb10). .envhasMSGWAY_API_KEY(msgway SMS) and mail through ger1 Postfix:MAIL_MAILER=smtp,MAIL_HOST=smtp.peppasoft.com,MAIL_PORT=25,MAIL_FROM_ADDRESS=no-reply@peppasoft.com. ger1 trusts the waybill IP130.185.76.10(Postfixmynetworksand OpenDKIMInternalHosts, so the mail is DKIM-signed forpeppasoft.com); the Hetzner cloud firewall allows TCP 25 / 587 from that IP.
Update steps (same archive as deb10; mod_php needs no reload):
scp /tmp/ticketing.tgz waybill:/tmp/
ssh waybill 'cd /var/www/ticketing && mariadb-dump ticketing | gzip > /root/ticketing-db-$(date +%F-%H%M).sql.gz \
&& tar xzf /tmp/ticketing.tgz && COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --optimize-autoloader \
&& php artisan migrate --force && php artisan optimize && chown -R www-data:www-data . && php artisan queue:restart'