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:
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user