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:
1 parent
0943829331
commit
efff0ddfa0
360 files changed
+1521
-366
No files matched your search
@@ -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>`.
|
||||
File renamed without changes.
+82
-13
@@ -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.
@@ -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
@@ -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
|
||||
File renamed without changes.
@@ -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
|
||||
```
|
||||
File renamed without changes.
File renamed without changes.
@@ -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;
|
||||
}
|
||||
}
|
||||
File renamed without changes.
+227
-3
@@ -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();
|
||||
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -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.
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.
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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.
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
Reference in new issue
Block a user