Commit Graph
208 Commits
Author SHA1 Message Date
Nick SzaunigandClaude Opus 5.5 eda2c11fe3 chore(k8s): Wabenhain auf Build sha-4827c8f (E-Rechnung, Storno, CRM-Export)
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Ausgerollt am 2026-10-12 nach Sicherung wabenhain-db-backup-pre-einvoice;
Migration 20261012010000_cancellation_invoices angewendet.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 09:39:59 +02:00
Nick SzaunigandClaude Opus 5.5 4827c8fa61 fix(invoice): Befunde aus dem Sicherheitsreview
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 9m45s
- E-Rechnung: in XML verbotene Steuerzeichen aus Namen/Anschriften entfernen,
  sonst waere das eingebettete XML bei einem einzigen Zeichen ungueltig.
- Rechnungskopie: genau eine Adresse; eine Liste ([email protected],[email protected]) wird beim
  Speichern abgelehnt und nie an nodemailer gegeben.
- Hinweis bei fehlgeschlagener Stornorechnung korrigiert (ein erneutes
  Stornieren ist nicht moeglich, die Stripe-Erstattung holt sie nach).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:57:29 +02:00
Nick SzaunigandClaude Opus 5.5 2995890b07 chore(k8s): App-Deployment auf festen Build gepinnt, wie im Cluster
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Image per Digest statt :latest, imagePullPolicy IfNotPresent, Pod- und
Container-securityContext (runAsNonRoot 1001, seccomp RuntimeDefault, keine
Rechteausweitung, alle Capabilities entfernt). kubectl diff gegen den Cluster
ist leer. Neue Builds per kubectl set image ...:sha-<commit>.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:48:00 +02:00
Nick SzaunigandClaude Opus 5.5 4b15ca0b8d fix(invoice): Rechnung ohne PDF/A statt Fehler, wenn Schriften fehlen
Docker Image bauen und veroeffentlichen / build-and-push (push) Canceled after 0s
Fehlt assets/fonts im Container, entsteht das PDF mit Standardschriften
und eingebettetem XML, nur nicht als PDF/A; der Fehler steht laut im Log.
Eine Rechnung darf nie an den Schriften scheitern.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:24:25 +02:00
Nick SzaunigandClaude Opus 5.5 051eedaf7f feat(erp): Kunden und Firmenanfragen fuer das CRM in ERPNext
Zwei nur lesende Endpunkte mit ERP_EXPORT_TOKEN:
- GET /api/erp/kunden: Kundenkonten (ohne Admins, ohne Passwort/2FA) mit
  Standardadresse; ERPNext fuehrt sie als Privatkunden.
- GET /api/erp/anfragen: Firmenanfragen; ERPNext fuehrt die Firma als
  B2B-Kunden mit Ansprechpartner und je Anfrage eine Chance.
Seitenweise ueber den Cursor "nach"; ERPNext holt jeweils die ganze Liste
und erkennt so auch geloeschte Konten.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:22:48 +02:00
Nick SzaunigandClaude Opus 5.5 4d1bcbbdb7 feat(invoice): Stornorechnung und Rechnungskopie an Admin-Adresse
Rechnungskopie: unter Einstellungen > Firma > "Rechnungskopie an" laesst sich
eine Adresse hinterlegen; jede Rechnung und Stornorechnung geht zusaetzlich
als eigene Mail mit derselben E-Rechnung dorthin (lib/invoice-mail.ts).

Stornorechnung: eine Rechnung wird nie geloescht. Bei Stornierung im Admin,
erstatteter Retoure oder voller Erstattung bei Stripe entsteht genau eine
Stornorechnung ST-JJJJ-NNNN (eigener lueckenloser Nummernkreis) mit allen
Positionen und Bezug auf die Rechnung, als E-Rechnung (Gutschrift 381) an
die Kundschaft und als Kopie. Gleichzeitige Ausloeser (Admin-Storno und
Stripe-Webhook derselben Erstattung) enden bei einer Stornorechnung.
Bestellungen mit Stornorechnung lassen sich nicht wieder oeffnen.

ERP-Export: GET /api/erp/rechnungen?art=storno liefert Stornorechnungen mit
Bezug (cancels), Rechnungen tragen cancelledBy; PDFs auch fuer ST-Nummern.
Admin-Bestelldetail zeigt die Stornorechnung mit Download.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 06:19:42 +02:00
Nick SzaunigandClaude Opus 5.5 508ff24aa7 feat(invoice): Rechnungen als E-Rechnung (ZUGFeRD/Factur-X EN 16931)
Jedes Rechnungs-PDF ist jetzt eine hybride E-Rechnung: PDF/A-3b mit
eingebettetem factur-x.xml (CII D16B, Profil EN 16931). Netto und Steuer je
Satz kommen unveraendert aus der Aufstellung des PDFs, Versand und Rabatt
stehen als Zu- und Abschlag je Satz; XML und PDF tragen dieselben Summen.

- lib/einvoice/cii.ts: XML-Erzeugung (Rechnung 380, Stornorechnung 381 mit
  Bezug), ohne USt-IdNr. oder bei Positionen ohne Satz nur PDF mit Warnung
- lib/einvoice/facturx-pdf.ts: PDF/A-3b, Factur-X-XMP mit Erweiterungsschema,
  eingebettete Liberation-Schriften (gleiche Laufweiten wie Helvetica/Times)
