Compare commits

8 Commits
Author SHA1 Message Date
libretech 901a3b385f feat(product): price standalone products from the cover alone
build / build (push) Successful in 8m52s
Recovered work in progress from 2026-05. A product-cover can be marked
standalone, and a standalone product (Journaling Booklet) prices as the
cover price without the pattern/pages/ruling lookups, which do not exist
for it. The remaining component lookups are now guarded so a missing
relation yields null instead of calling findOne with undefined.
2026-09-23 22:10:06 +02:00
libretech 7f7cacc448 ci: bump runner-image pin v0.1.0 → v0.2.0 (#4)
build / build (push) Successful in 5m57s
Co-authored-by: Michael Czechowski <mail@dailysh.it>
Co-committed-by: Michael Czechowski <mail@dailysh.it>
2026-05-04 23:46:06 +02:00
libretech fa2a81ecd1 fix(ci): correct image digest separator
build / build (push) Successful in 7m47s
2026-04-30 12:20:33 +02:00
libretech 8e0554572b ci: digest-pin runner-image v0.1.0
build / build (push) Failing after 0s
2026-04-30 12:17:55 +02:00
libretech 3213a5a400 ci: update runner-image path to public/ namespace
build / build (push) Failing after 1s
2026-04-30 11:57:23 +02:00
libretech 5c36ce7dbb chore: update action ref to public/actions
build / build (push) Successful in 6m4s
2026-04-29 22:50:14 +02:00
libretech 9600bc8fd3 chore: slim build.yml to use shared docker-build action (#3)
build / build (push) Successful in 6m43s
2026-04-29 22:08:31 +02:00
libretech defd078e4b feat(cms): white-label email templates and API doc config (#2)
Build and publish / build (push) Successful in 7m11s
## Summary

- Replace all hardcoded muellerprints brand identity in email templates with template variables sourced from env vars
- Inject shop context into both `sendInvoice` and `sendDeliveryNote` template calls in `order.ts`
- Make API documentation title/description env-var driven

## New env vars (all optional with safe defaults)

| Var | Default | Used in |
|-----|---------|---------|
| `SHOP_NAME` | `"Shop"` | Email subject, body, footer |
| `SHOP_CONTACT_NAME` | `""` | Email footer |
| `SHOP_ADDRESS` | `""` | Email footer |
| `SHOP_PHONE` | `""` | Email footer |
| `SHOP_EMAIL` | `ADMIN_EMAIL_ADDRESS` | Email footer |
| `SHOP_LOGO_URL` | `""` | Email header logo |
| `SHOP_SECONDARY_LOGO_URL` | `""` | Email footer logo |
| `API_TITLE` | `"Paperwork API"` | Swagger/OpenAPI title |
| `API_DESCRIPTION` | `"Paperwork API"` | Swagger/OpenAPI description |

## Test plan

- [ ] Set `SHOP_NAME=TestShop` and trigger invoice send → confirm subject and body use `TestShop`
- [ ] Leave `SHOP_NAME` unset → confirm default `"Shop"` appears
- [ ] Set `SHOP_LOGO_URL` → confirm logo renders in email header; unset → confirm no broken `<img>` tag
- [ ] Check Strapi admin `/documentation` with `API_TITLE=MyAPI` env var

Closes #1

Reviewed-on: #2
Co-authored-by: Michael Czechowski <mail@dailysh.it>
Co-committed-by: Michael Czechowski <mail@dailysh.it>
2026-04-29 20:13:23 +02:00
12 changed files with 134 additions and 92 deletions
+6 -37
View File
@@ -1,4 +1,4 @@
name: Build and publish
name: build
on:
push:
@@ -7,49 +7,18 @@ on:
pull_request:
branches: [main]
# Required secrets:
# REGISTRY git.librete.ch
# REGISTRY_USER libretech-bot
# REGISTRY_PASS bot PAT (write:package; bot is in libreshop Owners team)
# Required variable:
# PUBLISH_ENABLED "true" to actually push (off = build-only on PRs)
#
# Image: git.librete.ch/libreshop/cms
# main pushes → :main + :sha-<short>
# tag pushes → :<tag> + :latest
jobs:
build:
runs-on: ubuntu-latest
container:
image: git.librete.ch/libretech/runner-image:v1
image: git.librete.ch/public/runner-image:v0.2.0@sha256:f60c587d3c0b0aac04a572db5349e27672bf76baec2ce547a3dcc28cebcf1b7e
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Login (only when publishing)
if: ${{ vars.PUBLISH_ENABLED == 'true' }}
uses: docker/login-action@v3
- uses: https://git.librete.ch/public/actions/.gitea/actions/docker-build@main
with:
registry: ${{ secrets.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.REGISTRY }}/libreshop/cms
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,format=short
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
- uses: docker/build-push-action@v6
with:
context: .
push: ${{ vars.PUBLISH_ENABLED == 'true' && github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
registry_user: ${{ secrets.REGISTRY_USER }}
registry_pass: ${{ secrets.REGISTRY_PASS }}
publish: ${{ github.event_name == 'push' && vars.PUBLISH_ENABLED == 'true' }}
+20 -1
View File
@@ -2,7 +2,26 @@
All notable changes to libreshop/cms are documented here.
## Unreleased
## [0.1.1] - 2026-04-29
### Changed
- White-label email templates: replace all hardcoded muellerprints company
identity (name, contact, address, phone, email, logo URLs) with
`<%= shopName %>` / `<%= shopContactName %>` / `<%= shopAddress %>` /
`<%= shopPhone %>` / `<%= shopEmail %>` / `<%= shopLogoUrl %>` /
`<%= shopSecondaryLogoUrl %>` template variables, sourced from env vars
`SHOP_NAME`, `SHOP_CONTACT_NAME`, `SHOP_ADDRESS`, `SHOP_PHONE`,
`SHOP_EMAIL`, `SHOP_LOGO_URL`, `SHOP_SECONDARY_LOGO_URL`.
- `config/constants.ts`: export shop brand constants from env.
- `src/api/order/services/order.ts`: inject shop context into template calls.
- `config/plugins.ts`: API doc title/description driven by `API_TITLE` /
`API_DESCRIPTION` env vars (defaults: `"Paperwork API"`).
- `src/extensions/documentation/config/settings.json`: neutral placeholder.
Closes #1
## [0.1.0] - 2026-04-29
- Extracted from `mp/cms/` (2026-04-29). The component history before
the extraction lives in the `muellerprints` repository.
+8
View File
@@ -10,6 +10,14 @@ export const paypalEnvironment = process.env.PAYPAL_ENVIRONMENT! === "production
export const adminEmail = process.env.ADMIN_EMAIL_ADDRESS!;
export const shopName = process.env.SHOP_NAME ?? "Shop";
export const shopContactName = process.env.SHOP_CONTACT_NAME ?? "";
export const shopAddress = process.env.SHOP_ADDRESS ?? "";
export const shopPhone = process.env.SHOP_PHONE ?? "";
export const shopEmail = process.env.SHOP_EMAIL ?? process.env.ADMIN_EMAIL_ADDRESS ?? "";
export const shopLogoUrl = process.env.SHOP_LOGO_URL ?? "";
export const shopSecondaryLogoUrl = process.env.SHOP_SECONDARY_LOGO_URL ?? "";
// TODO: Should be retrieved from DepotApi
export const vatIncludedDecimal = 1.19;
// TODO: Should be retrieved from DepotApi
+4 -8
View File
@@ -10,15 +10,11 @@ export default ({ env }) => ({
openapi: "3.0.1",
info: {
version: "1.0.0",
title: "MUELLERPTINTS. Paperwork",
description: "API Documentation for MUELLERPRINTS. Paperwork",
title: env("API_TITLE", "Paperwork API"),
description: env("API_DESCRIPTION", "Paperwork API"),
termsOfService: false,
contact: {
name: "Michael W. Czechowski",
email: "mail@dailysh.it",
url: "https://dailysh.it"
},
license: "Copyright (C) 2024 Michael W. Czechowski",
contact: false,
license: false,
externalDocs: false
},
"x-strapi-config": {
+9 -7
View File
@@ -3,7 +3,7 @@ import fs from "fs";
import { factories } from "@strapi/strapi";
import { sanitize } from "@strapi/utils";
import { ID } from "@strapi/database/dist/types";
import { vatDecimal, vatIncludedDecimal, baseUrl, adminEmail } from "../../../../config/constants";
import { vatDecimal, vatIncludedDecimal, baseUrl, adminEmail, shopName, shopContactName, shopAddress, shopPhone, shopEmail, shopLogoUrl, shopSecondaryLogoUrl } from "../../../../config/constants";
import { calculateTotalProductPrice, productDefaultParams } from "../../product/services/product";
import { CartProduct, Order, PdfBody } from "../../../../types";
import { pdfApi } from "../../../services/PdfApi";
@@ -81,12 +81,13 @@ export default factories.createCoreService("api::order.order", ({ strapi }) => (
strapi.log.info(`app:i:order-service: - Sending invoice for order ${uuid}`);
const oderUnsafe = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Partial<Order>;
const shopContext = { shopName, shopContactName, shopAddress, shopPhone, shopEmail, shopLogoUrl, shopSecondaryLogoUrl };
try {
const emailConfig: MessageBody = {
to_email: oderUnsafe.email,
subject: template(invoiceEmailTemplate.subject)(oderUnsafe),
message: template(invoiceEmailTemplate.text)({ baseUrl, ...oderUnsafe }),
html: template(invoiceEmailTemplate.html)({ baseUrl, ...oderUnsafe })
subject: template(invoiceEmailTemplate.subject)({ ...shopContext, ...oderUnsafe }),
message: template(invoiceEmailTemplate.text)({ baseUrl, ...shopContext, ...oderUnsafe }),
html: template(invoiceEmailTemplate.html)({ baseUrl, ...shopContext, ...oderUnsafe })
};
strapi.log.debug(`app:d:order-service: - Email config ${emailConfig.html}`);
@@ -118,12 +119,13 @@ export default factories.createCoreService("api::order.order", ({ strapi }) => (
sendDeliveryNote: async (uuid: string, blob?: ArrayBuffer) => {
strapi.log.info(`app:i:order-service: - Sending delivery note for order ${uuid}`);
const oderUnsafe = (await strapi.service("api::order.order").findOneByUuid(uuid)) as Partial<Order>;
const shopContext = { shopName, shopContactName, shopAddress, shopPhone, shopEmail, shopLogoUrl, shopSecondaryLogoUrl };
try {
const emailConfig: MessageBody = {
to_email: oderUnsafe.email,
subject: template(deliveryNoteEmailTemplate.subject)(oderUnsafe),
message: template(deliveryNoteEmailTemplate.text)(oderUnsafe),
html: template(deliveryNoteEmailTemplate.html)({ ...oderUnsafe, baseUrl })
subject: template(deliveryNoteEmailTemplate.subject)({ ...shopContext, ...oderUnsafe }),
message: template(deliveryNoteEmailTemplate.text)({ ...shopContext, ...oderUnsafe }),
html: template(deliveryNoteEmailTemplate.html)({ baseUrl, ...shopContext, ...oderUnsafe })
};
await mailApi.sendTextMessage(emailConfig);
strapi.log.info(`app:i:order-service: ✔ Sending delivery note for order ${uuid}`);
@@ -54,6 +54,10 @@
"price": {
"type": "decimal"
},
"standalone": {
"type": "boolean",
"default": false
},
"products": {
"type": "relation",
"relation": "oneToMany",
@@ -11,10 +11,26 @@ export default {
strapi.log.verbose("app:v:product-lifecycle: Before create", {
params
});
const cover = await strapi.entityService.findOne("api::product-cover.product-cover", params.data?.cover, { fields: ["price"] });
const pattern = await strapi.entityService.findOne("api::product-pattern.product-pattern", params.data?.pattern);
const pages = await strapi.entityService.findOne("api::product-page.product-page", params.data?.pages, { fields: ["price"] });
const ruling = await strapi.entityService.findOne("api::product-ruling.product-ruling", params.data?.ruling, { fields: ["price"] });
const cover = params.data?.cover
? await strapi.entityService.findOne("api::product-cover.product-cover", params.data.cover, { fields: ["price", "standalone"] })
: null;
// Standalone products (e.g. Journaling Booklet) have no pattern/pages/ruling.
// Price is the cover price alone — skip combinatorial component lookups.
if (params.data?.standalone || (cover as any)?.standalone) {
params.data.totalPrice = cover?.price ?? 0;
return;
}
const pattern = params.data?.pattern
? await strapi.entityService.findOne("api::product-pattern.product-pattern", params.data.pattern)
: null;
const pages = params.data?.pages
? await strapi.entityService.findOne("api::product-page.product-page", params.data.pages, { fields: ["price"] })
: null;
const ruling = params.data?.ruling
? await strapi.entityService.findOne("api::product-ruling.product-ruling", params.data.ruling, { fields: ["price"] })
: null;
const product = {
...params.data,
cover,
+18 -1
View File
@@ -108,6 +108,12 @@ export default factories.createCoreController("api::product.product", ({ strapi
strapi.log.verbose(`Fetching variants for product ${id}`);
const product = await getProductDetails(id as ID);
// Standalone products (e.g. Journaling Booklet) have no pattern — no variants.
if (!product.pattern?.id) {
return [];
}
const variants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", {
...productVariantParams,
filters: {
@@ -146,6 +152,11 @@ export default factories.createCoreController("api::product.product", ({ strapi
const product = await getProductDetails(id as ID);
const { pattern, cover, ruling, pages } = product;
// Standalone products (e.g. Journaling Booklet) have no pattern → no variants.
if (!pattern?.id) {
return { allProductPattern: [], productVariants: [], patterns: [] };
}
const productVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>("api::product.product", {
...productVariantParams,
filters: {
@@ -203,6 +214,11 @@ export default factories.createCoreController("api::product.product", ({ strapi
const product = await getProductDetails(id as ID);
// Standalone products (e.g. Journaling Booklet) have no pattern → no variant groups.
if (!product.pattern?.id) {
return { pages: [], cover: [], ruling: [] };
}
const allProductPatternVariants = await strapi.entityService.findMany<"api::product.product", ProductVariantParams>(
"api::product.product",
{
@@ -335,7 +351,8 @@ export default factories.createCoreController("api::product.product", ({ strapi
const productGroups = new Map();
allProductsSanitized.forEach((product) => {
const key = `${product.cover.id}-${product.pattern.id}`;
// Standalone products (e.g. Journaling Booklet) have no pattern — never grouped.
const key = product.pattern?.id ? `${product.cover?.id}-${product.pattern.id}` : `standalone-${product.id}`;
if (!productGroups.has(key) || calculateTotalProductPrice(product) < calculateTotalProductPrice(productGroups.get(key))) {
productGroups.set(key, product);
}
@@ -2,8 +2,8 @@
"openapi": "3.0.1",
"info": {
"version": "1.0.0",
"title": "MUELLERPTINTS. Paperwork",
"description": "API Documentation for MUELLERPRINTS. Paperwork",
"title": "Paperwork API",
"description": "Paperwork API",
"termsOfService": false,
"contact": false,
"license": false
+31 -31
View File
@@ -1,5 +1,5 @@
export const invoiceEmailTemplate = {
subject: "MUELLERPRINTS Rechnungsnummer <%= invoiceNumber %>",
subject: "<%= shopName %> Rechnungsnummer <%= invoiceNumber %>",
text: `Rechnungsnummer: <%= invoiceNumber %>
Ausstellungsdatum: <%= new Date(acceptedTermsAndConditionsAt).toLocaleDateString("de-DE") %>
@@ -7,7 +7,7 @@ Ausstellungsdatum: <%= new Date(acceptedTermsAndConditionsAt).toLocaleDateString
Hallo <%= invoiceAddressStructured.givenName %>,
vielen Dank für deinen Einkauf bei MUELLERPRINTS.
vielen Dank für deinen Einkauf bei <%= shopName %>.
Deine Bestellnummer: <%= id %>
Zur Bestellübersicht: <%= baseUrl %>/checkout/result/<%= uuid %>
@@ -49,14 +49,14 @@ inkl. MwSt. (19%): <%= VAT.toFixed(2) %> EUR
Deine Rechnung kannst du jederzeit über diesen Link herunterladen: <%= baseUrl %><%= invoice.url %>
<% } %>
Vielen Dank, dass du dich für MUELLERPRINTS entschieden hast.
Vielen Dank, dass du dich für <%= shopName %> entschieden hast.
------------------
MUELLERPRINTS
Max Müller
Rotenbergstraße 39, 70190 Stuttgart
T +49 (0)711/262 49 64
paperwork@muellerprints.de`,
<%= shopName %>
<%= shopContactName %>
<%= shopAddress %>
<%= shopPhone ? "T " + shopPhone : "" %>
<%= shopEmail %>`,
html: `<!DOCTYPE html>
<html lang="de">
<head>
@@ -74,7 +74,7 @@ paperwork@muellerprints.de`,
<!-- Header -->
<tr>
<td style="background-color: #E31E26; padding: 30px 40px;">
<img src="https://muellerprints-paperwork.com/paperwork-logo.png" alt="MUELLERPRINTS PAPERWORK" style="max-width: 200px; height: auto;">
<% if (shopLogoUrl) { %><img src="<%= shopLogoUrl %>" alt="<%= shopName %>" style="max-width: 200px; height: auto;"><% } %>
</td>
</tr>
@@ -83,7 +83,7 @@ paperwork@muellerprints.de`,
<td style="padding: 40px;">
<!-- Greeting -->
<h1 style="margin: 0 0 20px; color: #333; font-size: 24px; font-weight: normal;">
Vielen Dank für deinen Einkauf bei MUELLERPRINTS.
Vielen Dank für deinen Einkauf bei <%= shopName %>.
</h1>
<!-- Order Info -->
@@ -197,7 +197,7 @@ paperwork@muellerprints.de`,
<% } %>
<p style="color: #666; font-size: 14px; margin: 20px 0;">
Vielen Dank, dass du dich für MUELLERPRINTS entschieden hast.
Vielen Dank, dass du dich für <%= shopName %> entschieden hast.
</p>
</td>
</tr>
@@ -209,15 +209,15 @@ paperwork@muellerprints.de`,
<tr>
<td style="color: #666; font-size: 12px; line-height: 1.5;">
<p style="margin: 0 0 10px;">
MUELLERPRINTS<br>
Max Müller<br>
Rotenbergstraße 39, 70190 Stuttgart<br>
T +49 (0)711/262 49 64<br>
paperwork@muellerprints.de
<%= shopName %><br>
<% if (shopContactName) { %><%= shopContactName %><br><% } %>
<% if (shopAddress) { %><%= shopAddress %><br><% } %>
<% if (shopPhone) { %>T <%= shopPhone %><br><% } %>
<% if (shopEmail) { %><%= shopEmail %><% } %>
</p>
</td>
<td style="text-align: right; vertical-align: bottom;">
<img src="https://www.muellerprints.de/images/digi_logo.png" alt="MUELLERPRINTS" style="max-width: 100px; height: auto;">
<% if (shopSecondaryLogoUrl) { %><img src="<%= shopSecondaryLogoUrl %>" alt="<%= shopName %>" style="max-width: 100px; height: auto;"><% } %>
</td>
</tr>
</table>
@@ -243,7 +243,7 @@ paperwork@muellerprints.de`,
};
export const deliveryNoteEmailTemplate = {
subject: "Your Delivery Note <%= deliveryNoteNumber %> from MUELLERPRINTS",
subject: "Your Delivery Note <%= deliveryNoteNumber %> from <%= shopName %>",
text: `Dear Customer,
Thank you for your Your delivery note <%= deliveryNoteNumber %> is attached to this email.
@@ -272,14 +272,14 @@ Delivery Address:
If you have any questions about your delivery, please don't hesitate to contact us.
Best regards,
MUELLERPRINTS PAPERWORK
<%= shopName %>
------------------
MUELLERPRINTS PAPERWORK
Max Müller
Rotenbergstraße 39, 70190 Stuttgart
T +49 (0)711/262 49 64
paperwork@muellerprints.de`,
<%= shopName %>
<%= shopContactName %>
<%= shopAddress %>
<%= shopPhone ? "T " + shopPhone : "" %>
<%= shopEmail %>`,
html: `<!DOCTYPE html>
<html lang="de">
<head>
@@ -295,7 +295,7 @@ paperwork@muellerprints.de`,
<!-- Header -->
<tr>
<td style="background-color: #E31E26; padding: 30px 40px;">
<img src="https://muellerprints-paperwork.com/paperwork-logo.png" alt="MUELLERPRINTS PAPERWORK" style="max-width: 200px; height: auto;">
<% if (shopLogoUrl) { %><img src="<%= shopLogoUrl %>" alt="<%= shopName %>" style="max-width: 200px; height: auto;"><% } %>
</td>
</tr>
@@ -407,15 +407,15 @@ paperwork@muellerprints.de`,
<tr>
<td style="color: #666; font-size: 12px; line-height: 1.5;">
<p style="margin: 0 0 10px;">
MUELLERPRINTS PAPERWORK<br>
Max Müller<br>
Rotenbergstraße 39, 70190 Stuttgart<br>
T +49 (0)711/262 49 64<br>
paperwork@muellerprints.de
<%= shopName %><br>
<% if (shopContactName) { %><%= shopContactName %><br><% } %>
<% if (shopAddress) { %><%= shopAddress %><br><% } %>
<% if (shopPhone) { %>T <%= shopPhone %><br><% } %>
<% if (shopEmail) { %><%= shopEmail %><% } %>
</p>
</td>
<td style="text-align: right; vertical-align: bottom;">
<img src="https://www.muellerprints.de/images/digi_logo.png" alt="MUELLERPRINTS" style="max-width: 100px; height: auto;">
<% if (shopSecondaryLogoUrl) { %><img src="<%= shopSecondaryLogoUrl %>" alt="<%= shopName %>" style="max-width: 100px; height: auto;"><% } %>
</td>
</tr>
</table>
+11 -1
View File
@@ -110,8 +110,12 @@ async function generateNewProducts(strapi: Strapi, componentType: string, compon
strapi.log.debug(`app:d:lifecycle-factory ${JSON.stringify({ covers, patterns, pages, rulings })}`);
// Standalone covers (e.g. the Journaling Booklet) are not combinatorial —
// their product is created manually and must never be fanned out.
const sellableCovers = covers.filter((cover) => cover && !(cover as ProductCover).standalone);
// Generate new combinations
for (const cover of covers) {
for (const cover of sellableCovers) {
for (const pattern of patterns) {
for (const page of pages) {
for (const ruling of rulings) {
@@ -181,6 +185,12 @@ async function updateRelatedProducts(strapi: Strapi, componentType: string, comp
for (const product of products) {
try {
// Standalone products (cover-only, e.g. Journaling Booklet) are not
// factory-managed — never rename/reslug/delete them.
if (!product.pattern?.id) {
continue;
}
// Get the current components for this product
const cover = product.cover?.id;
const pattern = product.pattern?.id;
+1
View File
@@ -453,6 +453,7 @@ export interface ApiProductCoverProductCover extends Schema.CollectionType {
number
> &
Attribute.DefaultTo<0>;
standalone: Attribute.Boolean & Attribute.DefaultTo<false>;
updatedAt: Attribute.DateTime;
updatedBy: Attribute.Relation<"api::product-cover.product-cover", "oneToOne", "admin::user"> & Attribute.Private;
};