Add booking stack, client deploys, and back-office updates

- deploy/booking: shared Easy!Appointments stack with brand-matched
  wizard (flatpickr recolor, single-tenant provider hide, iframe
  auto-fit height reporter)
- deploy/clients: per-client isolated nginx compose stacks with
  _template scaffold, new-client.sh, and happynails live site
- deploy/backup: smb-db backup script
- n8n: booking-sync workflow; onboarding tweaks
- playbooks: lead-to-customer lifecycle + outreach
- templates: nail-studio landing previews
- backoffice: app/db/init/compose updates

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 16:10:15 +02:00
parent 4257bd3e55
commit 156166b4e5
25 changed files with 2900 additions and 23 deletions
+54
View File
@@ -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")
+7 -1
View File
@@ -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:
+7 -5
View File
@@ -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 `<td title="${esc(v)}">${esc(v)}</td>`;
}).join('');
return `<tr>${cells}<td style="white-space:nowrap"><button class="act" title="Bearbeiten" onclick="openForm('${esc(id)}')"></button><button class="del" title="Löschen" onclick="del('${esc(id)}')">🗑</button></td></tr>`;
return `<tr>${cells}<td class="actcol"><button class="act" title="Bearbeiten" onclick="openForm('${esc(id)}')">Bearbeiten</button><button class="del" title="Löschen" onclick="del('${esc(id)}')">Löschen</button></td></tr>`;
}).join('');
document.getElementById('table').innerHTML = `<table><thead>${head}</thead><tbody>${body}</tbody></table>`;
}
@@ -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},