- invoice-pdf: Darstellung als Stornorechnung (negative Betraege, Bezug)
- Geprueft mit XSD, CEN-Schematron EN 16931 1.3.16 und Mustang 2.26
  (veraPDF: PDF/A-3b konform, XML valid)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 05:32:44 +02:00
Nick SzaunigandClaude Opus 5.5 f28e5e9d84 feat(k8s): Sicherung und Hintergrund-Jobs lauffaehig machen
- Werkzeug-Image wabenhain-tools (Dockerfile "AS tools", Muster wie
  artista-modelcamp-tools): Im App-Image fehlten tsconfig.json und Pakete
  wie stripe, Bestandsabgleich und Bestellfreigabe brachen mit
  "Cannot find module" ab. Die CI baut es neben dem App-Image.
- Datenbank-Sicherung: PV auf der Storage Box (Customer/Wabenhain), der
  Unterordner db-backup entsteht per subPath. Vorher blieb der PVC ohne
  StorageClass haengen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 08:15:51 +02:00
Nick SzaunigandClaude Opus 5.5 b246c9ff4e feat(erp): read-only invoice export for ERPNext
ERPNext (collab.vycept.com) now picks up every shop invoice with its PDF,
books it as a Wabenhain sales invoice and matches it to the Stripe payment
via the PaymentIntent, so fees, revenue and the invoice document sit on one
booking.

- GET /api/erp/rechnungen?seit=<ISO>&limit=<1-200>: invoices oldest first,
  with order, payment intent, customer address (no phone, no notes), items
  and the VAT split per rate exactly as printed (lib/vat.ts), including the
  shipping and discount share per rate, so ERPNext books the same cents.
- GET /api/erp/rechnungen/<RE-YYYY-NNNN>/pdf: the stored invoice PDF.
- Bearer token ERP_EXPORT_TOKEN (min. 32 chars, timing-safe compare);
  without it the export is off. Failed attempts are rate limited.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 04:39:11 +02:00
Nick SzaunigandClaude Opus 5.5 7698d4cef3 chore(k8s): CPU- und Speicher-Reservierung entfernen
Requests stehen explizit auf 0 statt zu fehlen, weil Kubernetes einen
fehlenden Request auf das Limit setzt und dann noch mehr reserviert.
Die Limits bleiben als Obergrenze.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-10 04:08:40 +02:00
Nick SzaunigandClaude Opus 5.5 93b589ff04 feat(stock): keep shop stock in sync with ApiCore, write sales back
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m40s
ApiCore now owns the remaining stock per batch; the shop sells from it
and reports every paid sale and every return back.

- Outbox apicore_stock_events, written in the same transaction as the
  status change: paid -> SALE, cancelled-after-payment / refunded return
  -> RETURN onto the batches of the sale, reopened paid order -> SALE.
  Whether a return is due is decided by the open sales in the outbox,
  so unpaid orders never book and nothing is booked twice.
- Delivery right after the response (next/server after()) plus CronJob
  wabenhain-apicore-stock every 10 minutes with 1/5/15/60 min backoff;
  ApiCore deduplicates by event id. Rejected bookings stop and show up
  on the dashboard.
- Reconcile: shop stock = ApiCore remaining - unconfirmed bookings -
  reservations of unpaid orders, in a serializable transaction like the
  checkout. Triggered after bookings, by the signed ApiCore webhook
  stock.changed (ids only, numbers are fetched) and by the sync button.
- Manufactured goods (candles, propolis products, ...): one shop product
  per ApiCore article via Product.apicoreProductId, drafts created by the
  sync, existing products linkable in the product form; new category
  SONSTIGES. Stock field is read-only for linked products.
- OrderItem.apicoreAllocations keeps which lots were sold (recalls).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 21:16:54 +02:00
Nick SzaunigandClaude Opus 5.5 641afa1ae4 fix(harvests): allow several harvests of the same type per year
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m15s
The ApiCore sync failed with "Unique constraint failed on the constraint:
harvests_year_type_key" as soon as ApiCore had two harvests of the same
honey type in one year, which is normal (several extractions per season).
Every bottling of that harvest was then skipped too.

- Drop the (year, type) unique index. The slug is now the only unique,
  public identifier: /ernten/<slug>, e.g. bluetenhonig-2026, then
  bluetenhonig-2026-07-14 for a further harvest of the same type.
- New Harvest.apicoreHarvestId as the sync key, backfilled from the old
  "apicore-<id>" slugs; the sync gives those rows a readable slug.
- Slugs are generated by one shared slugify() that normalizes Unicode
  (decomposed umlauts) and writes out umlauts. The migration repairs
  manual slugs created by the old umlaut bug ("fr-htracht-2026").
- Slug collisions are resolved by the database unique index, so parallel
  writers cannot produce duplicates; known slugs are skipped up front to
  keep the logs free of handled P2002 errors.
