Skip to content
fatehimanPublic

About

Lightweight ticketing & project management (Laravel 12, fa/en, RTL/LTR)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Ticketing

تیکتینگ — سامانه پشتیبانی کیمیا (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/fa and lang/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, 0 too, cost=any) or Range — only Range shows From / To (both ends included; only From = cost ≥ From, only To = cost ≤ To). 0 is 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.bulk route, 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 in ROW_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 or HH:MM columns 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_templates table (read from msgway's template/get; php artisan sms:templates reads 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=false turns 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, never 7,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

Requirements

  • PHP 8.3+ with pdo_mysql (or pdo_sqlite), mbstring, intl, gd, fileinfo, zip
  • Composer 2, Node 20+
  • MariaDB 10.6+ / MySQL 8 (SQLite works for local development)

Local setup

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 serve

Default 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).

Tests

php artisan test

How it works (short)

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

Deployment

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

deb10

Served at http://ticketing.localkimia.com (LAN, hosts file → 192.168.1.10).

  • Code: /var/www/ticketing, owner www-data, .env is chmod 600 and 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'

waybill (ticketing.kimiasoft.ir)

  • 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 :80 and :443 (self-signed origin cert in /etc/ssl/{certs,private}/ticketing.kimiasoft.ir.*, the CDN does not verify it).
  • Code: /var/www/ticketing, owner www-data, .env chmod 600. MariaDB database and user ticketing.
  • .env has TRUSTED_PROXIES=* (real visitor IP for the login rate limit) and SESSION_SECURE_COOKIE=true; an https:// APP_URL makes every generated URL https.
  • No demo data. Admin admin@ticketing.local; password in E:wwwmyLan�edentials.md.
  • Queue worker: systemd unit ticketing-queue (same file as deb10).
  • .env has MSGWAY_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 IP 130.185.76.10 (Postfix mynetworks and OpenDKIM InternalHosts, so the mail is DKIM-signed for peppasoft.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'

About

Lightweight ticketing & project management (Laravel 12, fa/en, RTL/LTR)

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages