v2.0.0
Build and push image / build (push) Failing after 0s

Zmiana struktury repozytorium (łamiąca dotychczasowy sposób wdrożenia):
- Aplikacja Laravel przeniesiona z src/ do katalogu głównego.
- Pliki wdrożeniowe w docker/: Dockerfile, compose.yaml(.example), .env
  dla Compose oraz dane bazy (docker/mysql/). Compose uruchamia się z
  docker/ albo z -f docker/compose.yaml; nazwa projektu przypięta w pliku,
  więc nazwy kontenerów się nie zmieniają. Katalog docker/ jest zasłonięty
  przed kontenerami PHP (tmpfs).
- Dokumentacja techniczna w dokumentacja/, przewodniki użytkownika w wiki/,
  README.md zostaje w katalogu głównym, CLAUDE.md w .claude/.
- CI buduje obraz z docker/Dockerfile.
- Wersja pokazywana w Admin > O aplikacji jest teraz w config/app.php
  (wcześniej w nieśledzonym .env, przez co zatrzymała się na 1.4.0).

Co nowego:
- Eksport / import ustawień (Admin > Eksport / import oraz
  php artisan settings:export / settings:import): cała konfiguracja —
  ustawienia i integracje, logo/favicon, użytkownicy z rolami, zespoły,
  kategorie, pola dodatkowe, statusy, priorytety/SLA, szablony, wyzwalacze,
  automatyzacje, skrzynki IMAP, klucze API — w jednym pliku JSON, bez
  zgłoszeń. Import tylko do instalacji bez zgłoszeń.
- Import z Heska: tryb --sync-updates dociąga odpowiedzi, notatki i zmiany
  statusu dodane w Hesku po imporcie do już zaimportowanych zgłoszeń.
- Komunikat na stronie logowania można wyłączyć bez kasowania treści;
  edytor dostał wybór rozmiaru tekstu.

Zmiany:
- Komunikat logowania ma zawsze jeden kolor info (nowy token --color-info,
  półprzezroczyste tło); wybór Informacja/Ostrzeżenie/Sukces/Ważne usunięty.

Poprawki:
- Nagłówki ustawione w edytorze komunikatu logowania nie były większe od
  zwykłego tekstu na stronie logowania; nad i pod tekstem komunikatu była
  pusta linia.
- Limit pamięci dla testów podniesiony w phpunit.xml (zestaw przekraczał
  domyślne 128 MB).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rz61PznDTKUACA7sVTB8H3
This commit is contained in:
kzbkowskiandClaude Opus 5.5 committed 2026-10-01 13:06:56 +02:00
1 parent 0943829331
commit efff0ddfa0
360 files changed
+1521 -366

No files matched your search

+55 -14
View File
@@ -1,8 +1,38 @@
# CLAUDE.md
Guidance for AI coding agents working in this repo. See [README.md](README.md)
for the feature/tech overview, [ARCHITECTURE.md](ARCHITECTURE.md) for how the app
is put together, and [install.md](install.md) for full deployment instructions.
Guidance for AI coding agents working in this repo. See [README.md](../README.md)
for the feature/tech overview, [ARCHITECTURE.md](../dokumentacja/ARCHITECTURE.md) for how the app
is put together, and [install.md](../dokumentacja/install.md) for full deployment instructions.
## Repository layout (since 2.0.0)
The Laravel app **is the repo root** (`app/`, `resources/`, `artisan`, the
Laravel `.env`, … — there is no `src/` anymore). Around it:
- `docker/` — `Dockerfile`, `compose.yaml` (untracked; `compose.yaml.example` is
the tracked copy — keep the two in sync), the compose-level `.env`, and
`mysql/` (MariaDB data dir, untracked).
- `dokumentacja/` — technical docs (ARCHITECTURE, install, TESTING,
CONTRIBUTING, SECURITY, CHANGELOG). `wiki/` — per-role user guides.
`README.md` stays in the root.
- `.claude/` — this file. `scripts/hesk-import/` — Hesk import wrapper.
Compose commands need the file path when run from the repo root:
`sudo docker compose -f docker/compose.yaml <command>`. The project name is
pinned in the file (`name: servicedesk`), so container names are unchanged
(`servicedesk-app-1`, …) and `sudo docker exec servicedesk-app-1 ...` works
from anywhere.
Inside the PHP containers the whole repo root is mounted at `/var/www/html`,
with an empty tmpfs over `/var/www/html/docker` so they can't see the
database files or `docker/.env`. Don't expect to read anything under
`docker/` from inside a container.
On this host new files under the repo inherit world-writable permissions from
the NFS export. MariaDB ignores a world-writable `docker/mysql/.my-healthcheck.cnf`,
which makes its healthcheck fail and blocks every dependent container — if
the data dir is ever recreated, `sudo chmod 600` that file (and `chmod 750
docker/mysql`) before `up -d`.
## This is a live production system
@@ -21,7 +51,7 @@ roles). Treat the running database as production, not a sandbox:
## Container operations: use `sudo`, never build the image locally
All Docker commands against this stack need `sudo` (e.g.
`sudo docker compose exec app ...`, `sudo docker exec servicedesk-app-1 ...`).
`sudo docker compose -f docker/compose.yaml exec app ...`, `sudo docker exec servicedesk-app-1 ...`).
The stack is four services sharing the one `servicedesk` image — `app` (Apache,
what actually serves HTTP), `reverb` (websocket server, `php artisan
@@ -30,11 +60,12 @@ below), and `mariadb`. Only `app` and `reverb` are reachable from Traefik.
**Never run `docker build`, `docker compose build`, or `--build`.** The
`servicedesk` image is built by CI (`.gitea/workflows/build.yml`, triggered on
`Dockerfile` changes on `main`) and pushed to the Gitea container registry;
`compose.yaml` only ever `pull`s a tag (`sudo docker compose pull && sudo docker
compose up -d`, see [install.md](install.md) 1.3/1.3a) — building locally would
`docker/Dockerfile` changes on `main`) and pushed to the Gitea container registry;
`docker/compose.yaml` only ever `pull`s a tag (`sudo docker compose -f
docker/compose.yaml pull && sudo docker compose -f docker/compose.yaml up -d`,
see [install.md](../dokumentacja/install.md) 1.3/1.3a) — building locally would
just diverge from what CI produces. The app container
(`servicedesk-app-1`) mounts `./src` from the host over NFS
(`servicedesk-app-1`) mounts the repo root from the host over NFS
(`/mnt/rabbit-containers` → NFS export), so plain file edits already take effect
with no rebuild or restart:
@@ -44,7 +75,7 @@ with no rebuild or restart:
host nor the app container has Node installed; rebuild with a throwaway
container instead of touching the app image:
```bash
sudo docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm run build
sudo docker run --rm -v "$(pwd)":/app -w /app node:22 npm run build
```
(run from the repo root; `node_modules` already exists, no install needed).
Verify via `public/build/manifest.json` picking up a new hash.
@@ -67,19 +98,19 @@ with no rebuild or restart:
## Scheduled commands run in the dedicated `cron` container
The Docker image ships no cron/supervisor of its own (see [install.md](install.md)),
The Docker image ships no cron/supervisor of its own (see [install.md](../dokumentacja/install.md)),
so `tickets:check-sla-breaches`, `automation:run-rules`, `emails:fetch-imap`,
and `ai:run-ticket-automation` (all registered in `routes/console.php` via
`Schedule::command(...)`) only ever run if something calls `php artisan
schedule:run` on a timer. **As of 2026-08-04 this is the `cron` service** in
`compose.yaml` — same `servicedesk` image, running `php artisan schedule:work`
`docker/compose.yaml` — same `servicedesk` image, running `php artisan schedule:work`
(Laravel's own foreground scheduler loop, ticks every minute internally, no
external trigger needed). Before this it was a root crontab entry on the host
calling `docker compose exec -T servicedesk schedule:run`; that entry has been
removed from `sudo crontab -l -u root` now that the container replaces it —
don't re-add it, the two would double-run every scheduled command.
If the `cron` container isn't running (`sudo docker compose ps cron`), none of
If the `cron` container isn't running (`sudo docker compose -f docker/compose.yaml ps cron`), none of
the four scheduled commands fire — same failure mode as the old missing-crontab
case, just check the container instead of the crontab. IMAP-specific activity
(connect attempts, per-message accept/reject decisions, created/replied ticket
@@ -137,7 +168,7 @@ reason — never add a `public/icons/` directory.
`'created'`) and both tabs of the client dashboard
(`App\Livewire\Client\Dashboard::baseQuery()`, `orderByDesc('created_at')`).
Deliberately not `updated_at` — see the timer/`updated_at` note in
[ARCHITECTURE.md](ARCHITECTURE.md#data-model); even with that fixed, sorting
[ARCHITECTURE.md](../dokumentacja/ARCHITECTURE.md#data-model); even with that fixed, sorting
by "last touched" is a worse default for a support queue than a stable
creation order. Keep any new ticket list consistent with this rather than
defaulting to `updated_at`.
@@ -152,6 +183,16 @@ reason — never add a `public/icons/` directory.
## Testing & code style
See [TESTING.md](TESTING.md) and [CONTRIBUTING.md](CONTRIBUTING.md) — run
See [TESTING.md](../dokumentacja/TESTING.md) and [CONTRIBUTING.md](../dokumentacja/CONTRIBUTING.md) — run
`php artisan test` and `./vendor/bin/pint` before considering a change done.
There is no CI; local test runs are the only gate.
## Releasing a version
A release is one commit titled `vX.Y.Z` (Polish summary in the body — see
`git log`), tagged `vX.Y.Z`, pushed to the `Gitea` remote. In that same commit:
- bump `'version'` in `config/app.php` — it's what Admin > O aplikacji shows,
and it is hardcoded there (not in `.env`) precisely so it can't be forgotten;
- turn the `[Unreleased]` section of `dokumentacja/CHANGELOG.md` into
`[X.Y.Z] - <date>`.
View File
File renamed without changes.
+82 -13
View File
@@ -1,16 +1,85 @@
# Copy to .env (gitignored) — this is the Docker Compose–level env file, read by
# compose.yaml only. It is separate from src/.env, which configures the Laravel
# app itself (see src/.env.example and install.md).
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
HOSTNAME=servicedesk.twoja-domena.pl
AUTHOR_CONTACT=helpdesk@kzbikowski.pl
# Database
MYSQL_ROOT_PASSWORD=wygeneruj-silne-haslo
MYSQL_DATABASE=servicedesk
MYSQL_USER=servicedesk
MYSQL_PASSWORD=wygeneruj-inne-silne-haslo
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
# Which image tag to deploy (built by CI, see .gitea/workflows/build.yml).
# Defaults to "latest" if unset — pin to a specific commit SHA tag to control
# exactly when this host picks up a new image.
# IMAGE_TAG=latest
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
# Reverb (real-time broadcasting) — set BROADCAST_CONNECTION=reverb to enable.
# REVERB_HOST/PORT/SCHEME are server-to-server (this container calling the
# "reverb" compose service over the internal docker network) — REVERB_HOST
# should be the compose service name ("reverb"), not a public hostname.
# VITE_REVERB_* is what gets baked into the built frontend bundle and is what
# the browser connects to — same public hostname/TLS as the rest of the app.
REVERB_APP_ID=wygeneruj-losowy-id
REVERB_APP_KEY=wygeneruj-losowy-klucz
REVERB_APP_SECRET=wygeneruj-losowy-sekret
REVERB_HOST=reverb
REVERB_PORT=8080
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST=servicedesk.twoja-domena.pl
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
File renamed without changes.
+4 -4
View File
@@ -1,13 +1,13 @@
name: Build and push image
# The app image only provides PHP/Apache/extensions — application code is bind-
# mounted from ./src at deploy time (see compose.yaml), so this only needs to
# mounted from the repo root at deploy time (see docker/compose.yaml), so this only needs to
# run when the image definition itself changes, not on every app code change.
on:
push:
branches: [main]
paths:
- Dockerfile
- docker/Dockerfile
- .gitea/workflows/build.yml
permissions:
@@ -41,8 +41,8 @@ jobs:
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
context: ./docker
file: ./docker/Dockerfile
push: true
tags: |
gitea.kzbikowski.pl/kzbkowski/servicedesk:latest
+38 -3
View File
@@ -1,4 +1,39 @@
/mysql/
*.log
.DS_Store
.env
compose.yaml
scripts/hesk-import/.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.codex
/.cursor/
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/.scribe
/public/build
/public/fonts-manifest.dev.json
/public/hot
/public/storage
/public/vendor/scribe
/resources/views/scribe
/storage/app/private/scribe
/storage/*.key
/storage/pail
/vendor
_ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
# Deployment (docker/) — real compose file, its env and the database files stay local
/docker/.env
/docker/compose.yaml
/docker/mysql/
/scripts/hesk-import/.env
/.claude/settings.local.json
View File
File renamed without changes.
+15 -13
View File
@@ -220,7 +220,7 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
- **Backend**: Laravel, Livewire (server-driven UI, no SPA build beyond Tailwind/Vite
for CSS), LdapRecord for directory auth, Sanctum for API tokens, L5-Swagger for
API docs, Laravel Reverb for WebSocket broadcasting (real-time queue/chat
updates — see [ARCHITECTURE.md](ARCHITECTURE.md)), webklex/php-imap for the
updates — see [ARCHITECTURE.md](dokumentacja/ARCHITECTURE.md)), webklex/php-imap for the
optional e-mail intake fetcher (pure-PHP IMAP client, no `ext-imap` needed).
- **Frontend**: Blade + Livewire + a little Alpine.js for local UI state; Tailwind
v4 via Vite for `resources/css/app.css`; Laravel Echo + Pusher-protocol client
@@ -228,7 +228,7 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
dashboard is hand-rolled inline-styled bar/column charts, so it needs no client
build step beyond the CSS bundle.
- **Database**: MariaDB.
- **Deployment**: `compose.yaml` — `app` (source bind-mounted from `./src`,
- **Deployment**: `docker/compose.yaml` — `app` (source bind-mounted from the repo root,
no image rebuild needed for PHP/Blade/route changes) + `mariadb` + `reverb`
(same image, `php artisan reverb:start`) + `cron` (same image, `php artisan
schedule:work` — runs the scheduled commands below without needing a host
@@ -236,23 +236,23 @@ and **[wiki/admin](wiki/admin/README.md)** for role-specific how-to guides.
is routed to `reverb` by a higher-priority Traefik rule; everything else
goes to `app`). The `servicedesk` image
itself is built and pushed by Gitea Actions (`.gitea/workflows/build.yml`) to
the Gitea container registry whenever `Dockerfile` changes — `compose.yaml`
the Gitea container registry whenever `docker/Dockerfile` changes — `compose.yaml`
just pulls a tag, it never builds locally.
See **[install.md](install.md)** for full step-by-step deployment instructions —
See **[install.md](dokumentacja/install.md)** for full step-by-step deployment instructions —
both via Docker Compose (this stack) and directly on a server with Apache/Nginx,
including which `.env` values to set (there are two separate `.env` files —
Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed.
## Local/dev notes
- The app container mounts `./src` directly — editing PHP/Blade/routes takes effect
- The app container mounts the repo root directly — editing PHP/Blade/routes takes effect
immediately, no rebuild or restart needed.
- CSS/JS changes under `resources/` **do** need a Vite build. Neither the host nor
the app container has Node installed; rebuild with a throwaway container instead
of rebuilding the app image:
```bash
sudo docker run --rm -v "$(pwd)/src":/app -w /app node:22 npm run build
sudo docker run --rm -v "$(pwd)":/app -w /app node:22 npm run build
```
- Fresh install / reset:
```bash
@@ -267,7 +267,7 @@ Compose-level and Laravel-level) and the LDAP/SMTP gotcha after a fresh seed.
## Project layout
```
src/ Laravel application
. Laravel application lives in the repo root
app/Livewire/ Client/Operator/Admin Livewire components
app/Models/ Eloquent models
app/Events/ Broadcast events (TicketQueueChanged, TicketMessagePosted)
@@ -288,10 +288,12 @@ src/ Laravel application
routes/web.php Client/Operator/Admin routes (role-gated)
routes/api.php REST API (Sanctum, ability-gated)
routes/channels.php Broadcasting channel authorization (operator.queue, ticket.{id})
wiki/
client/ How-to guide for the Client role
operator/ How-to guide for the Operator role
admin/ How-to guide for the Admin role
scripts/
hesk-import/ One-time Hesk 3.x ticket history import — see its own README.md
docker/ Deployment: Dockerfile (image built by CI), compose.yaml(.example),
compose-level .env(.example), mysql/ (MariaDB data, untracked)
dokumentacja/ Technical docs: ARCHITECTURE, install, TESTING, CONTRIBUTING,
SECURITY, CHANGELOG
wiki/ Role-specific how-to guides (client / operator / admin)
scripts/hesk-import/ Hesk 3.x ticket import (history + later updates) — see its own README.md
.claude/ CLAUDE.md — guidance for AI coding agents
.gitea/workflows/ CI: builds and pushes the image when docker/Dockerfile changes
```
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use App\Services\ConfigurationTransfer;
use Illuminate\Console\Command;
class ExportSettings extends Command
{
protected $signature = 'settings:export
{path? : Plik docelowy (domyślnie storage/app/servicedesk-ustawienia-<data>.json)}';
protected $description = 'Eksportuj całą konfigurację (ustawienia, użytkownicy, kategorie, szablony itd. — bez zgłoszeń) do pliku JSON';
public function handle(ConfigurationTransfer $transfer): int
{
$path = $this->argument('path') ?: storage_path('app/servicedesk-ustawienia-'.now()->format('Y-m-d-His').'.json');
$export = $transfer->export();
if (file_put_contents($path, json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)) === false) {
$this->error("Nie udało się zapisać pliku {$path}.");
return self::FAILURE;
}
// The file holds integration passwords/tokens in plain text.
@chmod($path, 0600);
$this->table(['Tabela', 'Wiersze'], collect($export['tables'])->map(fn ($rows, $table) => [$table, count($rows)])->values()->all());
$this->info("Zapisano: {$path}");
$this->warn('Plik zawiera hasła i tokeny integracji (LDAP, SMTP, IMAP, AI, BookStack, Snipe-IT) jawnym tekstem oraz hasze haseł kont lokalnych — przechowuj go jak hasło.');
return self::SUCCESS;
}
}
@@ -26,8 +26,10 @@ use PDO;
* storage/app/hesk-import-state.json). Belt-and-suspenders: tickets.hesk_ticket_id
* is also unique at the DB level, so a state file that's lost/desynced from
* a crash between commit and state-save can't turn into a silent duplicate —
* see alreadyImported(). Not idempotent across *edits* on the Hesk side —
* this is a one-time historical import, not an ongoing sync.
* see alreadyImported(). The import itself never revisits a ticket it has
* already created; activity that lands in Hesk afterward (new replies/notes,
* a status change) is pulled in separately by --sync-updates, see
* runSyncUpdates().
*
* As of the 2026-08 re-import pass, this never creates new local User
* accounts (the servicedesk user base is considered authoritative/complete)
@@ -44,7 +46,8 @@ class ImportHeskTickets extends Command
{--include-unmapped-categories : Also import tickets whose Hesk category has no matching servicedesk category (default: skip them)}
{--assign-teams : Backfill team_id (by category) on already-imported tickets that don\'t have one yet, then exit — does not import anything}
{--fix-closed-dates : Backfill accurate closedat-based updated_at + a closure history entry on already-imported closed tickets, then exit — does not import anything}
{--assign-operators : Backfill assignee_id (from Hesk\'s ticket owner, matched by e-mail to an existing operator account) on already-imported tickets that don\'t have one yet, then exit — does not import anything}';
{--assign-operators : Backfill assignee_id (from Hesk\'s ticket owner, matched by e-mail to an existing operator account) on already-imported tickets that don\'t have one yet, then exit — does not import anything}
{--sync-updates : Pull replies/notes and status changes made in Hesk since the import into already-imported tickets, then exit — does not import new tickets}';
protected $description = 'Import tickets (with full reply/note history) from a Hesk 3.x database, restricted to one e-mail domain';
@@ -119,6 +122,12 @@ class ImportHeskTickets extends Command
: self::FAILURE;
}
if ($this->option('sync-updates')) {
return $this->configureHeskConnection()
? $this->runSyncUpdates((bool) $this->option('commit'))
: self::FAILURE;
}
$domain = trim((string) $this->option('domain'), " \t\n\r\0\x0B@");
if ($domain === '') {
@@ -650,6 +659,221 @@ class ImportHeskTickets extends Command
return self::SUCCESS;
}
/**
* Catch-up mode (--sync-updates): for tickets already imported, pulls in
* whatever happened on the Hesk side since — replies/notes that aren't
* here yet, plus a status change. Exists because Hesk kept being used
* after the original import, so "already imported" stopped meaning "up
* to date". Imports no new tickets (the normal run does that).
*
* A Hesk reply/note counts as already present when this ticket has a
* message with the same timestamp and internal flag — imported messages
* keep Hesk's dt verbatim as created_at, so that pair identifies them
* without needing a Hesk reply id column. Counted rather than just
* checked for existence, so two replies in the same second both survive.
*
* Status follows Hesk only when Hesk is the side that moved last: its
* lastchange has to be later than the newest status change recorded in
* this ticket's history and than any message written here that Hesk
* doesn't know about. Otherwise an operator closing a ticket in
* servicedesk would have it reopened by a Hesk row that simply never
* changed. Missing Hesk messages are still added in that case. Edits to
* the text of a reply that was already imported are not picked up.
*/
private function runSyncUpdates(bool $commit): int
{
$tickets = DB::table('tickets')
->where('source', 'hesk_import')
->whereNotNull('hesk_ticket_id')
->get(['id', 'number', 'hesk_ticket_id', 'customer_id', 'status_key', 'updated_at'])
->keyBy('hesk_ticket_id');
if ($tickets->isEmpty()) {
$this->warn('Brak zaimportowanych z Heska zgłoszeń do zsynchronizowania.');
return self::SUCCESS;
}
$this->loadHeskStaffNames();
$heskIds = $tickets->keys()->all();
$heskTickets = DB::connection('hesk')->table('help_tickets')
->whereIn('id', $heskIds)
->get(['id', 'dt', 'status', 'lastchange', 'closedat'])
->keyBy('id');
$replies = DB::connection('hesk')->table('help_replies')
->whereIn('replyto', $heskIds)->orderBy('dt')->get()->groupBy('replyto');
$notes = DB::connection('hesk')->table('help_notes')
->whereIn('ticket', $heskIds)->orderBy('dt')->get()->groupBy('ticket');
$localMessages = DB::table('ticket_messages')
->whereIn('ticket_id', $tickets->pluck('id'))
->get(['ticket_id', 'internal', 'created_at'])
->groupBy('ticket_id');
$localStatusChangedAt = DB::table('ticket_histories')
->whereIn('ticket_id', $tickets->pluck('id'))
->where('text', 'like', 'Status zmieniony na:%')
->groupBy('ticket_id')
->selectRaw('ticket_id, max(created_at) as changed_at')
->pluck('changed_at', 'ticket_id');
$stats = ['tickets' => 0, 'messages' => 0, 'operator_messages_linked' => 0, 'status' => 0, 'status_kept_local' => 0, 'missing_in_hesk' => 0, 'failed' => 0];
$lines = [];
foreach ($tickets as $heskId => $row) {
$heskTicket = $heskTickets->get($heskId);
if (! $heskTicket) {
$stats['missing_in_hesk']++;
continue;
}
// What this ticket already holds, as "internal|timestamp" => count.
$present = [];
foreach ($localMessages->get($row->id, collect()) as $message) {
$key = ((int) $message->internal).'|'.$message->created_at;
$present[$key] = ($present[$key] ?? 0) + 1;
}
$take = function (string $key) use (&$present): bool {
if (($present[$key] ?? 0) > 0) {
$present[$key]--;
return true;
}
return false;
};
$take('0|'.$heskTicket->dt);
$newReplies = $replies->get($heskId, collect())->reject(fn ($reply) => $take('0|'.$reply->dt))->values();
$newNotes = $notes->get($heskId, collect())->reject(fn ($note) => $take('1|'.$note->dt))->values();
// Whatever is left in $present is a message only servicedesk has.
$localOnly = array_map(fn ($key) => substr($key, 2), array_keys(array_filter($present)));
$lastLocalChange = max([(string) ($localStatusChangedAt[$row->id] ?? ''), ...$localOnly]);
$statusKey = self::STATUS_MAP[(int) $heskTicket->status] ?? 'open';
$statusDiffers = $statusKey !== $row->status_key;
$applyStatus = $statusDiffers && (string) $heskTicket->lastchange > $lastLocalChange;
if ($newReplies->isEmpty() && $newNotes->isEmpty() && ! $statusDiffers) {
continue;
}
if ($statusDiffers && ! $applyStatus) {
$stats['status_kept_local']++;
}
if ($newReplies->isEmpty() && $newNotes->isEmpty() && ! $applyStatus) {
continue;
}
$closedAt = $statusKey === 'closed' ? $this->heskClosedAt($heskTicket) : null;
$lines[] = sprintf(
'%s (Hesk #%d): +%d odpowiedzi, +%d notatek%s',
$row->number,
$heskId,
$newReplies->count(),
$newNotes->count(),
$applyStatus ? ", status {$row->status_key} -> {$statusKey}" : '',
);
if (! $commit) {
$stats['tickets']++;
$stats['messages'] += $newReplies->count() + $newNotes->count();
$stats['status'] += $applyStatus ? 1 : 0;
continue;
}
try {
DB::transaction(function () use ($row, $heskTicket, $newReplies, $newNotes, $applyStatus, $statusKey, $closedAt, &$stats) {
$ticket = Ticket::query()->findOrFail($row->id);
$customer = User::query()->find($row->customer_id);
if ($newReplies->isNotEmpty() && ! $customer) {
throw new \RuntimeException('Zgłoszenie nie ma konta klienta — nie da się przypisać autora odpowiedzi.');
}
foreach ($newReplies as $reply) {
$this->importReply($ticket, $reply, $customer, $stats);
}
foreach ($newNotes as $note) {
$this->importNote($ticket, $note, $stats);
}
// Query builder, not Eloquent, for the same reason as in
// importOneTicket(): a model save() would stamp
// updated_at with "now" instead of Hesk's own date.
$update = [];
if ($applyStatus) {
$update['status_key'] = $statusKey;
$ticket->histories()->create([
'text' => 'Status zmieniony na: '.Status::labelFor($statusKey),
'created_at' => $closedAt ?? $heskTicket->lastchange,
]);
$stats['status']++;
}
// Never moves the ticket's dates backwards past something
// that happened here after Hesk's last change.
$heskDate = $closedAt ?? $heskTicket->lastchange;
if ((string) $heskDate > (string) $row->updated_at) {
$update['updated_at'] = $heskDate;
$update['last_customer_activity_at'] = $heskTicket->lastchange;
}
if ($update) {
DB::table('tickets')->where('id', $row->id)->update($update);
}
});
$stats['tickets']++;
} catch (\Throwable $e) {
$stats['failed']++;
$this->error("Zgłoszenie {$row->number} (Hesk #{$heskId}) nie zostało zsynchronizowane: ".$e->getMessage());
Log::channel('hesk_import')->error(
"Zgłoszenie {$row->number} (Hesk #{$heskId}) nie zostało zsynchronizowane: {$e->getMessage()}\n".$e->getTraceAsString()
);
}
}
foreach ($lines as $line) {
$this->line($line);
}
$this->table(['Miara', 'Wartość'], [
['Sprawdzone zaimportowane zgłoszenia', $tickets->count()],
['Zgłoszenia zaktualizowane', $stats['tickets']],
['Dodane odpowiedzi/notatki', $stats['messages']],
['Zmienione statusy', $stats['status']],
['Status pozostawiony (nowsza zmiana w servicedesk)', $stats['status_kept_local']],
['Brak zgłoszenia w bazie Heska', $stats['missing_in_hesk']],
['Błędy', $stats['failed']],
]);
Log::channel('hesk_import')->info(sprintf(
'Synchronizacja zmian (%s): zgłoszenia=%d, wiadomości=%d, statusy=%d, status pozostawiony=%d, błędy=%d.%s',
$commit ? 'commit' : 'dry-run',
$stats['tickets'],
$stats['messages'],
$stats['status'],
$stats['status_kept_local'],
$stats['failed'],
$lines ? "\n".implode("\n", $lines) : '',
));
if (! $commit) {
$this->newLine();
$this->comment('To był dry-run — uruchom ponownie z --sync-updates --commit, żeby faktycznie zapisać.');
}
return self::SUCCESS;
}
private function loadHeskStaffNames(): void
{
$rows = DB::connection('hesk')->table('help_users')->select('id', 'name', 'email')->get();
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use App\Services\ConfigurationTransfer;
use Illuminate\Console\Command;
use RuntimeException;
class ImportSettings extends Command
{
protected $signature = 'settings:import
{path : Plik utworzony przez settings:export lub pobrany z Admin > Eksport / import}
{--force : Nie pytaj o potwierdzenie}';
protected $description = 'Zastąp konfigurację tej instalacji zawartością pliku eksportu (tylko instalacja bez zgłoszeń)';
public function handle(ConfigurationTransfer $transfer): int
{
$path = $this->argument('path');
if (! is_file($path) || ($json = file_get_contents($path)) === false) {
$this->error("Nie można odczytać pliku {$path}.");
return self::FAILURE;
}
if (! $this->option('force') && ! $this->confirm('Import ZASTĄPI wszystkie ustawienia, użytkowników, kategorie, zespoły, szablony itd. tej instalacji zawartością pliku. Kontynuować?')) {
$this->comment('Przerwano — nic nie zostało zmienione.');
return self::SUCCESS;
}
try {
$result = $transfer->importJson($json);
} catch (RuntimeException $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
$this->table(['Tabela', 'Wiersze'], collect($result['tables'])->map(fn ($count, $table) => [$table, $count])->values()->all());
$this->info("Zaimportowano. Pliki (logo/favicon): {$result['files']}.");
foreach ($result['skipped_columns'] as $table => $columns) {
$this->warn("Pominięto kolumny nieznane tej wersji aplikacji — {$table}: ".implode(', ', $columns));
}
return self::SUCCESS;
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -20,7 +20,7 @@ class TicketController extends Controller
#[OA\Get(
path: '/tickets',
summary: 'List tickets',
description: "Any filter below accepts a `!` prefix to negate it, e.g. `status_key=!closed` returns everything except closed tickets.",
description: 'Any filter below accepts a `!` prefix to negate it, e.g. `status_key=!closed` returns everything except closed tickets.',
security: [['sanctum' => []]],
tags: ['Tickets'],
parameters: [
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -126,7 +126,7 @@ class Panel extends Component
public string $loginNoticeHtml = '';
public string $loginNoticeType = 'info';
public bool $loginNoticeEnabled = true;
// Bumped on "Przywróć domyślny" for the same reason as $emailFooterVersion
// above — forces the wire:ignore'd Quill editor to remount and re-read
@@ -193,7 +193,7 @@ class Panel extends Component
$this->accentColor = Settings::accentColor();
$this->emailFooterHtml = Settings::get('email_footer');
$this->loginNoticeHtml = Settings::get('login_notice_html');
$this->loginNoticeType = Settings::loginNoticeType();
$this->loginNoticeEnabled = Settings::bool('login_notice_enabled');
$this->systemConfig = [
'defaultStatus' => Settings::get('default_status'),
@@ -1341,23 +1341,17 @@ class Panel extends Component
$this->loginNoticeHtml = $value;
}
public function setLoginNoticeType(string $type): void
public function toggleLoginNoticeEnabled(): void
{
if (! array_key_exists($type, Settings::loginNoticeTypes())) {
return;
}
Settings::set('login_notice_type', $type);
$this->loginNoticeType = $type;
$this->loginNoticeEnabled = ! Settings::bool('login_notice_enabled');
Settings::set('login_notice_enabled', $this->loginNoticeEnabled ? '1' : '0');
}
public function resetLoginNotice(): void
{
$default = Settings::default('login_notice_html');
Settings::set('login_notice_html', $default);
Settings::set('login_notice_type', Settings::default('login_notice_type'));
$this->loginNoticeHtml = $default;
$this->loginNoticeType = Settings::default('login_notice_type');
$this->loginNoticeVersion++;
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Ticket;
use App\Services\ConfigurationTransfer;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Livewire\WithFileUploads;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Admin > Eksport / import — download or upload the whole configuration as
* one file (see ConfigurationTransfer for what that covers and why import
* needs a ticket-less install).
*
* Both actions re-check the admin role themselves rather than leaning on
* the page route's middleware alone: one hands out every integration secret
* in plain text, the other replaces every account.
*/
class Transfer extends Component
{
use WithFileUploads;
public $importFile = null;
public ?string $importError = null;
public function export(ConfigurationTransfer $transfer): StreamedResponse
{
abort_unless(Auth::user()?->hasRole('admin'), 403);
$json = $transfer->exportJson();
return response()->streamDownload(
function () use ($json) {
echo $json;
},
'servicedesk-ustawienia-'.now()->format('Y-m-d-His').'.json',
['Content-Type' => 'application/json'],
);
}
public function import(ConfigurationTransfer $transfer): void
{
abort_unless(Auth::user()?->hasRole('admin'), 403);
$this->importError = null;
$this->validate(
['importFile' => 'required|file|extensions:json|max:51200'],
['importFile.*' => 'Wybierz plik .json utworzony przez eksport ustawień (maks. 50 MB).'],
);
try {
$transfer->importJson($this->importFile->get());
} catch (RuntimeException $e) {
$this->importError = $e->getMessage();
return;
}
// Every account was just replaced — the id in this session now
// belongs to whoever holds it in the imported data, so the session
// can't be kept. Sign in again with an imported account.
Auth::logout();
session()->invalidate();
session()->regenerateToken();
$this->redirect(route('login'));
}
public function render()
{
return view('livewire.admin.transfer', [
'hasTickets' => Ticket::query()->exists(),
]);
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff. Show more