- Old URLs /ernten/<year>/<type> answer with 308 to the new address.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 04:25:02 +02:00
Nick SzaunigandClaude Opus 5.5 f1c0da4673 feat(admin): archive tabs for business inquiries and reviews
Inquiries: completed and rejected inquiries move to an "Archiv" tab;
resetting the status brings them back. Reviews: new "Ablehnen" action
(Review.rejectedAt) instead of only deleting; the archive shows decided
reviews in two columns, accepted and rejected, and either decision can
be reversed. Pending counters in the sidebar and dashboard exclude
rejected reviews, and the product page tells the author their review was
not published instead of "still being checked".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-02 04:13:07 +02:00
Nick SzaunigandClaude Opus 5.5 866d3726d1 fix(apicore-sync): explain that HTTP 403 can mean an IP allowlist block
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m46s
ApiCore API keys can now be limited to source addresses; a 403 then
usually means this server's address is not on the key's allowlist.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 02:42:23 +02:00
Nick SzaunigandClaude Opus 5.5 5c6d99d0a3 docs(einrichtung): ApiCore sync uses an API key, not a service account
The setup guide still listed APICORE_SYNC_EMAIL/APICORE_SYNC_PASSWORD,
which the client no longer reads since 3bc0693.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 01:09:53 +02:00
Nick SzaunigandClaude Opus 5.5 f71bcb2b67 feat(inhalte): text placeholder {{USt-IdNr}} for the VAT ID
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m53s
Admin texts can now contain the placeholder {{USt-IdNr}}. Wherever
content is read (lib/content.ts), it is replaced with the VAT ID
maintained under Admin > Einstellungen > Firmendaten, so every text
follows a change automatically. The placeholder is matched case- and
space-insensitively; an empty VAT ID inserts nothing, and unknown
placeholders stay visible so typos show up. Images, videos, select
fields and the source field itself are never rewritten.

The default imprint text uses the placeholder instead of a hard-coded
number, and the settings page lists the available placeholders.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 00:59:04 +02:00
Nick SzaunigandClaude Opus 5.5 77bdd9958c feat(rechnung): branded invoice with VAT ID and per-rate grouping
The invoice PDF and the invoice page now share one branded layout:
logo (uploaded brand logo, otherwise the drawn honeycomb mark) and
wordmark in the header, honey accents, a forest-green table header,
a meta box with invoice number, date, order number and VAT ID, and a
footer with address, contact, VAT ID and page numbers on every page.

A mixed cart (e.g. honey at 7 % and a beeswax candle at 19 %) lists
its items in one block per VAT rate with a subtotal each. Next to the
totals, a VAT table shows net, VAT and gross per rate plus a sum row.
Long invoices continue on further pages with the table header repeated.

The VAT identification number is a new company field
(company.vatId), editable in Admin > Inhalte, defaulting to the
number already stated in the imprint.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 00:49:59 +02:00
Nick SzaunigandClaude Opus 5.5 1daef79026 feat(cart): open the cart drawer only after the drip has run down
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m20s
The drawer used to slide in 180 ms after the honey drop landed and
covered the glow and the drip on the cart icon. It now waits until the
drip has finished (about 1.4 s). Without a visible flight (reduced
motion, icon off-screen) it still opens almost at once. The drip
timing lives in cart-flight.tsx so both files share one source.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 00:34:38 +02:00
Nick SzaunigandClaude Opus 5.5 15a457f66e feat(ernten): show only the remaining jars per batch
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 4m6s
The batch list on a harvest page no longer states the batch's total
size ("12 von 300 Gläsern verfügbar"). It now reads "Es sind noch 12
Gläser verfügbar", with dedicated wording for a single jar and for a
sold-out batch, which reads scarcer and nudges customers to buy.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-01 00:23:20 +02:00
Nick SzaunigandClaude Opus 5.5 55ae867f68 fix(cart): keep the cart icon unchanged while it glows
The landing glow was a larger, filled basket with a thicker stroke and
a blurred halo, so the icon looked bigger during the effect. The glow
is now an exact copy of the icon (same size, same stroke width, no
fill, no scaling) that only changes colour to honey for a moment.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-30 23:57:52 +02:00
Nick SzaunigandClaude Opus 5.5 e19827d6af feat(cart): honey glow and drip when the drop lands in the cart
After the flying honey drop reaches the header cart icon, the basket
now lights up twice in honey colour with a warm halo, and a small
honey drop gathers at its lower edge, stretches and runs down. With
reduced motion only a single calm glow remains, without the drip.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-30 23:45:54 +02:00
Nick SzaunigandClaude Opus 5.5 3107a02588 feat(konto): settings page for security and privacy
Adds /konto/einstellungen, which gathers two-factor authentication,
"sign out on all devices", the Erntepost consent, the data export
(Art. 15 GDPR) and account deletion (Art. 17 GDPR) in one place.
The account overview now links there through a "Settings" card
instead of listing these blocks inline. Server actions are unchanged
apart from redirecting failures and revalidating the new page.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-30 21:20:37 +02:00
Nick SzaunigandClaude Opus 5.5 3bc0693e4d refactor(apicore-sync): API key and shop feed only, no password login
The sync now talks to ApiCore exclusively through the secured shop
interface: GET /shop-sync with an API key as bearer token. Removed:

- the email/password service-account login (and its MFA handling),
- the fallback to ApiCore's general read endpoints (/harvests,
  /products, /extractions, /bottling-batches, /apiaries).

A missing interface (404) or a rejected key (401/403) now stops the run
with a specific message instead of falling back. APICORE_SYNC_EMAIL and
APICORE_SYNC_PASSWORD are gone from .env.example, README and the k8s
secret template; the system status needs APICORE_API_KEY.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 06:05:11 +02:00
Nick SzaunigandClaude Opus 5.5 55be1fe881 feat(apicore-sync): authenticate with an ApiCore API key
APICORE_API_KEY replaces the service-account login: the key is sent as
the bearer token directly, needs no password and no MFA exemption, may
only read the shop feed and can be revoked in ApiCore at any time. A
malformed key is rejected with a clear message before any request.
Email/password stays as fallback for ApiCore versions without keys.

