Commit Graph

1928 Commits

Author SHA1 Message Date
Darko Gjorgjijoski
e958cada30 ci: stop at the draft; publishing is a human action (#720)
2.4.3-beta.2 was cut by tag and came out correctly — published,
pre-release, zip attached, notes from CHANGELOG.md — and then nothing
else happened. No updater registration, no Docker images.

GitHub does not start workflow runs from events created with
GITHUB_TOKEN. The publish step authenticated as github-actions[bot], so
`release: published` fired and triggered nothing. Every earlier
docker.yaml run was event=release from a release a human published,
which is why beta.1 worked and beta.2 did not. 3.x carries the same
design and would have failed identically on the first RC.

The workflow now stops at the draft. Everything else is unchanged: the
tag still runs the tests, reads the notes, builds the package and
attaches it. Pressing Publish fires the event under a real identity and
the proven downstream runs as it always has — and it puts a deliberate
gate in front of a release going public.

prerelease and make_latest move onto the draft rather than being applied
at publish time, so the flags are already right when the button is
pressed; GitHub's publish dialog otherwise defaults "Set as the latest
release" to checked, which would let a 3.0.0 alpha displace 2.4.x.

The run now ends by writing the draft URL and the resolved flags to the
job summary, since a draft nobody knows about is no use.

Documents the whole flow in AGENTS.md, including why publishing is
manual — the reasoning is not guessable from the workflow alone.
2026-07-29 17:04:15 +02:00
Darko Gjorgjijoski
1a403984fe ci: check the release notes before building (#716)
The CHANGELOG.md section was read after composer, pnpm and the frontend
build had all run, so tagging without notes spent a full build before
failing on something knowable in the first seconds. It only needs PHP, so
it now runs directly after the PHP setup.

Noticed while dry-running a tag with no section against #715: the run
correctly refused to publish, but took a whole build to say so.
2026-07-29 15:56:29 +02:00
Darko Gjorgjijoski
7597ddaea7 ci: make tag-triggered releases work, and only publish complete ones (#715)
release.yaml has never run. It listens for "v*" tags while every tag ever
cut is bare — 3.0.0-alpha.1, 2.4.2, 2.4.3-beta.1 — so tagging by the
established convention produced silence, and whoever cuts 3.0.0 would
have hit that first. It now accepts both spellings.

It also hand-copied the release file list, which docker.yaml then rebuilt
via `make clean dist` and uploaded with overwrite: true. The two lists
were identical, so nothing had broken yet, but which zip users received
was decided by job ordering. `make dist` owns the artifact now and the
duplicate is gone.

The release is created as a draft with the package already attached and
published in a separate step, so `release: published` fires only once the
tests have passed and the asset is in place. A failed run leaves no
release at all, rather than a published one nobody can download — which
is what 2.4.2 left behind. Registration can no longer race the upload
either, so docker.yaml drops its build job, and register_release loses
both that dependency and the always() dance it needed to survive the job
being skipped on a manual dispatch.

GitHub's "Latest release" pointer is gated on LATEST_MAJOR, exactly as
docker.yaml gates its moving image tags: a 3.0.0 alpha can no longer
displace 2.4.x as the release users are shown first. Release notes come
from CHANGELOG.md instead of being generated from commits, matching where
the updater already reads them, and a tag with no section fails before
anything is published.

The test job moves to a reusable workflow rather than being written out
in both places — the duplicated file list above is the argument.
2026-07-29 15:51:14 +02:00
Darko Gjorgjijoski
3308f3b2fa ci: source release notes from CHANGELOG.md (#713)
Port of the 2.x change to 3.x, with the same extractor script.

Registration sent the GitHub release body to the updater, so the notes
every install sees were written at publish time — after review, outside
the repo, with nothing checking they existed or matched what shipped.

CHANGELOG.md becomes the source. It is written and reviewed alongside the
change itself, so what installs are offered cannot drift from what was
merged, and the release body can simply point at it.

A release with no section for its tag fails the job rather than
registering an empty changelog. A manual dispatch falls back to the
release body, since re-registering a release older than this file is
legitimate — which covers 3.0.0-alpha.1.

Backfilled with 3.0.0-alpha.1.
2026-07-29 15:04:30 +02:00
Darko Gjorgjijoski
00cb1a6389 ci: don't rebuild the docker image on a registration dispatch (#711)
manual_docker_build fires on any workflow_dispatch, and pushes
invoiceshelf/invoiceshelf:${{ inputs.tag }} where tag is required and
defaults to "latest". Dispatching to re-register a release therefore also
rebuilt from the dispatched branch and overwrote the published :latest
image — from 3.x that would hand every :latest user a 3.0.0-alpha build.

The registration path added in #709 is meant to be a safe recovery route,
so triggering it must not have that side effect. A dispatch carrying
register_tag now runs registration only.
2026-07-29 14:31:23 +02:00
Darko Gjorgjijoski
9eeb141b0d ci: fix updater registration and make it re-runnable (#709)
Port of #708 to 3.x. This branch carries the identical bug: the changelog
is written to /tmp/changelog.txt while curl is told to read
`changelog.txt`, a relative path resolved against the checkout. The next
3.x release would fail exactly as 2.4.2 did — published, zip uploaded,
images built, and never registered, so no install offered it.

It has not bitten here only because no 3.x release has been cut since the
automation landed; 3.0.0-alpha.1 predates it, the same reason 2.4.2 was
the first to hit it on 2.x.

Registration moves into its own job that downloads the published asset
rather than reusing the build job's working directory, so it can also be
re-run on demand for an existing tag instead of needing production shell
access to repair. A missing WEBSITE_RELEASE_TOKEN is now fatal on a real
release rather than a warning, and the registration is verified against
the download endpoint afterwards, which fails unless the row exists and
its zip is retrievable.

Both blocks are byte-identical to what merged on 2.x, so the two trees do
not drift and the next change ports cleanly.

Checked for this branch specifically: the verify probe returns 200 for
3.0.0-alpha.1, so it holds on the insider channel; that release ships the
same InvoiceShelf.zip asset name; and the "-" suffix already derives
CHANNEL=insider without change. LATEST_MAJOR stays "2" — 2.x owns the
moving :latest tags until 3.0.0 GA.
2026-07-29 14:27:40 +02:00
Darko Gjorgjijoski
fdd958c1e5 fix(setup): support mariadb in the installation wizard (#704)
Fixes InvoiceShelf/docker#79 — a fresh install using the shipped
docker-compose.mysql.yml cannot get past the database step, because that
compose file sets DB_CONNECTION=mariadb.

getDatabaseEnvironment() switched on sqlite, pgsql and mysql with no arm
for mariadb and no default, so it answered {"config":[]}. The wizard
chooses which form to render from database_connection in that response,
so step 4 rendered blank with no way forward — and nothing reached the
log, because the app never errored, it just replied with nothing.

Adds the mariadb arm, and a default so an unrecognised driver can never
again produce an unrenderable response: it is echoed back with the server
defaults, leaving the fields editable rather than the step empty.

MariaDB is now offered in the driver dropdown too. It was already a valid
DB_CONNECTION with its own connection in config/database.php, and the
form fields are identical to MySQL's.

Tested against the original code, where three of the new cases fail with
"Failed asserting that null is identical to 'mariadb'".
2026-07-29 12:29:01 +02:00
Darko Gjorgjijoski
3b2aa1ca2e fix(config): make the application timezone configurable (#703)
Fixes InvoiceShelf/docker#64 — recurring invoices and scheduled tasks
running on the wrong timezone.

Two bugs compounded so that no supported value had any effect:

config/app.php had no timezone key at all, so Laravel's own fallback won
— and that fallback is a literal 'UTC' string, not an env() lookup
(vendor/laravel/framework/config/app.php). APP_TIMEZONE has therefore
been inert since the config was slimmed, despite being shipped in
.env.example.

inject.sh wrote a bare TIMEZONE key into .env, which nothing reads, so
setting the documented container variable also did nothing. It now
writes APP_TIMEZONE, and accepts either name so existing compose files
keep working without edits.

Verified against a built image: TIMEZONE=Europe/Berlin now yields
APP_TIMEZONE=Europe/Berlin in .env and config('app.timezone') ==
Europe/Berlin, where before both spellings left it on UTC.
2026-07-29 12:23:20 +02:00
Darko Gjorgjijoski
ae035498c6 fix(docker): recreate storage directories on container start (#702)
Fixes InvoiceShelf/docker#75 and #69, and gives InvoiceShelf/docker#77
and #63 an actionable error instead of a cryptic one.

storage/framework/{cache,sessions,views}, storage/logs and storage/app
hold no tracked content — only .gitignore stubs — so nothing guarantees
they exist inside a mounted volume. Docker seeds a named volume from the
image exactly once, when the volume is empty, and never again: a volume
created by an older image keeps whatever it had through every subsequent
upgrade. When those directories are absent Laravel dies at boot with
"Please provide a valid cache path", because config/view.php resolves its
compiled path with realpath(), which returns false for a missing
directory. The sqlite branch also cannot place its database.

Reproduced against a locally built image: deleting storage/framework from
a named volume fails the container with exactly that message, and passes
with this change.

The chown is guarded on being root. The image runs as www-data (uid 82),
where chown of a foreign-owned file is EPERM and, under `set -e`, would
stop the container from starting at all — which is the likely reason it
was dropped from this tree previously. Guarding it keeps the benefit for
anyone running as root without that failure mode.

A mount the container genuinely cannot write to is not something the
entrypoint can fix, so it now says so and names the remedy, rather than
letting the failure surface later as a Laravel stack trace.
2026-07-29 12:20:28 +02:00
Darko Gjorgjijoski
552da3ca84 feat(devenv): make the Gotenberg compose stacks work out of the box (#699)
Choosing Gotenberg in ./devenv started the `pdf` sidecar and configured
nothing else, so the app still defaulted to dompdf. Pointing it at the
sidecar by hand then hit the SSRF guard, because `pdf` resolves to a
private address on the compose network — the failure #691 fixed, hit
from inside our own dev environment.

The three gotenberg compose files now set PDF_DRIVER, GOTENBERG_HOST and
GOTENBERG_ALLOWED_PRIVATE_HOST on php-fpm, so the stack renders through
the sidecar with no .env editing at all. The serversideup pool config
already sets `clear_env = no`, so these reach the workers; verified by
generating a real invoice PDF end to end (24967 bytes, %PDF-1.4).

Setting the environment in compose rather than writing to .env keeps the
devenv script from mutating a developer's own file — it does not touch
.env today, and the values belong to the compose file the developer
selected. Non-Docker setups have the same keys documented in .env.example.

devenv now prints what it configured, including that the compose file
exempts that one host from the SSRF guard, since a security control being
relaxed should not be silent.
2026-07-29 11:08:24 +02:00
csoscd
f8cfb6cd33 fix: allow Gotenberg to reach private/Docker-internal hosts (#691)
* fix: allow Gotenberg to reach private/Docker-internal hosts (Issue #688)

The SSRF guard introduced in #664/#671 correctly blocks arbitrary
private URLs, but also prevents legitimate use-cases where Gotenberg
runs alongside InvoiceShelf in a Docker Compose network (e.g. the
default http://pdf:3000 service name resolves to a private IP).

Add a `gotenberg_allow_private_host` setting (env:
GOTENBERG_ALLOW_PRIVATE_HOST, default false) that:
- skips PrivateNetworkGuard in GotenbergPdfDriver
- skips PublicHttpUrl validation in PDFConfigurationRequest
- exposes a clearly-warned toggle in the admin PDF settings UI
- is persisted to the settings table and loaded via AppConfigProvider

A disabled guard is safe for controlled private networks (Docker
Compose, LAN); it must never be enabled for untrusted hosts. The UI
surfaces a prominent warning to communicate this constraint.

Closes #688

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(gotenberg): scope the private-host exemption to a declared host

Reshapes the escape hatch from a boolean admin setting into an
environment-declared host allowlist.

The driver streams the upstream response body back as the PDF, so a
mis-set Gotenberg host is full-response SSRF — pointed at a link-local
metadata endpoint it returns cloud credentials. A blanket "allow
private" switch left that reachable: gotenberg_host stays editable from
the admin UI, so any install that enabled the switch to run a sidecar
could have the host repointed at an internal service. The population the
flag existed to serve was exactly the population it failed to protect.

GOTENBERG_ALLOWED_PRIVATE_HOST now names the single host that may skip
the guard. Only that exact value is exempt; every other private target
stays blocked. GotenbergHostPolicy owns the comparison so the save-time
rule and the runtime driver guard cannot drift, and normalises case,
trailing slash and surrounding whitespace on both sides.

Being env-only also drops the settings-table key, the AppConfigProvider
branch and the whole admin UI surface — the toggle there could not be
switched on in any case, since BaseSwitchSection has no slot and was
passed no v-model, so the child BaseSwitch was discarded and the value
never changed from false.

Restores the gotenberg_margins validation rule, which the previous
revision replaced rather than added alongside.

Tests cover both directions, including that declaring one private host
does not exempt another; sabotaging the policy to always exempt fails 16
of the 22.

Co-authored-by: csoscd <csoscd@users.noreply.github.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Darko Gjorgjijoski <dg@darkog.com>
Co-authored-by: csoscd <csoscd@users.noreply.github.com>
2026-07-29 10:57:52 +02:00
Darko Gjorgjijoski
01da03624d fix(seeder): assign unique_hash to seeded documents (#698)
RealisticDemoSeeder builds invoices, payments and estimates with
Model::create(), which bypasses both paths that normally set unique_hash
— the factories set it directly, and InvoiceService and friends encode it
from the id after insert. Nothing assigned it here, so every seeded
document had it NULL.

The PDF routes bind on that column, so the frontend built
`/invoices/pdf/` with an empty segment. That 404s, and the only symptom
is "Unable to load document preview" in the UI with nothing written to
the log, which makes it a genuinely slow thing to track down. Anyone who
seeds realistic demo data and opens a document hits it.

Production is unaffected: documents created through the app go through
the service layer, which assigns the hash. Existing seeded databases
need a backfill, encoding each id the same way the services do.

Uses Hashids, as the services do, rather than the factories' str_random,
so demo data matches what the app itself would have produced.
2026-07-29 10:41:45 +02:00
Darko Gjorgjijoski
9a5731106e fix(ui): stop depending on secure-context APIs over plain HTTP (#697)
crypto.randomUUID() and navigator.clipboard are both [SecureContext]-
gated, so neither exists on a plain-HTTP origin that isn't localhost.
That covers the dev host (http://invoiceshelf.test) and any self-hosted
install reached over a hostname or LAN IP — a large share of them.

generateClientId() called crypto.randomUUID() unguarded. It runs during
Pinia store construction via the invoice, estimate and recurring-invoice
stub factories, so on those origins it threw a TypeError before the
store existed and took the document screens down with it. The value is
only a placeholder identity for a row that has no server id yet — the
server assigns the real one on save, which is why DocumentItem and
DocumentTax type it `number | string`. It never needed randomness, so
it is now a session counter: no crypto, no fallback branch, works
everywhere.

PaymentDropdown.copyPdfUrl() had a textarea fallback attached with
.catch(), which cannot fire — on a non-secure origin navigator.clipboard
is undefined, so `.writeText` throws on property access before any
promise exists. Test up front instead, matching the guard the invoice
and estimate dropdowns already use.
2026-07-29 10:31:36 +02:00
Darko Gjorgjijoski
403a4d6722 test(pdf): cover every stock PDF template, and silence the PHP 8.5 PDO deprecation (#696)
* test(pdf): render every stock template through the real pdf routes

Blade templates reference PHP classes as plain strings, so a namespace
move leaves them dangling without Pint, the IDE, or CI noticing — which
is how #695 shipped a fatal ImageUtils reference in all seven stock
templates and left it there for three months.

Renders each invoice, estimate and payment template end-to-end through
the pdf routes with a company logo attached, since every template guards
the logo behind `@if ($logo)` and the fallback branch never reaches
ImageUtils. One extra assertion checks the rendered markup actually
carries the base64 data URI, so a template that silently drops the logo
fails too rather than emitting a valid but logo-less PDF.

Template names are globbed off disk rather than hardcoded, so a new
stock template is covered as soon as it lands.

* fix(config): resolve the mysql SSL CA attribute per PHP version

PHP 8.5 deprecated PDO::MYSQL_ATTR_SSL_CA in favour of
Pdo\Mysql::ATTR_SSL_CA, so every test in the suite was reported as
deprecated rather than passed — noise that would hide a real one.

Pdo\Mysql does not exist before 8.5 and this package supports ^8.4, so
the constant is resolved at runtime; the untaken ternary branch is never
looked up, which keeps 8.4 working. The lookup stays behind the
extension_loaded() check because neither name is defined when pdo_mysql
is missing.

Verified against both runtimes: with MYSQL_ATTR_SSL_CA set, 8.4 resolves
to attribute 1009 and 8.5 to 1008 — each version's own value, matching
what the previous code produced there.
2026-07-28 19:58:34 +02:00
Lukas Selch
bf1c4d85cf fix: update ImageUtils namespace in PDF blade templates (#695)
The ImageUtils class was moved from App\Services\Pdf to App\Support\Pdf
but the blade templates were not updated to reflect this change.

This caused 'Class App\Services\Pdf\ImageUtils not found' errors when
generating PDFs for invoices, estimates, and payments.

Updated namespace in 7 blade template files:
- invoice1.blade.php
- invoice2.blade.php
- invoice3.blade.php
- estimate1.blade.php
- estimate2.blade.php
- estimate3.blade.php
- payment.blade.php
2026-07-28 19:39:06 +02:00
Darko Gjorgjijoski
d0391b9cfc ci: auto-register published release on the website updater (#693)
Append a step to the release_artifact_build job that POSTs the freshly built
InvoiceShelf.zip + metadata to the website updater's /api/releases endpoint
(Bearer WEBSITE_RELEASE_TOKEN) right after the asset upload, so deployed installs
are offered the release automatically instead of a manual kubectl+tinker import.

- Runs only on release events; skips with a warning if WEBSITE_RELEASE_TOKEN is unset
- Channel derived from the prerelease flag / "-" tag suffix (GA->stable, pre->insider)
- min_php + extensions read from config/installer.php; release fields passed via env
  to avoid shell injection from the release body
- Idempotent (the endpoint upserts per version)


Claude-Session: https://claude.ai/code/session_012tpgisKcrC4D4mCbGTeTKz

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 14:33:38 +02:00
Darko Gjorgjijoski
c6cd8f2b66 Update security policy with bot reporting rule
Added a hard rule for bot or LLM vulnerability reports.
2026-06-30 21:19:46 +02:00
Darko Gjorgjijoski
209a766c8c ci(docker): authenticate composer against GitHub in the image build
Multi-arch builds run composer (incl. the merge-plugin's update) twice and exhausted GitHub's unauthenticated API rate limit, failing with 'Could not authenticate against github.com'. Pass the Actions token as a build secret and feed it to composer via COMPOSER_AUTH (build-time only, never in the image).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3.0.0-alpha.1
2026-06-15 00:32:02 +02:00
gdarko
87f13cde77 docs: regenerate OpenAPI spec [skip ci] 2026-06-14 21:47:40 +00:00
Darko Gjorgjijoski
2af8bfee22 docs(security): drop third-party cc from the security policy
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:12 +02:00
Darko Gjorgjijoski
3ca4027871 fix(documents): repair inline add-item modal on invoice/estimate create
Render a single shared ItemModal (was one per row, so stacked dialogs closed each other), validate the modal's own local form (vuelidate did not track the shared store object in the persistent modal), surface save errors, and carry the typed item name into the new-item form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:12 +02:00
Darko Gjorgjijoski
9e496102d4 feat(updater): disable the in-app updater in containerized installs
The Docker image already injects CONTAINERIZED=true; consume it via config('invoiceshelf.containerized'), expose it on /app/version, block the update endpoints + console command, and show a 'docker compose pull' panel instead of the updater. Adds missing i18n keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:11 +02:00
Darko Gjorgjijoski
c04b84e5b5 ci(docker): release-driven tags, drop nightly cron, add 3.x pre-release channel
Remove the scheduled nightly/alpha builds; gate :latest on a single LATEST_MAJOR; publish :beta/:next for pre-releases; keep a transitional :nightly alias on stable. Also fix the production Dockerfile so a stale host public/build can no longer clobber the freshly built frontend (reorder COPY, ignore public/build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:11 +02:00
Darko Gjorgjijoski
d3202b8b2a fix(members): scope member view & update to the acting company
Member view/update bound the target user by global id and authorized only that the requester owns their active company, not that the target belonged to it. Bind the route model under the members param and require shared company membership in UserPolicy so an owner of one company can no longer read or modify users of another.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:42:11 +02:00
Darko Gjorgjijoski
8d929ec09d feat(api): generate OpenAPI spec with Scramble for api-docs.invoiceshelf.com (#685)
Auto-generate an OpenAPI 3.1 spec from the v1 API's FormRequests and Resources
(no annotations) for publishing at api-docs.invoiceshelf.com as a static
Swagger UI site.

- config/scramble.php: scope to api/v1, version from version.md, clean
  placeholder server, export to public/openapi.json
- ScrambleServiceProvider: advertise Bearer (Sanctum) auth; add the required
  `company` tenancy header only to routes using the `company` middleware
- OpenApiDocumentationTest: assert spec shape, auth scheme, company-header gating
- .github/workflows/openapi.yml: export + commit spec on release, notify the
  api-docs site to rebuild
- public/openapi.json: generated seed spec (184 paths)
- dedoc/scramble added as a dev-only dependency

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:51:54 +02:00
Darko Gjorgjijoski
421c385fa7 chore: drop Laravel Boost; make AGENTS.md the canonical agent doc (#684)
- Remove laravel/boost (+ its deps laravel/mcp, laravel/roster) and Boost's
  .cursor/ skills + MCP registration. No version sweep (composer remove).
- AGENTS.md is now the hand-maintained source of truth (was Boost-generated):
  folds in the CLAUDE.md content + a Conventions section codifying the migration
  FK rule (unsignedInteger for INT-PK refs, not foreignId — MySQL error 3780)
  and the MySQL/PostgreSQL/SQLite cross-DB requirement.
- CLAUDE.md / GEMINI.md / .github/copilot-instructions.md are now gitignored
  symlinks to AGENTS.md, created by bin/ai-docs.php (composer run ai-docs, also
  wired into post-autoload-dump).
2026-06-12 21:54:09 +02:00
Darko Gjorgjijoski
a2aa74aa8a fix(migrations): align ai_conversations company_id/user_id with INT pk (#683)
Follow-up to #618. The ai_conversations table (added after #618) used
foreignId() (BIGINT) for company_id/user_id, which mismatches the INT
UNSIGNED users.id / companies.id and breaks MySQL FK creation (error 3780)
on the v2->v3 upgrade. Use plain unsignedInteger, matching the codebase's
no-DB-FK convention for these columns. conversation_id stays foreignId — it
references the BIGINT ai_conversations.id and its delete cascade is intentional
(and covered by AiChatFlowTest).
2026-06-12 15:19:21 +02:00
Darko Gjorgjijoski
217deb0bf9 feat: localize country names in PDFs via symfony/intl (#681)
Ports #639 to 3.x — the original targets the now feature-frozen 2.x line.
Address::country_name now resolves the localized country name for the
current app locale via Symfony\Component\Intl\Countries, falling back to
the stored name on lookup failure. Adds symfony/intl + a unit test.

Co-authored-by: Lukas Selch <selchlukas@icloud.com>
2026-06-12 14:43:41 +02:00
mchev
acbb1f040c fix(migrations): align FK column types with users/companies id on MySQL (#618)
Laravel foreignId() creates BIGINT UNSIGNED columns, but users.id and
companies.id use increments() (INT UNSIGNED). MySQL 8 rejects foreign keys
when referencing and referenced column types differ (error 3780).

Use unsignedInteger for impersonation_logs admin_id/user_id and for
company_invitations company_id, user_id, and invited_by. Keep foreignId
for role_id since roles.id is bigIncrements.

This fixes upgrades from v2 on MySQL when running v3.0 migrations.

Made-with: Cursor
2026-06-12 14:43:27 +02:00
Darko Gjorgjijoski
c13aba948d fix(deps): bump marked, dompurify, postcss to patched versions (#680)
npm advisories Dependabot surfaced on 3.x once it scanned the pnpm lock:
- marked 18.0.0 -> 18.0.5 (high)
- dompurify 3.3.3 -> 3.4.9 (4 advisories)
- postcss 8.5.8 -> 8.5.15 (transitive — added a pnpm override, like brace-expansion)

Lockfile + version-floor bumps; no app code changes.
2026-06-12 14:22:29 +02:00
Darko Gjorgjijoski
1aba43aca1 fix(deps): bump vulnerable composer dependencies to patched versions (#679)
3.x is now the default branch, so Dependabot re-surfaced the symfony/guzzle
advisories (composer.lock was never bumped on v3). Same fix as v2's #674:
- laravel/framework -> 13.15.0 (CVE-2026-48019, CRLF in the email rule)
- symfony/{mime,http-kernel,mailer,routing,yaml,polyfill-intl-idn} -> patched
- guzzlehttp/psr7 -> 2.11.0 (host-confusion + CRLF advisories)
composer audit clean.
2026-06-12 14:04:30 +02:00
Darko Gjorgjijoski
e48212b18a build: migrate frontend tooling to pnpm (v3) (#678)
* build: migrate frontend tooling to pnpm (v3)

Rebuilds the stale #673 on current 3.x so it doesn't revert #657's test
split, the Node-24 action bumps, or composer-install@4.0.0.

- package.json: packageManager pnpm@11.6.0; drop dead 'resolutions'
- pnpm-workspace.yaml: nodeLinker hoisted, allowBuilds vue-demi,
  overrides brace-expansion (replaces resolutions)
- pnpm-lock.yaml generated via 'pnpm import' from yarn.lock (keeps the
  resolved versions, incl. vite 8.0.3 / rolldown rc.12); yarn.lock removed
- docker.yaml + release.yaml: pnpm/action-setup@v6 + cache pnpm + pnpm
  install/build (action versions and the #657 split left intact; check.yaml
  needs no change — its test job is PHP-only after #657)
- 3 Dockerfiles: node:24 + corepack + pnpm install --frozen-lockfile && pnpm build
- Makefile, composer 'dev' script, CLAUDE.md, .gitignore -> pnpm

* fix(deps): pin vite to 8.0.5 (security)

Now that 3.x is the default branch, Dependabot flags vite <8.0.5. Pin to
8.0.5 (the patched version), which keeps rolldown 1.0.0-rc.12 — still
below 8.0.15 where the broken rolldown 1.0.3 (the init_runtime_dom_esm_bundler
chunk regression) starts, so the build stays clean. Mirrors v2's #674.
2026-06-12 14:04:16 +02:00
Darko Gjorgjijoski
4ab62b98c6 ci: speed up PHP test jobs (disable Xdebug, drop frontend build, run parallel) (#657)
* ci: speed up the test job (disable Xdebug, drop frontend build, run parallel)

The `tests` job in check.yaml carried three sources of wasted wall-clock,
none of which it actually used:

- `coverage: xdebug` loaded Xdebug into every PHP process, but no step ever
  passes `--coverage` — so it was pure tax (~2-3x slower execution). Switch
  to `coverage: none`. If coverage is wanted later, use pcov + `--coverage`.
- The job ran `npm install` + `npm run build` before the PHP tests. The
  feature suite is API/JSON only (49/56 feature files use getJson/assertJson)
  and nothing renders the Vite blade, so the built assets are never needed.
  Drop the Node/Vite steps; release & docker workflows still build assets.
- Tests ran single-process. brianium/paratest is already installed and the
  runner has 4 cores, so run `php artisan test --parallel`.

Validated locally: full suite passes in parallel (exit 0), including
repeated runs of the two filesystem-writing module tests — no races.

docker.yaml carries the same pattern but only runs on release/nightly cron,
so it is left for a follow-up.

* ci: apply the same test-job speedups to docker.yaml

The release/nightly `tests` job in docker.yaml carried the identical waste
that check.yaml had: Xdebug loaded but never used for coverage, an
unnecessary frontend build before the PHP tests, and serial execution.

Mirror the check.yaml fix: coverage: none, drop the Node/Vite steps
(the suite is API/JSON and the separate release_artifact_build job builds
its own assets), and run php artisan test --parallel.

* ci: run module-scaffolding tests serially under --parallel

The Modules/* tests (module:make ScaffoldProbe + modules_statuses.json
toggles) mutate shared on-disk module state. paratest isolates the DB
per worker but NOT the filesystem, so concurrent workers boot with
ScaffoldProbe enabled and fatal on the un-autoloaded ServiceProvider
(31 failures). Tag them 'modules' (Pest group on Feature/Company/Modules)
and split CI: parallel --exclude-group=modules, then serial --group=modules.

* ci: stub Vite in tests + bump all actions to Node 24 versions

Part A (fixes #657): the customer-portal entrypoint test renders the SPA
shell (app.blade.php → @vite). With the frontend build dropped from CI
there's no manifest, so it 500'd (ViteManifestNotFoundException). Call
$this->withoutVite() in TestCase::setUp() so SPA-shell renders work
without a built manifest; the build stays dropped.

Part B: bump every Node-20 action to its node24 release — checkout v4->v6,
setup-node v4->v6, paths-filter v3->v4, cancel-workflow-action 0.12.1->0.13.1,
softprops/action-gh-release v2->v3, docker/{setup-buildx v3->v4, login v3->v4,
metadata v5->v6, build-push v5->v7}. setup-php@v2, ramsey/composer-install@v2
(composite) and svenstaro/upload-release-action@v2 are already node24.

* ci: bump ramsey/composer-install v2 -> 4.0.0 (node24 internal cache)

composer-install@v2 is composite but internally calls actions/cache@v3
(Node 20), which still trips the deprecation. 4.0.0 uses actions/cache
v5.0.3 (Node 24) and keeps the composer-options input we use.
2026-06-12 12:35:10 +02:00
Darko Gjorgjijoski
e41ee8083d fix(documents): preserve edited line-item description (#658)
The per-line item description typed into the row's textarea was never
saved. BaseItemSelect's description field pushes edits out only via an
`update:description` emit, but DocumentItemRow (the single shared row used
by invoices, estimates, and recurring invoices) wired only `@search` and
`@select` — so the emit was dropped and `form.items[index].description`
never updated. On submit the field was lost; a catalog item's description
also reverted on the next re-render.

Capture the event and route it through the existing `updateItemAttribute`
store updater, mirroring how name/quantity/price/discount already sync.
One fix covers all three document types since they share the row.

Also add the missing `items.*.description` nullable rule to
RecurringInvoiceRequest for parity with the invoice/estimate requests.

The backend already persisted and returned description correctly; this was
purely a dropped frontend event.
2026-06-12 11:27:56 +02:00
Darko Gjorgjijoski
f007daa65b feat(brand): adopt the redesigned InvoiceShelf logo (#660)
- MainLogo.vue: new 'receipt + check' mark + InvoiceShelf wordmark lockup
  (monochrome, inherits the existing light/dark colour props — works in the
  header, auth, installation, sidebar, loader, and customer portal).
- Favicons + PWA icons regenerated from the new mark (16/32/180/192/512/150 +
  favicon.ico); safari-pinned-tab.svg redrawn to the new mark; mask-icon colour
  #5851d8 -> #4a3dff.
- New brand SVG assets (logo-mark + variants + lockup) added under static/img;
  logo-gray.png (public-invoice footer) regenerated as the mono mark.
2026-06-12 11:27:39 +02:00
Darko Gjorgjijoski
84524ce247 fix(security): recompute document totals server-side (GHSA-8c69) (#672)
v3 port. Invoice/estimate/recurring creation and update accepted total,
sub_total, tax and due_amount straight from the request with no recalculation,
letting a client persist financial totals that don't match the line items
(and corrupt the invoice update due-amount/paid-amount logic which keyed off
the client total).

- Adds App\Support\DocumentTotals (mirrors the front-end calc) trusting only
  price/quantity/discounts/tax-line amounts.
- getInvoicePayload/getEstimatePayload/getRecurringInvoicePayload override the
  client totals; the shared DocumentItemService::createItems recomputes each
  item total; InvoiceService::update keys its due-amount logic off the
  recomputed total.

Adds DocumentTotals unit tests + a feature test proving a tampered invoice
total is ignored; existing create/update tests no longer assert the now
server-authoritative derived totals.
2026-06-12 11:02:23 +02:00
Darko Gjorgjijoski
99ea898e88 fix(security): block SSRF via the Gotenberg host setting (GHSA-mfxg) (#671)
v3 port. The Gotenberg PDF driver was missed when the SSRF guards were added
to the AI, exchange-rate and file-disk drivers: gotenberg_host was validated
only with 'url', and the driver POSTs the rendered HTML to it.

Reuses the existing infrastructure (consistency with the other drivers):
- Wires App\Rules\PublicHttpUrl into the gotenberg_host validation rule.
- Adds PrivateNetworkGuard::assertAllowed() in GotenbergPdfDriver before the
  outbound call (covers env/seed/stale config + DNS rebinding).

Adds a unit test asserting the gotenberg_host rule rejects private/loopback/
link-local addresses and allows a public one.
2026-06-12 11:02:19 +02:00
Darko Gjorgjijoski
ca6dd57bf9 fix(security): block ORDER BY SQL injection via orderByField (GHSA-cp8p) (#670)
v3 port. orderByField/orderBy were passed straight into Eloquent's orderBy()
in every model's scopeWhereOrder (and Invoice::scopeApplyFilters), allowing
arbitrary SQL in the ORDER BY clause.

Adds App\Support\SafeOrderBy::apply() (plain/table-qualified column identifier
only, asc/desc clamp) and routes all 10 model sort sinks through it. Aliased
sorts (e.g. estimates by customers.name) stay valid.

Adds unit tests for injection rejection, plain + aliased columns, direction clamp.
2026-06-12 11:02:15 +02:00
Darko Gjorgjijoski
e56b6b4fe5 fix(security): harden public EmailLog token endpoints (GHSA-73q7) (#669)
v3 port. Customer PDF controllers resolved the target document by raw
mailable_id ignoring mailable_type, and skipped expiry on the JSON endpoints.

- Resolve via $emailLog->mailable + assert the expected type (404) to close
  cross-type disclosure.
- Enforce isExpired() (403) on every public path incl. the JSON endpoints.
- Harden EmailLog::isExpired() against a null/unresolvable mailable.

Adds tests for cross-type 404, JSON-path expiry 403, and the valid path.
2026-06-12 11:02:11 +02:00
Darko Gjorgjijoski
c6a00df120 fix(security): enforce company scope on notes, doc-convert, and member bulk-delete (#668)
v3 port of the v2 authorization fixes.

- Notes IDOR (GHSA-85wc): NotePolicy checks the note's company_id and
  NotesController passes the bound model to authorize() on show/update/destroy.
- Estimate<->Invoice convert IDOR (GHSA-j2vg): EstimatesController::convertToInvoice
  and InvoicesController::convertToEstimate authorize 'view' on the source
  document before creating the target.
- Member bulk-delete (GHSA-wxrv): MembersController scopes ids via
  User::whereCompany() before MemberService::delete.

Adds feature tests for cross-company 403s + same-company happy paths.
2026-06-12 11:02:08 +02:00
Darko Gjorgjijoski
f3ab0f22fc chore(frontend): fix ESLint, add Pint+ESLint pre-commit hook, centralize v-html
Fix the broken ESLint setup: add vue-eslint-parser and @typescript-eslint/parser
and wire the TS parser into eslint.config.mjs so .ts and <script lang=ts> parse
(was failing outright). Clear the resulting backlog to a clean 0/0 baseline —
fix genuine issues, relax two intentional-pattern rules (multi-word-component-names,
no-required-prop-with-default).

Add a committed .githooks/pre-commit (enabled via core.hooksPath, auto-set by the
prepare script) that runs Pint on staged PHP and ESLint --max-warnings 0 on staged
resources/scripts JS/TS/Vue, blocking on failure. Add composer/npm lint scripts and
document the gate in CLAUDE.md.

Replace every scattered v-html with a single audited BaseSanitizedHtml component
that DOMPurify-sanitizes its input (new utils/markdown.ts sanitizeHtml), so
server/registry-provided HTML is actually sanitized and vue/no-v-html stays enabled
everywhere but one reviewed sink.
2026-06-11 11:11:12 +02:00
Darko Gjorgjijoski
1e8b113cc9 refactor(frontend): drop unused deps, moment→date-fns, guid→crypto.randomUUID
Remove 10 unused/redundant frontend dependencies (@stripe/stripe-js, maska,
@vuelidate/components, path, mini-svg-data-uri, @types/node, @tailwindcss/forms,
brace-expansion [kept only as a resolutions pin], moment, guid).

Replace moment with the already-present date-fns via a shared utils/date-range.ts
(used by the 4 report views), and guid with native crypto.randomUUID(). Add
tsconfig ignoreDeprecations so the vue-tsc typecheck script can run.
2026-06-11 11:11:12 +02:00
Darko Gjorgjijoski
f07b41fe55 fix(security): patch 7 vulnerable frontend deps on the v3 line (#654)
Clears all 7 npm advisories surfaced by `yarn audit` (3 high, 4 moderate -> 0).
These are not surfaced by Dependabot since 3.x is not the default branch.

- axios            1.14.0  -> 1.17.0  (high; was exact-pinned)
- vite             8.0.3   -> 8.0.16  (high)
- marked           18.0.1  -> 18.0.5  (high; AI-chat markdown parser)
- dompurify        3.3.3   -> 3.4.8   (moderate; AI-chat HTML sanitizer)
- postcss          8.5.8   -> 8.5.15  (moderate)
- follow-redirects 1.15.11 -> 1.16.0  (moderate)
- brace-expansion  5.0.5   -> 5.0.6   (moderate; dev-only transitive under eslint, pinned via resolutions)

Updates the tracked yarn.lock; `yarn build` verified.
2026-06-11 09:10:57 +02:00
Darko Gjorgjijoski
b73dcb5278 fix: boot app + AI driver registration on invoiceshelf/modules 3.0.3 (via VCS) (#655)
* fix(deps): require invoiceshelf/modules ^3.0.2 (adds registerExchangeRateDriver)

DriverRegistryProvider::registerExchangeRateDrivers() calls
Registry::registerExchangeRateDriver(), which only exists in
invoiceshelf/modules >= 3.0.2. The constraint (^3.0) and the committed
lock (3.0.1) allowed/pinned versions without it, so a fresh
`composer install` (CI, Docker, new clones) boots into:

  Call to undefined method InvoiceShelf\Modules\Registry::registerExchangeRateDriver()

Pin to ^3.0.2 and update the lock so every install gets a version that
has the method. `php artisan package:discover` verified clean.

* fix(ai): register the AI driver via the generic Registry::registerDriver('ai', ...)

registerAiDriver() is a convenience wrapper that is NOT present in any
published invoiceshelf/modules release (only the generic registerDriver()
and registerExchangeRateDriver() ship), so DriverRegistryProvider crashed
app boot on a clean composer install:

  Call to undefined method InvoiceShelf\Modules\Registry::registerAiDriver()

Use registerDriver('ai', 'openrouter', ...) instead -- it stores under the
'ai' type exactly like the wrapper would, and AiConfigurationService /
AiDriverFactory read it back via allDrivers('ai') / driverMeta('ai', ...).

Verified by clean-reinstalling invoiceshelf/modules (no local patch) and
running `php artisan package:discover` -> boots clean.

* style: fix pre-existing Pint violations in backup services

BackupService.php and BackupConfigurationFactory.php (untouched by this
PR's boot fix) carried style violations from an earlier domain-reorg
refactor (6d1816bd). `pint --test` checks the whole tree and runs on any
PR that touches PHP, so these failed CI here. Auto-fixed with Pint
(braces_position, no_unused_imports, single_line_empty_body) so the check
goes green.

* build(deps): pull invoiceshelf/modules ^3.0.3 via VCS, restore registerAiDriver()

The 3.0.3 release adds Registry::registerAiDriver() (the method DriverRegistryProvider
and AiDriverFactoryTest call). Packagist has the package frozen, so resolve it directly
from the canonical GitHub repo via a composer VCS repository and require ^3.0.3 (the tag
exists; the freeze is Packagist-side only).

Now that the method ships, restore DriverRegistryProvider to Registry::registerAiDriver()
— reverting the temporary generic registerDriver('ai', ...) workaround — so it matches the
package's intended API and the existing tests. The provider is now net-identical to 3.x.

Verified: php artisan package:discover boots clean; the AI suite (incl. the previously
failing AiDriverFactoryTest) passes.

* test: provision modules_statuses.json in the test bootstrap

The Modules/HelloWorld integration test needs the module enabled at the nwidart
level, read from storage/app/modules_statuses.json at app boot. That file is
gitignored (created locally by `module:make`), so it's absent on CI and fresh
clones — HelloWorld stays disabled and the 5 integration tests fail with 404s.

Provision it (only if missing) in tests/Pest.php before any test boots the app,
so CI matches a local dev environment. Full suite: 462 pass.

* fix(test): enable HelloWorld via a committed modules_statuses.json

The Modules/HelloWorld integration test needs the module enabled at the nwidart
level — read from storage/app/modules_statuses.json by FileActivator. That file
is gitignored, so it's absent in CI / fresh clones, leaving HelloWorld disabled
and the 5 integration tests failing with 404s.

The previous tests/Pest.php provisioning (60a7f0d6) only worked under
`./vendor/bin/pest`. CI runs `php artisan test`, which boots the console app
first; FileActivator reads and caches the (absent) statuses at construction
BEFORE Pest.php runs, so test-runtime provisioning is too late. The file must
exist before any boot.

Commit the file via a storage/app/.gitignore negation, and revert the
ineffective Pest.php hack. Prod-safe: Modules/ is gitignored and not copied by
release.yaml, so a phantom "HelloWorld: true" status is ignored by nwidart (no
such module on disk). bootstrap/cache/modules.php is gitignored (absent in CI),
so nothing overrides the committed file.

Verified with `php artisan test --filter=HelloWorld` (the CI command) and the
full suite: 462 pass; pint clean.

* fix(test): commit the Modules/HelloWorld sample module

HelloWorldIntegrationTest exercises Modules/HelloWorld end-to-end, but Modules/
was gitignored (/Modules), so the module was absent from the repo and from CI —
the 5 integration tests 404'd, and the committed modules_statuses.json merely
enabled a module that wasn't there.

Track Modules/HelloWorld (the test fixture) via a `/Modules/*` + `!HelloWorld`
negation. Not shipped to prod (release.yaml omits Modules/). Now the module is
present, autoloaded (merge-plugin), and enabled (statuses file), so its provider
boots and the menu/settings/routes register.

* build: drop the boost:update post-update-cmd hook (breaks CI)

laravel/boost gates its commands to the local environment, so `php artisan
boost:update` fails in CI ("There are no commands defined in the boost
namespace"), making composer's post-update-cmd return exit 1.

It only began failing the build once Modules/HelloWorld/composer.json was
committed: the wikimedia merge-plugin then runs composer's update path on a
plain `composer install`, triggering post-update-cmd. Drop the auto-update
hook (run `boost:update` manually when needed); vendor:publish stays.
2026-06-05 14:57:06 +02:00
Darko Gjorgjijoski
ac2a8ca939 fix(security): gate AI tools by user ability and block admin-URL SSRF
The AI chat assistant scoped tool queries by company but ignored the
per-user Bouncer abilities the rest of the app enforces, so any `use ai`
holder could read customers, invoices, payments, and company financials
their role couldn't otherwise see. Each AiTool now declares a required
ability (entity-aligned); the registry hides unauthorized tools from the
model and refuses to execute them as a backstop.

Separately, admin/owner-supplied URLs were fetched server-side with no
guard against private/reserved targets (SSRF): the AI base URL, the
CurrencyConverter "DEDICATED" exchange-rate URL, and S3/Spaces file-disk
endpoints. A shared PrivateNetworkGuard now backs a PublicHttpUrl
validation rule (save-time) and runtime guards in each driver.

- AiTool::requiredAbility() + mapping across all 12 tools
- AiToolRegistry filters schemas() by ability and re-checks in execute()
- PrivateNetworkGuard / BlockedUrlException / PublicHttpUrl rule (new)
- Rule wired into AI config (service + 3 controllers), exchange-rate,
  and file-disk endpoints; runtime guards in OpenRouterDriver,
  CurrencyConverterDriver, and FileDiskService
- Tests for ability filtering, the guard, the rule, and 422 rejections
2026-06-05 00:01:09 +02:00
Darko Gjorgjijoski
3df2a01832 feat(ai): add customer/item/expense ranking tools to the chat assistant
Adds three new read-only tools the chat LLM can call to answer
"who/what did the most X" questions that previously fell through
the cracks:

- rank_top_customers — ranks customers by invoiced_total, paid_total,
  invoice_count, or outstanding_balance over a named time period
- rank_top_items — ranks catalog items by quantity_sold or revenue
- rank_expense_categories — ranks expense categories by total spend

All three share a new ResolvesPeriod trait that centralizes the
period-name → [start, end] logic. GetCompanyStatsTool is refactored
onto the same trait (identical public schema — the 'all_time' option
is only exposed on the new ranking tools, where an unbounded window
makes sense; stats over "all time" collapses every record into one
giant bucket and is rarely useful).

Each tool follows the existing pattern: snake_case name, one-sentence
description tuned for LLM tool selection, JSON-schema parameters
with injected company scoping (never trusting LLM-supplied company
IDs), and JSON-encodable output. outstanding_balance on the customer
tool explicitly ignores the period param since it's a current-state
snapshot.

Multi-company scoping tests lock down the session-authoritative
boundary on every new tool. Per-metric ordering tests verify the
aggregate queries actually rank correctly, and an ad-hoc-item
exclusion test verifies rank_top_items skips invoice lines where
item_id is null (free-typed entries that have no catalog row to
rank by id).

15 new tests added (tests/Feature/Ai/Tools/); test suite grows from
398 to 413 passing. LLM tool count goes from 9 to 12 — the model
will discover the new tools automatically via the function-calling
schema with no prompt changes required.
2026-04-11 21:54:39 +02:00
Darko Gjorgjijoski
1f9aebdcfd style(ai): align chat drawer top bars to a single 48px row
The conversation-list sidebar header and the chat panel header lived
in separate columns with different heights — the "+ New conversation"
wrapper was ~60px (p-3 + py-2 button) while the "AI Assistant" header
was ~44px (p-3 + small icon/text). The resulting staircase looked
unintentional.

Pins both to h-12 (48px) so they form a single unified top bar across
the drawer. Shrinks the "+ New conversation" button to text-xs /
py-1 / px-2 so it fits the tighter height without clipping, and
switches its alignment to center to match the compacter footprint.
2026-04-11 21:11:14 +02:00
Darko Gjorgjijoski
5b53a7c283 refactor(ai): move chat + text-gen prompts into resources/ai/prompts/
Extracts the two inline LLM prompts (AiAssistantService's chat system
prompt and AiTextGenerationService's writing preamble) out of PHP
heredocs and into plain-markdown template files under resources/ai/
prompts/. Each file can now be edited without opening a service
class, without wrestling with PHP string interpolation, and with
proper markdown syntax highlighting in editors.

A tiny PromptLoader helper at app/Support/Ai/PromptLoader.php reads
the file and does {{placeholder}} substitution via strtr() — no
Blade, because Blade's {{ $var }} HTML-escapes ampersands and quotes,
which is wrong for LLM prompts (a company called "Smith & Co" would
be sent as "Smith &amp; Co"). Missing templates throw RuntimeException
so they fail loud during development.

Pure refactor: no prompt wording changes. Existing AI feature tests
(AiChatFlowTest, AiGenerationTest) pass unchanged — they assert on
message structure via ScriptedAiDriver, not on prompt content. Three
new unit tests in PromptLoaderTest lock the helper's contract:
placeholder substitution, no-var loading, missing-file error.
2026-04-11 21:08:43 +02:00
Darko Gjorgjijoski
f66d0f80b8 feat(ai): encourage emoji + richer markdown in chat system prompt
The assistant's output looked perfectly structured — headings, bold,
lists — but had zero visual cues. Sonnet 4.6 defaults to restrained
prose and won't decorate without a nudge. Users reviewing a list of
invoices or an overdue summary benefit from status icons ( paid,
⚠️ overdue, 📝 draft) scanning across many records.

Adds an emoji guideline to buildSystemPrompt() with a vocabulary
mapped to the domain model's statuses (paid / partially paid /
overdue / draft / sent / viewed / declined) plus totals, dates,
stats and tips. Capped at one emoji per bullet so responses don't
turn into decoration soup.

Also expands the Markdown rule to explicitly mention headings, bold,
bullet lists and tables — the model was already producing these but
the prompt only vaguely said "Format in Markdown", which left tables
off the table (pun intended) when comparing multiple records.
2026-04-11 21:00:44 +02:00
Darko Gjorgjijoski
02704a4b20 feat(ai): render assistant chat messages as sanitized markdown
The AI chat drawer was rendering assistant responses as plain text,
so code blocks, lists, tables and inline formatting came through as
literal asterisks and backticks — noisy and hard to scan.

Adds a shared renderMarkdown() helper in resources/scripts/utils/
markdown.ts that parses GFM markdown via marked and sanitizes the
result with DOMPurify before handing it to Vue's v-html. AiChatMessage
uses the helper for assistant messages only; user messages stay as
plain text since markdown syntax in their own typed input would be
surprising.

Assistant bubbles get the Tailwind `prose prose-sm` classes from the
already-enabled @tailwindcss/typography plugin so headings, lists and
code blocks inherit sensible defaults without per-element styling.

Security: DOMPurify runs in its default browser profile, which strips
<script>, event handlers, javascript: URLs and every other XSS vector.
The AI provider isn't a trusted source — it can echo arbitrary user
input and tool-call results from the database — so sanitization is
non-negotiable even though the immediate source is our own backend.
2026-04-11 20:58:41 +02:00