diff --git a/backoffice/app/app.py b/backoffice/app/app.py
index 54ccd3c..637446a 100644
--- a/backoffice/app/app.py
+++ b/backoffice/app/app.py
@@ -23,6 +23,9 @@ from sheets import Sheets
app = Flask(__name__, static_folder="static", static_url_path="")
CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "")
+# Separate read-only token for the public iCal feed (calendar apps can't send
+# the basic-auth header, so the feed is gated by this query token instead).
+ICS_TOKEN = os.environ.get("ICS_TOKEN", "")
# DB -> Sheets one-way mirror. Postgres is the source of truth; the Sheet is a
# best-effort projection. A mirror failure never fails the DB write.
@@ -270,6 +273,57 @@ def sync_all():
return jsonify({"synced": out})
+def _ics_dt(v):
+ if isinstance(v, datetime):
+ u = v if v.tzinfo else v.replace(tzinfo=timezone.utc)
+ return u.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ return None
+
+
+def _ics_esc(t):
+ return (str(t or "").replace("\\", "\\\\").replace(";", "\\;")
+ .replace(",", "\\,").replace("\n", "\\n"))
+
+
+@app.get("/api/bookings.ics")
+def bookings_ics():
+ """Read-only iCal feed for Apple/Google Calendar subscription. Gated by the
+ ICS_TOKEN query param (no header auth, so calendar apps can fetch it)."""
+ if not ICS_TOKEN or request.args.get("token") != ICS_TOKEN:
+ return Response("forbidden\n", status=403, mimetype="text/plain")
+ cid = request.args.get("client_id")
+ with db.connect() as conn, conn.cursor() as cur:
+ if cid:
+ cur.execute("SELECT * FROM bookings WHERE client_id = %s ORDER BY start_time", (cid,))
+ else:
+ cur.execute("SELECT * FROM bookings ORDER BY start_time")
+ rows = cur.fetchall()
+ now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ out = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//smb-crm//bookings//DE",
+ "CALSCALE:GREGORIAN", "METHOD:PUBLISH",
+ "X-WR-CALNAME:" + _ics_esc("Buchungen " + cid if cid else "Buchungen")]
+ for r in rows:
+ st = _ics_dt(r.get("start_time"))
+ if not st:
+ continue
+ summary = r.get("service") or "Termin"
+ if r.get("customer_name"):
+ summary += " – " + r["customer_name"]
+ out += ["BEGIN:VEVENT",
+ "UID:%s@smb-crm" % (r.get("booking_id") or now),
+ "DTSTAMP:%s" % (_ics_dt(r.get("created_at")) or now),
+ "DTSTART:%s" % st]
+ en = _ics_dt(r.get("end_time"))
+ if en:
+ out.append("DTEND:%s" % en)
+ out.append("SUMMARY:" + _ics_esc(summary))
+ if r.get("customer_contact"):
+ out.append("DESCRIPTION:" + _ics_esc("Kontakt: " + r["customer_contact"]))
+ out += ["STATUS:CONFIRMED", "END:VEVENT"]
+ out.append("END:VCALENDAR")
+ return Response("\r\n".join(out) + "\r\n", mimetype="text/calendar")
+
+
@app.get("/")
def index():
return Response(INDEX_HTML, mimetype="text/html")
diff --git a/backoffice/app/db.py b/backoffice/app/db.py
index 58c5213..554933a 100644
--- a/backoffice/app/db.py
+++ b/backoffice/app/db.py
@@ -21,7 +21,7 @@ TABLES = {
"cols": ["client_id", "business_name", "owner_name", "email", "phone",
"niche", "tier", "status", "domain", "stack_notes", "vault_ref",
"services", "billing_cycle", "monthly_fee_eur", "start_date",
- "renewal_date", "created_at", "notes"],
+ "renewal_date", "created_at", "notes", "notify_channel"],
"dates": ["start_date", "renewal_date"],
"timestamps": ["created_at"],
"numbers": ["monthly_fee_eur"],
@@ -101,6 +101,12 @@ def parse_ts(v):
if isinstance(v, datetime):
return v
s = str(v).strip()
+ # ISO-8601 / RFC3339 (incl. trailing Z and +hh:mm offsets — what Google
+ # Calendar / booking tools emit). fromisoformat handles Z on Python 3.11+.
+ try:
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
+ except ValueError:
+ pass
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M:%S",
"%Y-%m-%dT%H:%M", "%Y-%m-%d"):
try:
diff --git a/backoffice/app/static/index.html b/backoffice/app/static/index.html
index b0ca802..52938cb 100644
--- a/backoffice/app/static/index.html
+++ b/backoffice/app/static/index.html
@@ -43,11 +43,12 @@
.pillv { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: .74rem; font-weight: 600;
background: var(--teal-soft); color: var(--teal-2); }
.empty { padding: 40px; text-align: center; color: var(--muted); }
- .del { background: none; border: 1px solid var(--line); border-radius: 7px; cursor: pointer;
- padding: 3px 8px; font-size: .9rem; }
- .del:hover { background: #fdecec; border-color: #f3c7c7; }
+ .actcol { white-space: nowrap; width: 1%; text-align: right; }
+ .del { background: none; border: 1px solid #e6b9b9; color: #b4322e; border-radius: 7px; cursor: pointer;
+ padding: 4px 10px; font-size: .8rem; font-weight: 600; }
+ .del:hover { background: #fdecec; border-color: #e08a86; }
.act { background: none; border: 1px solid var(--line); border-radius: 7px; cursor: pointer;
- padding: 3px 8px; font-size: .9rem; margin-right: 4px; }
+ padding: 4px 10px; font-size: .8rem; font-weight: 600; margin-right: 6px; }
.act:hover { background: var(--teal-soft); border-color: var(--teal); }
.overlay { position: fixed; inset: 0; background: rgba(8,20,19,.5); display: none;
align-items: flex-start; justify-content: center; padding: 40px 16px; z-index: 20; overflow: auto; }
@@ -151,7 +152,7 @@
if((c==='received_at'||c==='created_at') && v) v = String(v).replace('T',' ').slice(0,16);
return `
${esc(v)} | `;
}).join('');
- return `${cells} |
`;
+ return `${cells} |
`;
}).join('');
document.getElementById('table').innerHTML = ``;
}
@@ -183,6 +184,7 @@
{k:'status',t:'select',opts:['lead','onboarding','active','churned']},
{k:'billing_cycle',t:'select',opts:['monthly','yearly','one-off']},
{k:'monthly_fee_eur'},{k:'domain'},
+ {k:'notify_channel',t:'select',opts:['calendar','telegram','email','sms','whatsapp']},
{k:'start_date',t:'date'},{k:'renewal_date',t:'date'},
{k:'services',wide:true},{k:'stack_notes',t:'textarea',wide:true},
{k:'vault_ref'},{k:'notes',t:'textarea',wide:true},
diff --git a/backoffice/db/init.sql b/backoffice/db/init.sql
index 5b12ac4..eade021 100644
--- a/backoffice/db/init.sql
+++ b/backoffice/db/init.sql
@@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS clients (
renewal_date date,
created_at timestamptz,
notes text,
+ notify_channel text,
updated_at timestamptz NOT NULL DEFAULT now()
);
diff --git a/backoffice/docker-compose.yml b/backoffice/docker-compose.yml
index e6fb925..b9d8e75 100644
--- a/backoffice/docker-compose.yml
+++ b/backoffice/docker-compose.yml
@@ -24,6 +24,7 @@ services:
environment:
DATABASE_URL: postgresql://smbcrm:${DB_PASSWORD}@smb-db:5432/smbcrm
CRM_API_TOKEN: ${CRM_API_TOKEN}
+ ICS_TOKEN: ${ICS_TOKEN}
SHEET_ID: ${SHEET_ID}
GOOGLE_SA_JSON: /run/secrets/gcp-sa.json
volumes:
diff --git a/deploy/backup/smb-db-backup.sh b/deploy/backup/smb-db-backup.sh
new file mode 100644
index 0000000..9010a18
--- /dev/null
+++ b/deploy/backup/smb-db-backup.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Daily backup of smb-db (the CRM Postgres = source of truth). Keeps 14 days.
+# Installed on the homelab at /home/mivanchenko/backups/bin/ and run via cron.
+set -euo pipefail
+
+DEST=/home/mivanchenko/backups/smb-crm
+mkdir -p "$DEST"
+TS=$(date +%Y%m%d-%H%M%S)
+FILE="$DEST/smbcrm-$TS.sql.gz"
+
+# pg_dump runs inside the container; gzip on the host.
+docker exec smb-db pg_dump -U smbcrm -d smbcrm | gzip > "$FILE"
+
+# keep the 14 most recent dumps, delete older
+ls -1t "$DEST"/smbcrm-*.sql.gz 2>/dev/null | tail -n +15 | xargs -r rm -f
+
+echo "$(date -Is) backup ok: $FILE ($(du -h "$FILE" | cut -f1))"
diff --git a/deploy/booking/.env.example b/deploy/booking/.env.example
new file mode 100644
index 0000000..5e0644c
--- /dev/null
+++ b/deploy/booking/.env.example
@@ -0,0 +1,3 @@
+# Copy to .env on the homelab and fill with strong secrets (gitignored).
+EA_DB_PASSWORD=change-me
+EA_DB_ROOT_PASSWORD=change-me-too
diff --git a/deploy/booking/booking_layout.js b/deploy/booking/booking_layout.js
new file mode 100644
index 0000000..1f1dca0
--- /dev/null
+++ b/deploy/booking/booking_layout.js
@@ -0,0 +1,93 @@
+/* ----------------------------------------------------------------------------
+ * Easy!Appointments - Online Appointment Scheduler
+ *
+ * @package EasyAppointments
+ * @author A.Tselegidis
+ * @copyright Copyright (c) Alex Tselegidis
+ * @license https://opensource.org/licenses/GPL-3.0 - GPLv3
+ * @link https://easyappointments.org
+ * @since v1.5.0
+ * ---------------------------------------------------------------------------- */
+
+/**
+ * Booking layout.
+ *
+ * This module implements the booking layout functionality.
+ */
+window.App.Layouts.Booking = (function () {
+ const $selectLanguage = $('#select-language');
+
+ /**
+ * Initialize the module.
+ */
+ function initialize() {
+ App.Utils.Lang.enableLanguageSelection($selectLanguage);
+ }
+
+ document.addEventListener('DOMContentLoaded', initialize);
+
+ return {};
+})();
+
+/* ----------------------------------------------------------------------------
+ * SMB customisation — auto-fit iframe embed.
+ *
+ * EA doesn't report its rendered height, so when embedded in a fixed-height
+ * iframe the wizard overflows and gets its own scrollbar. Here we measure the
+ * wizard card and post its height to the parent page, which resizes the iframe
+ * to fit exactly (see the .booking-embed listener on the client site). Fires on
+ * load, on step changes, when available hours load, and on resize.
+ * ---------------------------------------------------------------------------- */
+(function () {
+ function reportHeight() {
+ var el = document.getElementById('book-appointment-wizard');
+ var h;
+ if (el) {
+ h = Math.ceil(el.getBoundingClientRect().height) + 40; // breathing room
+ } else if (document.body) {
+ h = document.body.scrollHeight;
+ }
+ if (h && h > 0) {
+ try {
+ window.parent.postMessage({ eaBookingHeight: h }, '*');
+ } catch (e) { /* not embedded / blocked — ignore */ }
+ }
+ }
+
+ function start() {
+ if (window.parent === window) {
+ return; // not in an iframe, nothing to report
+ }
+
+ reportHeight();
+
+ var target = document.getElementById('book-appointment-wizard') || document.body;
+
+ if (window.ResizeObserver) {
+ new ResizeObserver(reportHeight).observe(target);
+ }
+ if (window.MutationObserver) {
+ new MutationObserver(reportHeight).observe(target, {
+ subtree: true,
+ childList: true,
+ attributes: true,
+ });
+ }
+ window.addEventListener('resize', reportHeight);
+
+ // Catch late async renders (available hours, fade transitions) for a few seconds.
+ var ticks = 0;
+ var iv = setInterval(function () {
+ reportHeight();
+ if (++ticks > 25) {
+ clearInterval(iv);
+ }
+ }, 250);
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', start);
+ } else {
+ start();
+ }
+})();
diff --git a/deploy/booking/docker-compose.yml b/deploy/booking/docker-compose.yml
new file mode 100644
index 0000000..831ee2b
--- /dev/null
+++ b/deploy/booking/docker-compose.yml
@@ -0,0 +1,70 @@
+# booking/ — self-hosted appointment scheduler (Easy!Appointments) + its DB.
+#
+# One shared instance serves all clients; each client = a provider/service with
+# its own booking-page URL. Lightweight (PHP + MariaDB) to fit the 16 GB box.
+# Booking events fire a webhook -> n8n "booking-sync" -> CRM `bookings` + notify.
+# Routed by Caddy: booking.mivanchenko.de { reverse_proxy easyappointments:80 }
+name: smb-booking
+
+services:
+ ea-db:
+ image: mariadb:11
+ container_name: ea-db
+ restart: unless-stopped
+ environment:
+ MARIADB_DATABASE: easyappointments
+ MARIADB_USER: ea
+ MARIADB_PASSWORD: ${EA_DB_PASSWORD}
+ MARIADB_ROOT_PASSWORD: ${EA_DB_ROOT_PASSWORD}
+ volumes:
+ - ea-db-data:/var/lib/mysql
+ networks: [booking-net]
+ mem_limit: 512m
+ healthcheck:
+ test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
+ interval: 10s
+ timeout: 5s
+ retries: 6
+
+ easyappointments:
+ image: alextselegidis/easyappointments:latest
+ container_name: easyappointments
+ restart: unless-stopped
+ environment:
+ BASE_URL: https://booking.mivanchenko.de
+ DEBUG_MODE: "FALSE"
+ DB_HOST: ea-db
+ DB_NAME: easyappointments
+ DB_USERNAME: ea
+ DB_PASSWORD: ${EA_DB_PASSWORD}
+ # Email is optional — booking notifications go via webhook -> n8n. Fill to
+ # also send customers EA's own confirmation emails.
+ MAIL_PROTOCOL: mail
+ MAIL_FROM_ADDRESS: info@booking.mivanchenko.de
+ MAIL_FROM_NAME: Buchung
+ volumes:
+ # Brand-matched booking wizard: recolours the flatpickr datepicker to the
+ # client's --bs-primary and compacts the layout so the embed fits its
+ # iframe without an outer scrollbar. (Wizard chrome colour itself comes
+ # from the EA "company colour" setting.) Re-sync from upstream on EA upgrade.
+ # NOTE: production (DEBUG_MODE=FALSE) serves the .min.* bundles, so those
+ # are the ones that must be overridden — the plain files are dev-only.
+ - ./frontend.css:/var/www/html/assets/css/frontend.css:ro
+ - ./frontend.css:/var/www/html/assets/css/frontend.min.css:ro
+ # Reports the wizard's rendered height to the embedding page so the iframe
+ # auto-fits to its content (no inner scrollbar). Re-sync on EA upgrade.
+ - ./booking_layout.js:/var/www/html/assets/js/layouts/booking_layout.js:ro
+ - ./booking_layout.js:/var/www/html/assets/js/layouts/booking_layout.min.js:ro
+ depends_on:
+ ea-db:
+ condition: service_healthy
+ networks: [booking-net, proxy] # proxy: reached by Caddy as easyappointments:80
+ mem_limit: 256m
+
+volumes:
+ ea-db-data:
+
+networks:
+ booking-net:
+ proxy:
+ external: true
diff --git a/deploy/booking/frontend.css b/deploy/booking/frontend.css
new file mode 100644
index 0000000..30cdfcd
--- /dev/null
+++ b/deploy/booking/frontend.css
@@ -0,0 +1,155 @@
+/* ----------------------------------------------------------------------------
+ * Easy!Appointments - Online Appointment Scheduler
+ *
+ * @package EasyAppointments
+ * @author A.Tselegidis
+ * @copyright Copyright (c) Alex Tselegidis
+ * @license https://opensource.org/licenses/GPL-3.0 - GPLv3
+ * @link https://easyappointments.org
+ * @since v1.5.0
+ * ---------------------------------------------------------------------------- */
+/* Responsive booking wizard styles (rounded corners and shadow on md+ screens) */
+@media (min-width: 768px) {
+ #book-appointment-wizard {
+ border-radius: var(--bs-border-radius) !important;
+ box-shadow: var(--bs-box-shadow-sm) !important;
+ }
+}
+/* Active step indicator - dynamic state that changes via JavaScript */
+#book-appointment-wizard .book-step.active-step {
+ height: 45px !important;
+ width: 45px !important;
+ background: var(--bs-white) !important;
+ padding: 7px !important;
+ margin-right: 13px !important;
+ margin-top: 0 !important;
+}
+
+#book-appointment-wizard .book-step.active-step strong {
+ color: var(--bs-primary) !important;
+ font-size: 21px !important;
+}
+
+/* Inactive step indicator - ensures consistent styling when step becomes inactive */
+#book-appointment-wizard .book-step:not(.active-step) {
+ height: 35px !important;
+ width: 35px !important;
+ background: rgba(0, 0, 0, 0.2) !important;
+ padding: 8px !important;
+ margin-right: 12px !important;
+ margin-top: 6px !important;
+}
+
+#book-appointment-wizard .book-step:not(.active-step) strong {
+ color: rgba(255, 255, 255, 0.5) !important;
+ font-size: 12px !important;
+}
+
+/* Selected hour button state */
+#book-appointment-wizard #available-hours .available-hour {
+ margin-bottom: 10px;
+}
+
+#book-appointment-wizard #available-hours .selected-hour {
+ background-color: var(--bs-primary) !important;
+ border-color: var(--bs-primary) !important;
+ color: var(--bs-white) !important;
+}
+
+/* Captcha refresh icon hover effect */
+#book-appointment-wizard .captcha-title .fa-sync-alt {
+ cursor: pointer;
+ transition: all 0.3s linear;
+}
+
+#book-appointment-wizard .captcha-title .fa-sync-alt:hover {
+ color: var(--bs-primary);
+}
+
+/* Language popover list */
+.popover .popover-title {
+ text-align: center;
+}
+
+.popover .popover-content #language-list .language {
+ margin: 15px 0;
+}
+
+#book-appointment-wizard .flatpickr-calendar.inline {
+ margin: auto;
+}
+
+/* ============================================================================
+ * SMB customisation — match the client's landing-page brand (rose/blush) and
+ * make the embed fit its iframe without an outer scrollbar.
+ * The brand colour follows --bs-primary, which is set per-instance by the EA
+ * "company colour" setting (Settings → Business → Company colour), so this file
+ * stays client-agnostic. Only the flatpickr datepicker + compact sizing live here.
+ * ============================================================================ */
+
+/* --- Flatpickr datepicker: recolour the green (material_green) theme to brand --- */
+.flatpickr-calendar .flatpickr-months .flatpickr-month,
+.flatpickr-calendar .flatpickr-weekdays,
+.flatpickr-calendar span.flatpickr-weekday,
+.flatpickr-calendar .flatpickr-current-month .flatpickr-monthDropdown-months {
+ background: var(--bs-primary) !important;
+ color: #fff !important;
+ fill: #fff !important;
+}
+
+.flatpickr-calendar .flatpickr-day.selected,
+.flatpickr-calendar .flatpickr-day.selected:hover,
+.flatpickr-calendar .flatpickr-day.selected:focus,
+.flatpickr-calendar .flatpickr-day.startRange,
+.flatpickr-calendar .flatpickr-day.endRange {
+ background: var(--bs-primary) !important;
+ border-color: var(--bs-primary) !important;
+ color: #fff !important;
+}
+
+.flatpickr-calendar .flatpickr-day.today {
+ border-color: var(--bs-primary) !important;
+}
+
+.flatpickr-calendar .flatpickr-day.today:hover,
+.flatpickr-calendar .flatpickr-day.today:focus {
+ background: var(--bs-primary) !important;
+ border-color: var(--bs-primary) !important;
+ color: #fff !important;
+}
+
+/* --- Single-tenant embed: the provider is pinned per site via ?provider=,
+ * so the customer must never see or switch it (booking a different studio on
+ * e.g. happynails.mivanchenko.de would be wrong). EA's JS un-hides the
+ * provider control when a service is chosen, so we force it hidden here.
+ * The