The system status treats the key or the legacy login as configured.
README, .env.example and the k8s secret template describe the key.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-28 01:24:26 +02:00
Nick SzaunigandClaude Opus 5.5 5642f0c4af feat(apicore-sync): read from ApiCore's dedicated shop feed
The sync now fetches everything from ApiCore's GET /shop-sync in one call:
an allowlisted feed (harvest facts, article master data, bottlings) that
never contains internal notes and needs only the single permission
"shop-sync:read" instead of read access to harvests, extractions etc.

- Contract version is checked; an unknown version stops the run with a
  clear message and writes nothing. Extra fields are ignored.
- One consistent snapshot, processed only after it arrived completely: a
  failed fetch writes nothing (no more half state of new harvests and old
  batches); the batch-load-error path is gone.
- Falls back to the previous per-resource endpoints when ApiCore answers
  404, so both packages can be deployed independently.
- .env.example documents the reduced permission.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 20:30:33 +02:00
Nick SzaunigandClaude Opus 5.5 d38f4303ff feat(apicore-sync): field-level ownership between ApiCore and the shop
ApiCore stays the source of truth for beekeeping facts, Wabenhain for
everything sales-related; both remain separate packages talking only over
ApiCore's HTTP API.

- Batches: lot number, fill date, best-before date and filled jar count
  now follow ApiCore on every run (before: created once, corrections never
  arrived). remainingGlasses - the shop's own stock - is never written; if
  it exceeds a corrected fill quantity, the run reports it instead of
  silently adjusting.
- Batches are matched by the new Batch.apicoreBottlingId instead of the lot
  number, so correcting a lot number in ApiCore no longer creates a second
  batch. Existing synced batches are adopted once via their lot number.
- Harvests: date and year always follow ApiCore, also when published or
  edited; type, location and text stay as maintained in the admin.
- Products stay entirely shop-owned after creation (weight drives the
  legally required unit price, so it is never changed automatically).
- Admin panel: batch update count and stock-conflict notice; intro text
  states which system owns which fields.

Migration is additive (nullable unique column).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 20:14:35 +02:00
Nick SzaunigandClaude Opus 5.5 45ca0ea745 test(session): tamper inside the signature, not its last character
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m28s
The last base64url character of a 32-byte HMAC signature partly carries
padding bits that decoders ignore, so the tampered token was sometimes
still valid and the test failed at random. Changing a character in the
middle of the signature always alters the signature bytes.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 05:39:09 +02:00
Nick SzaunigandClaude Opus 5.5 0482fd490e fix(apicore-sync): keep internal notes out of the shop and protect edited drafts
- Harvest descriptions no longer include ApiCore's internal harvest notes.
  The description becomes public on "Veröffentlichen", so a draft published
  without a careful read could have carried operational notes into the shop.
- A repeated sync overwrote unpublished harvest drafts, undoing the admin's
  corrections (season instead of honey type, location, sales text). Drafts
  with an admin audit entry are now left alone and reported as protected.
- Every ApiCore request has a 20 s timeout; before, an ApiCore that accepted
  the connection but never answered left the admin action hanging.
- The sync panel shows the time of the last complete run.
- Client tests: timeout, failed fetch is never treated as an empty source,
  malformed responses are rejected as a whole.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 05:39:09 +02:00
Nick SzaunigandClaude Opus 5.5 e0b47faf45 feat(orders): reopen cancelled orders only after confirmation; accurate sales
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
Cancelled orders:
- Moving an order out of STORNIERT is possible again, but only after a
  warning is confirmed in the status form (OrderStatusForm); without
  the confirmation the server changes nothing.
- Reopening books the counter-entry to the cancellation: goods that the
  cancellation restocked are deducted again (atomically, only if enough
  stock is left, otherwise the order stays cancelled). A later second
  cancellation therefore restocks +1 again instead of leaving +2.
- The warning states that no new payment is collected.

Dashboard figures:
- One definition of a counted sale (lib/sales.ts): not unpaid (NEU),
  not cancelled, no refunded return. Used for revenue today/month, sold
  glasses, average cart, top products/categories, revenue trend and
  repeat customers.
- "Verkaufte Gläser" previously summed every order item, including
  cancelled orders and refunded returns.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 01:14:12 +02:00
Nick SzaunigandClaude Opus 5.5 dc1523aafd fix(orders): cancelled orders cannot be reopened (stock restocked twice)
After refund + cancellation the stock rose by 2 instead of 1. Cause:
the status form allowed moving an order out of STORNIERT. Cancelling
restocks the goods and refunds the payment; reopening did neither in
reverse, and the next cancellation passed the "not already cancelled"
claim again and restocked a second time (the refund was not repeated
because the payment was already REFUNDED, so only the stock showed it).

STORNIERT is now a final state:
- isOrderStatusChangeAllowed() in lib/inventory.ts next to the other
  cancel rules, with tests
- updateOrderStatusAction rejects the change up front with a clear
  message and moves non-cancel statuses with an atomic
  "status <> STORNIERT" update, so a cancellation from a second tab
  cannot be undone in between
- the order list and detail page no longer offer the status select for
  cancelled orders

Existing stock that was restocked twice has to be corrected by hand.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 00:45:31 +02:00
Nick SzaunigandClaude Opus 5.5 de82ad0233 feat(admin): settings page with tabs, system checks and email test
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m26s
"Inhalte" becomes Admin -> Einstellungen, grouped into tabs so each
area stands on its own: Firma & Versand, Marke, Startseite, Seiten,
Social Media, Rechtstexte and System & Tests. Tabs are plain links
(?reiter=...), bookmarkable and usable without JavaScript; saving
returns to the same tab.

System & Tests brings everything that checks the running shop into one
place:
- integration overview (Stripe, Stripe webhook, Microsoft 365 mail,
  Microsoft sign-in, Turnstile, Google reviews, DHL, ApiCore): only
  whether each is set up and the names of missing variables, never a
  value; warns when Stripe runs on test keys or mixes test/live
- the email test, now with the IPv4 network checks for
  smtp.office365.com:587 and login.microsoftonline.com:443

/admin/inhalte and /admin/e-mail-test redirect to the new page; the
separate "E-Mail-Test" menu entry is gone. The tab parameter and the
saved group are validated against a fixed list before they are used in
a redirect. Docs point to the new location and cover the ENETUNREACH
(IPv6) case.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-27 00:07:34 +02:00
Nick SzaunigandClaude Opus 5.5 b5311fae83 fix(mail): connect to Microsoft 365 over IPv4 and check network paths
The admin email test failed with "connect ENETUNREACH 2603:…:587".
nodemailer resolves smtp.office365.com to IPv4 and IPv6 addresses and
picks one at random; pods with an IPv6 interface but no IPv6 route to
the internet then fail depending on the draw, and the error shown is
always that of the last (IPv6) fallback.

- The shop now resolves smtp.office365.com via IPv4 only and connects
  to that address; the TLS certificate is still verified against
  smtp.office365.com (tls.servername), STARTTLS stays mandatory.
- A transport is built per send so a rotating Microsoft address never
  sticks.
- The email test first probes smtp.office365.com:587 and
  login.microsoftonline.com:443 over IPv4 and reports each result, so a
  blocked port (hoster firewall, NetworkPolicy) is distinguishable from
  a configuration error.

Verified against the real Microsoft endpoints: both probes succeed,
the connection is made via IPv4, TLS validates and the auth stage is
reached.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 22:41:49 +02:00
Nick SzaunigandClaude Opus 5.5 2943021fe9 fix(contact): keep the contact email editable and protect it from scrapers
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m20s
An earlier change rejected obfuscated entries such as
"info [á] wabenhain.com" in Admin -> Inhalte and replaced them with the
default address. The obfuscation was intentional, so:

- The admin accepts the entry as written (plain or obfuscated with
  [at], (at), {ät}, [á], <@>, [dot], [punkt]) and stores it unchanged;
  only entries that yield no valid address are rejected.
- lib/contact-email.ts turns the entry into the real address for
  mailto links and DHL labels.
- Public pages never contain the address in clear text: footer,
  contact page and every email in legal texts (Impressum, Datenschutz,
  Widerruf) render "info [at] wabenhain.com" plus an XOR-encoded payload
  with a fresh random key; the browser builds the clickable mailto link
  after load (components/protected-email.tsx).

Verified with a production build: the raw HTML of /, /kontakt,
/impressum, /datenschutz and /widerrufsbelehrung contains no plain
address, and after load each page shows mailto:[email protected].

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 20:35:50 +02:00
Nick SzaunigandClaude Opus 5.5 12675f381a docs: step-by-step setup guide for secrets, SSO and Microsoft 365 mail
docs/EINRICHTUNG.md walks from nothing to a running shop: tools and
access, generating database/session/admin values, the Entra ID app
registration used for both sign-in and mail (redirect URI, client
secret, SMTP.SendAsApp with admin consent, enterprise app object id),
unlocking the shared mailbox in Exchange Online, Stripe keys and webhook
events, optional services, a complete secret/config map table, rollout,
verification, maintenance (secret expiry) and troubleshooting keyed to
the messages of the admin email test. Linked from README and k8s/README.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 20:21:01 +02:00
Nick SzaunigandClaude Opus 5.5 11910dd6dd refactor(mail): send via the SSO app registration, drop SMTP basic auth
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m25s
Mail now uses the same Entra ID app registration as "Sign in with
Microsoft" (ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET), so
only one enterprise application is needed. The app additionally gets
the SMTP.SendAsApp permission; Exchange still limits it to the mailbox
granted via Add-MailboxPermission.

Password (basic auth) sending is removed entirely: SMTP_HOST, SMTP_PORT,
SMTP_PASSWORD and the separate SMTP_OAUTH_* settings are gone. The
transport is fixed to smtp.office365.com:587 with required STARTTLS.
SMTP_USER (sending mailbox) and SMTP_FROM remain.

- lib/entra.ts exposes readEntraAppCredentials(), shared by SSO and mail
- diagnosis drops basic-auth-only causes; hints name the ENTRA_* values
  and warn that an expired secret breaks sign-in and mail together
- admin e-mail test, .env.example, docker-compose, k8s manifests,
  preflight and README updated

Verified against Entra ID with fake values: token error classified,
client secret never exposed, missing ENTRA_CLIENT_SECRET reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 19:23:05 +02:00
Nick SzaunigandClaude Opus 5.5 0620e9d79a feat(mail): Microsoft 365 sending via OAuth 2.0 (client credentials)
Sending no longer needs a user password for Microsoft 365. When
SMTP_OAUTH_TENANT_ID, SMTP_OAUTH_CLIENT_ID and SMTP_OAUTH_CLIENT_SECRET
are set, the shop fetches an app-only token from Entra ID and signs in to
smtp.office365.com with XOAUTH2. This works with security defaults and
MFA, survives the end of SMTP basic auth (default off from end of
December 2026) and limits the app to the one mailbox Exchange grants it
(SMTP.SendAsApp + Add-MailboxPermission). Password login stays available
for other providers; a partial OAuth setup never silently falls back.

- lib/ms365-oauth.ts: token cached until 5 minutes before expiry,
  concurrent requests share one fetch, forced refresh after a server
  rejection, 15 s timeout, tenant id validated before it enters the URL,
  client secret redacted from every error.
- lib/smtp-diagnose.ts: explains AADSTS7000215 (secret ID instead of
  value), 7000222 (expired), 700016 (unknown app), 90002/900023 (tenant)
  and maps a rejected XOAUTH2 login to missing mailbox access.
- Admin e-mail test shows the auth mode and lists missing settings.
- k8s manifests, preflight check and README (step-by-step Entra and
  Exchange setup) updated; misplaced SMTP_PORT comment moved.

Verified against Entra ID and smtp.office365.com with fake values:
AADSTS90002 is classified as tenant error without leaking the secret,
an invalid token yields 535 5.7.3 and is classified as mailbox access.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 18:17:33 +02:00
Nick SzaunigandClaude Opus 5.5 302a18baa6 feat(mail): SMTP diagnosis and admin email test for Microsoft 365
Mail failures were only logged as a bare error, so a broken Microsoft
365 setup showed up as "no emails arrive" with no cause. Now:

- lib/smtp-diagnose.ts maps SMTP errors to a cause with German
  instructions: security defaults, SMTP AUTH disabled for tenant or
  mailbox, MFA/conditional access, SendAsDenied (SMTP_FROM differs from
  the mailbox), wrong credentials, basic-auth retirement, network, TLS.
  The password is redacted from any echoed server message.
- /admin/e-mail-test verifies login and sends a real test email to the
  signed-in admin only (no free recipient, rate limited, audit logged)
  and shows the server message, the cause and the settings in use.
- The transport now requires STARTTLS on port 587 so credentials are
  never sent unencrypted, strips a trailing newline from secrets (a
  common kubectl/base64 mistake) and logs the diagnosed cause.

Verified against smtp.office365.com with a fake account: STARTTLS
succeeds and "535 5.7.3" is classified as wrong credentials.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:48:40 +02:00
Nick SzaunigandClaude Opus 5.5 cef2248a45 fix(security): escape untrusted values in transactional email HTML
Customer input (first name, address, product names), order data, admin
fields (company data, carrier, tracking number) and URLs were inserted
into email HTML unescaped. A first name such as <a href="…">…</a> ended
up as a working link in a genuine shop email, usable for phishing.

All variable values in email HTML now go through escapeHtml
(lib/escape-html.ts, shared with the withdrawal receipt). Button links
only accept http(s), so a javascript: tracking URL is dropped. The plain
text part stays unescaped. Tests cover order confirmation, shipment,
status mails, layout company data and back-in-stock mails.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 17:42:49 +02:00
Nick SzaunigandClaude Opus 5.5 dcfad4e011 feat(legal): electronic withdrawal function and remaining compliance fixes
Withdrawal function (§ 356a BGB, mandatory since 19 June 2026):
- /vertrag-widerrufen: two-step flow "Vertrag widerrufen" ->
  "Widerruf bestätigen", no login, only name, order number and e-mail
  required. Prominent footer button on every page, prefilled link per
  order in the customer account.
- /api/widerruf stores every declaration (matched to the order only if
  order number and e-mail fit), mails a receipt with the declaration and
  the exact time of receipt, notifies ADMIN_EMAIL. Same response for
  matching and non-matching orders (no enumeration), origin check and
  rate limit instead of a CAPTCHA that could block a statutory right.
  User input is HTML-escaped and stripped of line breaks in headers.
- /admin/widerrufe lists declarations, open ones first, with an
  open-count badge and "mark as done".
- Withdrawal notice gains the statutory sentence on the online function.

Other fixes:
- Newsletter stores confirmedAt as proof of consent (Art. 7(1) GDPR).
- "Recently viewed" is only persisted with consent (§ 25(1) TDDDG);
  choosing "essential only" or withdrawing consent deletes it. The
  banner no longer promises audience-measurement cookies that do not
  exist.
- Admin rejects an invalid company e-mail; an obfuscated stored value
  falls back to the configured address so the legal notice stays valid.
- Privacy policy: Microsoft 365 as mail processor, log retention of at
  most 14 days, withdrawal-function processing, consent timestamps.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 11:14:02 +02:00
Nick SzaunigandClaude Opus 5.5 4ab1a7edae feat(legal): readable legal pages and corrected legal texts
Legal pages rendered admin text as bare paragraphs. LegalPage now has a
honeycomb header, a sticky table of contents with scroll tracking (a
collapsible one on mobile), anchor links, a reading progress bar and a
revision date pulled from a "Stand:" line. lib/legal-blocks.ts derives
lists, cookie/provider entries with labelled facts and safe links from
plain text, so the pages stay editable in the admin without markup.

Legal corrections (German law as of 2026):
- Widerrufsbelehrung now follows the official model (Anlage 1 und 2 zu
  Art. 246a EGBGB): recipient name/address/e-mail filled from company
  data, deadline rule, return period, refund details, diminished value
  and the model withdrawal form. The incomplete notice extended the
  withdrawal period to up to 12 months and 14 days. Honey is no longer
  declared perishable.
- Impressum: TMG references replaced by DDG; editorial contact gains an
  address.
- Datenschutz: removes the self-hosted vs. hosting-provider
  contradiction, adds PayPal/Klarna, reviews, back-in-stock
  notifications, storage period, no automated decisions, withdrawal of
  consent and the Art. 21 objection notice; newsletter double opt-in.
- AGB: payment methods and delivery area point to the live shipping
  page; adds contract-text and input-correction information.
- Cookie policy: storage period and provider for every entry.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 07:02:12 +02:00
Nick SzaunigandClaude Opus 5.5 86cc0ad310 feat(ui): Seite "Fuer Unternehmen" mit Leben - Glaeser, Biene, Zielgruppen
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m30s
- Hero: Titel gross auf dem Bienenfoto, darunter "Fuer ..." mit
  wechselnder Zielgruppe (Unternehmen, Kanzleien, Praxen, Hotels,
  Agenturen, Handwerksbetriebe - die Woerter aus dem Untertitel), eine
  Biene fliegt durchs Bild, Knopf springt zur Anfrage.
- Die drei Pakete wieder als Karten, jetzt mit gezeichnetem Honigglas:
  Klassik mit Banderole, Geschenk im Karton mit Schleife, Individuell mit
  Stift und Funkeln. Beim Hover hebt sich das Glas, die Schleife wippt.
  Ein Klick springt zum Formular und waehlt das Paket dort vor
  (?paket=, nur bekannte Schluessel).
- Anfrage: links Ueberschrift und das Glaeser-Foto (mitlaufend, leichte
  Parallaxe), rechts das Formular.

Neu im Woerterbuch nur Ueberschrift, Knopftext und die Zielgruppenliste;
keine neuen Aussagen. Dunkelmodus und reduzierte Bewegung beruecksichtigt.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 06:35:29 +02:00
Nick SzaunigandClaude Opus 5.5 1ff5f8c722 docs: list every cookie and storage entry in the cookie policy
The policy only named wabenhain_session, although the site sets ten
cookies and three localStorage entries. § 25 TDDDG requires the
information for every access to the user's device, so each entry now
states purpose, provider, storage period, flags and legal basis.

Also adds the legal-basis section (§ 25 Abs. 1/2 TDDDG, Art. 6 DSGVO),
third-party cookies (Stripe __stripe_mid/__stripe_sid, Microsoft,
Cloudflare Turnstile), browser controls, data subject rights, withdrawal
under Art. 7(3) DSGVO and a revision date. Corrects the claim that the
cart never leaves the device: it is sent when an order is submitted.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 06:34:55 +02:00
Nick SzaunigandClaude Opus 5.5 6950657c91 feat(ui): Dunkelmodus fuer Shop und Admin mit Umschalter
Rollen-Farben statt reiner Markenfarben, damit hell und dunkel aus einem
Satz Klassen entstehen:
- heading (Ueberschriften, Links, Rahmen): hell Waldgruen, dunkel Creme
- surface (abgesetzte Flaeche): hell Creme, dunkel angehobenes Dunkelgruen
- forest bleibt die dunkle Markenflaeche (Hero, Baender, Knoepfe,
  Admin-Leiste), cream der helle Text darauf
- honey-deep als fester Honigton fuer Flaechen; honey-dark (Text) wird
  im Dunkelmodus heller und bleibt lesbar
Codemod ueber src: text-/border-forest -> *-heading, bg-cream ->
bg-surface, bg-honey-dark -> bg-honey-deep. Im hellen Modus haben die
Rollen exakt die bisherigen Werte - das Erscheinungsbild bleibt gleich.

Umschalter (components/theme-toggle.tsx) Automatisch -> Hell -> Dunkel in
Kopfzeile, Mobilmenue und Admin-Leiste. Die Wahl liegt im Cookie "theme"
(nur light/dark, geprueft in lib/theme.ts); der Server setzt data-theme
schon beim Ausliefern, nichts blitzt hell auf. "Automatisch" folgt der
Systemeinstellung. Gewaehlter und automatischer Dunkelblock in
globals.css werden per Test auf Gleichheit geprueft.

Logo, Biene und Select-Pfeil nutzen die Rollenfarben. In der Kopfzeile
steht "Honig entdecken" zwischen 1280 und 1535 px nicht mehr doppelt
neben "Shop" (sonst stiess die Navigation an), der Slogan erscheint erst
ab 1680 px.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 06:12:44 +02:00
Nick SzaunigandClaude Opus 5.5 b77516b9f5 feat(ui): Startseite lebt - fliegende Biene, Imker-Portraet, bewegte Fotos
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m28s
- Eine gezeichnete Biene fliegt beim Scrollen eine geschwungene Bahn ueber
  "Aktuelle Ernte" und zieht eine gepunktete Honigspur hinter sich her;
  Fluegelschlag und leichtes Schweben per CSS (components/bee.tsx).
- Neuer Abschnitt nach der Philosophie: Portraet des Imkers in Wabenform,
  die Punkte des Wabenrings wandern an der Kante entlang, eine Biene
  umkreist das Bild. Daneben der eigene Satz von der Imkerei-Seite als
  Zitat - kein neuer Text.
- Herkunft-, Region- und Firmenkunden-Foto bewegen sich beim Scrollen
  leicht mit (Zoom und Parallax, nur transform; components/parallax-media).

Alles rein dekorativ (aria-hidden) und mit Pfad fuer reduzierte Bewegung:
die Biene sitzt dann still, Ring und Fotos stehen.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 05:57:45 +02:00
Nick SzaunigandClaude Opus 5.5 d52a762e8a fix: Produkt-Formular ueberschreibt keinen zwischenzeitlich gebuchten Bestand
Das Speichern schrieb den Formularwert immer fest in den Lagerbestand.
Wurde zwischen Oeffnen und Speichern ein Glas verkauft, kam es dadurch
wieder in den Bestand, ohne dass es je zurueckkam.

- Das Formular schickt den Bestand beim Oeffnen mit (quantityLoaded)
- Feld unveraendert: Bestand wird nicht angefasst
- Feld geaendert: Schreiben nur, wenn der Bestand noch dem geladenen Stand
  entspricht (Bedingung atomar im UPDATE); sonst wird nichts gespeichert
  und der aktuelle Wert angezeigt
- Fehlt quantityLoaded (alter Tab), muss neu geladen werden

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 05:55:36 +02:00
Nick SzaunigandClaude Opus 5.5 5848eb6ad5 fix: Bestand bei Storno nach erstatteter Retoure nicht doppelt zurueckbuchen
Wurde eine Retoure erstattet und die Bestellung danach auf "STORNIERT"
gesetzt, buchten beide Schritte die Ware zurueck. Bei einer Testbestellung
stieg der Bestand so von 105 auf 106 Glaeser.

- Stornieren bucht nur noch zurueck, wenn keine Retoure erstattet ist
  (needsRestockOnCancel in lib/inventory.ts)
- Retoure einer bereits stornierten Bestellung laesst sich nicht mehr
  erstatten, auch nicht im Wettlauf (Bedingung im atomaren Claim)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 03:12:37 +02:00
Nick SzaunigandClaude Opus 5.5 fdb6456207 feat(ui): Leerer Warenkorb zeigt verfuegbare Honige
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
Bisher stand dort nur ein Satz und ein Knopf zum Shop. Jetzt steht der
Hinweis als Spalte links, daneben bis zu zwei aktuell verfuegbare
Produkte (Honig zuerst) - dasselbe Muster wie "Aktuelle Ernte" auf der
Startseite. Nutzt die bereits geladenen availableProducts.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 02:14:52 +02:00
Nick SzaunigandClaude Opus 5.5 0fb176add8 feat(ui): Microsoft-Anmeldung nur wenn eingerichtet, in der Anmelde-Spalte
Der Knopf stand unter beiden Spalten (Anmelden und Konto erstellen),
obwohl er zur Anmeldung gehoert, und erschien auch ohne eingerichtete
Entra-Konfiguration - der Klick endete dann in der Meldung "SSO ist noch
nicht konfiguriert". Jetzt steht er unter dem Anmeldeformular und nur,
wenn isEntraConfigured() zutrifft. Die serverseitige Pruefung in der
Login-Route bleibt als zweite Absicherung.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 02:13:19 +02:00
Nick SzaunigandClaude Opus 5.5 774bc9cf00 feat(ui): Pakete fuer Firmenkunden als Auswahl-Karten im Formular
Die drei Pakete standen doppelt: als Deko-Kacheln (mit grossem
Waben-Wasserzeichen) ueber dem Formular und darin noch einmal als
Auswahlliste. Jetzt waehlt man sie direkt im Formular als Karten mit
sichtbarem Auswahl- und Fokuszustand; die Kacheln entfallen. Feldname
und Werte (package = KLASSIK/GESCHENK/INDIVIDUELL) sind unveraendert.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 02:11:29 +02:00
Nick SzaunigandClaude Opus 5.5 b588975317 feat(ui): Seitenabschluesse, Wissens-Verzeichnis, Kopfzeile ohne Ueberlauf
- Imkerei, Herkunft und Ernten endeten mit dem letzten Absatz direkt vor
  der Fusszeile. Neuer PageOutro: zwei naechste Wege als grosse
  Serif-Links mit Kurztext (Ernten/Shop bzw. Herkunft/Shop).
- Kopfzeile: bei 1280 px lief sie 54 px ueber (horizontale Scrollleiste),
  weil die nicht umbrechbare Slogan-Zeile unter dem Logo schon ab xl
  erschien. Jetzt erst ab 2xl; geprueft 1024 bis 1920 px ohne Ueberlauf.
- Wissen: Inhaltsverzeichnis statt zehn gleicher Bloecke mit je einem
  "Weiterlesen" - ganze Zeile ist der Link, Titel und Kurztext
  nebeneinander. Ungenutzter Schluessel readMore entfernt.
- Produktseite: bei ein, zwei Empfehlungen steht die Ueberschrift als
  erste Rasterspalte statt ueber einer einzelnen verlorenen Karte.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 02:05:25 +02:00