From 723eefa5b815c5420971fdaf41a0d565618ef40f Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sat, 28 Feb 2026 18:46:40 +0000 Subject: [PATCH 1/3] feat: add seed script and migration analysis for complexcaresolutions.de (tenant 10) Migrates all WordPress content to Payload CMS blocks: 10 pages, 3 testimonials, navigation, contact form, social links, site settings, and 1 blog post. Co-Authored-By: Claude Opus 4.6 --- docs/c2s/C2S_MIGRATION_ANALYSIS.md | 417 +++++++++ scripts/seed-c2s.ts | 1320 ++++++++++++++++++++++++++++ 2 files changed, 1737 insertions(+) create mode 100644 docs/c2s/C2S_MIGRATION_ANALYSIS.md create mode 100644 scripts/seed-c2s.ts diff --git a/docs/c2s/C2S_MIGRATION_ANALYSIS.md b/docs/c2s/C2S_MIGRATION_ANALYSIS.md new file mode 100644 index 0000000..a999a4f --- /dev/null +++ b/docs/c2s/C2S_MIGRATION_ANALYSIS.md @@ -0,0 +1,417 @@ +# complexcaresolutions.de → Payload CMS Migration Analysis + +**Source:** WordPress (Divi Builder) on Hetzner 1 +**Target:** Payload CMS Tenant ID 10 +**Date:** 2026-02-28 + +--- + +## 1. Site Overview + +| Property | Value | +|----------|-------| +| Domain | complexcaresolutions.de | +| WordPress URL | https://www.complexcaresolutions.de | +| Theme | Divi + Divi-c2s-child | +| Brand Colors | Navy `#004166`, Gold `#B3AF09` | +| Phone | 0800 80 44 100 | +| Fax | 0800 80 44 190 | +| Email | kontakt@complexcaresolutions.de | +| Company | complex care solutions GmbH | +| Address | Hans-Böckler-Str. 19, 46236 Bottrop | +| Geschäftsführer | Martin Porwoll | +| HRB | 15753 (AG Gelsenkirchen) | +| USt-IdNr | DE334815479 | +| Social Media | LinkedIn, Facebook, Instagram, Twitter, YouTube | +| Datenschutz | alfright.eu (key: `f60250224d4a459a90dbeeb289cd47f9`, tenant: `alfright_schutzteam`) | +| Media Files | 55 images/uploads | +| Status | WordPress returning HTTP 500 (broken) | + +--- + +## 2. Navigation Structure + +### Hauptmenü (7 items) +| Order | Label | Slug | Parent | +|-------|-------|------|--------| +| 1 | Patienten | patienten | — | +| 2 | Zweitmeinung | zweitmeinung | Patienten (submenu) | +| 3 | Ärzte | aerzte | — | +| 4 | Versicherungen | versicherungen | — | +| 5 | Über uns | ueber-uns | — | +| 6 | Motivation | motivation | — | +| 7 | Kontakt | kontakt | — | + +### Footer Menu (2 items) +| Order | Label | Slug | +|-------|-------|------| +| 1 | Datenschutz | datenschutzerklaerung | +| 2 | Impressum | impressum | + +--- + +## 3. Page Inventory & Content Mapping + +### Pages TO MIGRATE (10 substantial pages) + +#### 3.1 Startseite (ID: 11, slug: `startseite`) +**Content Structure:** +1. **Hero** — "Willkommen bei complex care solutions" + subline about healthcare company +2. **Image-Text pairs** — Schilddrüsen-Zweitmeinung + Gallenblasen-Zweitmeinung CTAs +3. **Card Grid** — "Die besten Lösungen" (3 cards: Expertenmeinungen, Spezialisierte Unterstützung, Smarte Technologie) +4. **Text Section** — "Wir schaffen Mehrwert" (3 paragraphs about quality, cost control, quality assurance) +5. **Quote/Text** — "Unser Versprechen" (company values statement) +6. **Contact Footer** — Phone, email, social media, contact form + +**Payload Blocks:** +- `hero-block` (headline, subline, CTA) +- `image-text-block` × 2 (Schilddrüsen + Gallenblasen sections) +- `card-grid-block` (3 cards: "Die besten Lösungen") +- `text-block` ("Wir schaffen Mehrwert") +- `quote-block` ("Unser Versprechen") +- `contact-form-block` + +--- + +#### 3.2 Patienten (ID: 987511082, slug: `patienten`) +**Content Structure:** +1. **Hero** — "Die beste Versorgung für unsere Patienten" +2. **Text** — "Umfassende medizinische Lösungen" (overview) +3. **Card Grid** — 3 service cards (Zweitmeinung, Intensivpflegeberatung, Medikationsanalyse) with descriptions +4. **Contact Footer** + +**Payload Blocks:** +- `hero-block` +- `text-block` (intro) +- `card-grid-block` (3 service cards linking to subpages) +- `contact-form-block` + +--- + +#### 3.3 Zweitmeinung (ID: 32, slug: `zweitmeinung`, LARGEST PAGE 48KB) +**Content Structure:** +1. **Hero** — "Zweitmeinung rettet Leben" + CTA +2. **Section: Kardiologie** — Headline + description + image + 2 blurbs (Qualifizierte Kardiologen, Schnelle Zweitmeinungen) +3. **Section: Onkologie** — Headline + description + image + 2 blurbs +4. **Section: Chirurgie** — Headline + description + image +5. **Section: Intensivmedizin** — Headline + description + image + 2 blurbs +6. **Section: Vorteile** — "Eine zweite Meinung für Ihre Gesundheit" + 3 benefit blurbs +7. **CTA** — "Holen Sie sich eine Zweitmeinung" +8. **Testimonials** — 3 patient testimonials (Basalzellkarzinom, Radiologie, Herzstolpern) +9. **Contact Footer** + integrated Divi contact form + +**Payload Blocks:** +- `hero-block` +- `image-text-block` × 4 (Kardiologie, Onkologie, Chirurgie, Intensivmedizin) — each with sub-features +- `card-grid-block` (3 benefit cards) +- `cta-block` +- `testimonials-block` (3 testimonials) +- `contact-form-block` + +**Testimonials to seed (Collection):** +1. "Nach einer Gewebeprobe wurde ein Basalzellkarzinom diagnostiziert..." — anonymous patient +2. "Die radiologische Zweitmeinung, die ich erhielt, war ein echter Lebensretter!..." — anonymous patient +3. "Seit einiger Zeit bemerkte ich ein Herzstolpern in meiner Brust..." — anonymous patient + +--- + +#### 3.4 Ärzte (ID: 20, slug: `aerzte`) +**Content Structure:** +1. **Hero** — "Kompetente Unterstützung für Sie und Ihre Patienten" (subtitle: "Für Ärzte") +2. **Text** — "Die beste Unterstützung für Ihre Patienten" (intro question) +3. **Card Grid** — 6 feature cards (Qualitätsdiagnose, Lokale Kapazitäten, Leitlinien, Expertennetzwerk, Über-/Fehlversorgung minimieren, Schnelle Prozesse + Transparenz, Beitrag zum Patientenwohl) +4. **CTA** — "Werden Sie Teil unseres Expertennetzwerks!" +5. **Contact Footer** + +**Payload Blocks:** +- `hero-block` (with "Für Ärzte" subtitle) +- `text-block` (intro) +- `card-grid-block` (features) +- `cta-block` +- `contact-form-block` + +--- + +#### 3.5 Versicherungen (ID: 22, slug: `versicherungen`) +**Content Structure:** +1. **Hero** — "Für höchste Versorgungsqualität, intelligent und wirtschaftlich" (subtitle: "Für Krankenkassen") +2. **Text** — "Hochwertige Lösungen für Ihre Versicherten" +3. **Card Grid** — 3 service areas (Datenbasierte Forschung + §25b SGB V, Innovationen §68 SGB V, Strategie) +4. **Card Grid** — "Ihre Vorteile" (3 benefit cards: Kostenersparnis, Bessere Versorgung, Wettbewerbsvorteil) +5. **Image-Text: Data Science** — "Data Science und KI intelligent nutzen" + 3 blurbs +6. **Image-Text: Expertise** — "Spezialisierte Expertise" + description +7. **Image-Text: Strategie** — "Strategie- und Organisationsentwicklung" + 2 blurbs +8. **Contact Footer** + +**Payload Blocks:** +- `hero-block` +- `text-block` (intro) +- `card-grid-block` (service areas) +- `card-grid-block` (Vorteile) +- `image-text-block` (Data Science) +- `image-text-block` (Expertise) +- `image-text-block` (Strategie) +- `contact-form-block` + +--- + +#### 3.6 Über uns (ID: 28, slug: `ueber-uns`) +**Content Structure:** +1. **Hero** — "Gesundheit in den besten Händen" + company description +2. **Text** — "Die Geschichte hinter der Gründung" (Martin Porwoll / Whistleblower story) +3. **Card Grid** — "Woran wir glauben" + 3 paragraphs about values +4. **Text** — "Was wir mitbringen" + 3 competency items (Expertise, Netzwerk, Ethische Grundhaltung) +5. **Timeline** — Company history timeline (Divi custom CSS timeline) +6. **Contact Footer** + +**Payload Blocks:** +- `hero-block` +- `text-block` (Gründung) +- `text-block` (Woran wir glauben) +- `card-grid-block` (Was wir mitbringen - 3 competency cards) +- `timeline-block` (company history) — NOTE: Timeline had extensive custom CSS in WordPress; may simplify to text or card layout +- `contact-form-block` + +--- + +#### 3.7 Motivation (ID: 30, slug: `motivation`) +**Content Structure:** +1. **Hero** — "Patientenwohl im Mittelpunkt" (subtitle: "Unsere Motivation") +2. **Text** — "Unser Fokus" + 3 paragraphs (patientenzentriert, Transparenz, Expertennetzwerk) +3. **Text** — "Motivation und Geschichte" (Martin Porwoll / Zytoskandal founding story) +4. **Quote** — Martin Porwoll quote about Zytoskandal and his mission +5. **Text** — "Der Zytoskandal Bottrop" (detailed scandal description) +6. **Text** — "Martin Porwolls Weg zum Streiter für das Patientenwohl" +7. **Contact Footer** + +**Payload Blocks:** +- `hero-block` +- `card-grid-block` (3 focus areas) +- `text-block` (Motivation und Geschichte) +- `quote-block` (Martin Porwoll quote) +- `text-block` (Zytoskandal) +- `text-block` (Porwolls Weg) +- `contact-form-block` + +--- + +#### 3.8 Kontakt (ID: 26, slug: `kontakt`) +**Content Structure:** +1. **Contact Section** — Phone, email, social media + Divi contact form (Name, Email, Betreff, Nachricht) + +**Payload Blocks:** +- `contact-form-block` (linked to form) + +**Note:** The WordPress Kontakt page is essentially just the contact footer section that appears on every page. The form has 4 fields: Name, Email, Betreff (Subject), Nachricht (Message). + +--- + +#### 3.9 Impressum (ID: 14, slug: `impressum`) +**Content Structure:** +1. **Hero** — "Impressum" +2. **Text** — Full legal text (company info, Handelsregister, Kontakt, USt-ID, EU-Streitschlichtung, Verbraucherschlichtung) +3. **Contact Footer** + +**Payload Blocks:** +- `hero-block` +- `text-block` (legal text, width: narrow) +- `contact-form-block` + +--- + +#### 3.10 Datenschutzerklärung (ID: 16, slug: `datenschutzerklaerung`) +**Content Structure:** +1. **alfright.eu widget** — Privacy policy generated dynamically +2. **Contact Footer** + +**Payload Blocks:** +- `hero-block` (headline: "Datenschutzerklärung") +- `html-embed-block` (alfright.eu `
` widget with key `f60250224d4a459a90dbeeb289cd47f9`) +- `contact-form-block` + +**alfright embed code:** +```html +
+ +``` +**Note:** c2s uses the alfright.eu `
` widget (not iframe like zweitmeinu.ng). This requires both the div AND the script tag to be embedded. + +--- + +### Pages TO SKIP (4 placeholder/empty pages) + +| Page | Reason | +|------|--------| +| Blog (ID: 24) | Empty — only 29 chars of content (empty Divi placeholder) | +| Medikationsanalyse (ID: 34) | Only contains contact footer section, no real content | +| Intensivpflegeberatung (ID: 38) | Only contains contact footer section, no real content | +| Klinik (ID: 987510888) | Lorem ipsum placeholder content | + +--- + +### Blog Post TO MIGRATE (1) + +#### "Geheimnis der guten Fürsorge" (ID: 987511216) +**Slug:** `geheimnis-der-guten-fuersorge-warum-uebertherapie-ein-gesellschaftliches-problem-ist` +**Content:** Long-form article (~7800 chars) about Übertherapie (over-treatment) in healthcare +**Structure:** Pure text (Gutenberg paragraphs), no Divi shortcodes +**Category:** Blog/News + +**Payload Collection:** `posts` (with tenant_id = 10) +**Content:** Rich text (Lexical paragraphs) + +--- + +## 4. Collections to Seed + +### 4.1 Site Settings (1 record) +``` +site_name: "complex care solutions GmbH" +site_url: "https://complexcaresolutions.de" +company_name: "complex care solutions GmbH" +address: "Hans-Böckler-Str. 19" +zip: "46236" +city: "Bottrop" +phone: "0800 80 44 100" +fax: "0800 80 44 190" +email: "kontakt@complexcaresolutions.de" +``` + +### 4.2 Social Links (2 confirmed + 3 placeholder) +| Platform | URL | Status | +|----------|-----|--------| +| LinkedIn | https://de.linkedin.com/company/complex-care-solutions-gmbh | Confirmed | +| Facebook | https://www.facebook.com/complex-care-solutions-GmbH | Confirmed | +| Instagram | — | Listed in nav but no URL set in WordPress | +| Twitter/X | — | Listed in nav but no URL set in WordPress | +| YouTube | — | Listed in nav but no URL set in WordPress | + +**Seed only LinkedIn + Facebook** (the others have no URLs configured). + +### 4.3 Navigation (1 record) +**mainMenu:** +1. Patienten → `/patienten` + - Zweitmeinung → `/zweitmeinung` (submenu) +2. Ärzte → `/aerzte` +3. Versicherungen → `/versicherungen` +4. Über uns → `/ueber-uns` +5. Motivation → `/motivation` +6. Kontakt → `/kontakt` + +**footerMenu:** +1. Datenschutz → `/datenschutzerklaerung` +2. Impressum → `/impressum` + +### 4.4 Contact Form (1 form, 4 fields) +| Field | Type | Required | +|-------|------|----------| +| Name | text | yes | +| Email | email | yes | +| Betreff | text | yes | +| Nachricht | textarea | yes | + +Email notification → `kontakt@complexcaresolutions.de` + +### 4.5 Testimonials (3 records) +1. Basalzellkarzinom patient — quote about finding right therapy +2. Radiology second opinion patient — "Lebensretter" quote +3. Heart patient — Herzstolpern/stress diagnosis + +### 4.6 Pages (10 pages) +See Section 3 for full content mapping. + +### 4.7 Posts (1 blog post) +"Geheimnis der guten Fürsorge – warum Übertherapie ein gesellschaftliches Problem ist" + +--- + +## 5. Media Files to Migrate + +55 total WordPress media files. Key images: + +| Image | Used On | +|-------|---------| +| c2s_master_logo_400x130.png | Site logo | +| Favicon-c2s.png | Favicon | +| c2s_startseite.jpg | Homepage hero | +| c2s_startseite_2.jpg | Homepage section | +| c2s_startseite_loesungen.jpg | Homepage solutions | +| c2s_startseite_versprechen.jpg | Homepage promise | +| zweitmeinung.jpg | Zweitmeinung hero | +| zweitmeinung_desktop.jpg | Zweitmeinung wide | +| zweitmeinung_herz.jpg | Kardiologie section | +| zweitmeinung_onkologie.jpg | Onkologie section | +| zweitmeinung_chirurgie.jpg | Chirurgie section | +| zweitmeinung_intensiv.jpg | Intensivmedizin section | +| patienten.jpg | Patienten hero | +| aerzte.jpg | Ärzte hero | +| krankenversicherung.jpg | Versicherungen hero | +| medikationsanalyse.jpg | Medikationsanalyse hero | +| tbvs-image-person-1024x1024-1.png | Martin Porwoll portrait | +| tbvs-image-2560x1440-1.png | Hero background | +| post-4.jpg | Blog post image | +| Various AdobeStock_*.jpg | Section backgrounds | + +**Strategy:** Upload to Payload media collection with `tenant_id: 10`. Download from WordPress `wp-content/uploads/` on Hetzner 1. + +--- + +## 6. Key Differences from zweitmeinu.ng + +| Aspect | zweitmeinu.ng (Tenant 12) | complexcaresolutions.de (Tenant 10) | +|--------|--------------------------|-------------------------------------| +| Pages | 9 seeded | 10 needed | +| Services | 6 (dedicated collection) | Embedded in page content (no service collection needed) | +| FAQs | 24 items | None | +| Blog Posts | 0 | 1 | +| Testimonials | 0 | 3 | +| Contact Form | 4 fields | 4 fields (same: Name, Email, Betreff, Nachricht) | +| Datenschutz | iframe (alfright.eu) | div widget (alfright.eu, different key) | +| Target Audience Pages | None | 3 (Patienten, Ärzte, Versicherungen) | +| Content Volume | Medium | Large (Zweitmeinung page alone is 48KB) | + +--- + +## 7. Implementation Plan + +### Phase 1: Seed Script (`scripts/seed-c2s.ts`) +1. Create site settings for tenant 10 +2. Create 5 social links +3. Create navigation (mainMenu with Patienten submenu + footerMenu) +4. Create contact form (4 fields) +5. Create 3 testimonials +6. Create 10 pages with block-based layout +7. Create 1 blog post + +### Phase 2: Media Migration +1. Download all 55 media files from WordPress on Hetzner 1 +2. Upload to Payload via Local API +3. Link media IDs to pages/blocks (hero images, image-text sections) + +### Phase 3: Frontend (complexcaresolutions.de) +- Either create dedicated Next.js frontend OR +- Reuse existing frontend framework (like zweitmeinu.ng's BlockRenderer pattern) +- Same design system: Navy #004166, Gold #B3AF09 + +### Phase 4: DNS/Hosting Cutover +- Replace WordPress on Hetzner 1 with Next.js frontend +- Point domain to new frontend +- Verify all pages, SEO, redirects + +--- + +## 8. Existing Payload Data (Tenant 10) + +| Collection | Count | +|------------|-------| +| Pages | 0 | +| Site Settings | 0 | +| Navigations | 0 | +| Services | 0 | +| Forms | 0 | +| Posts | 0 | +| FAQs | 0 | +| Social Links | 0 | +| Testimonials | 0 | + +**Tenant 10 is completely empty — clean slate for seeding.** diff --git a/scripts/seed-c2s.ts b/scripts/seed-c2s.ts new file mode 100644 index 0000000..0a930fe --- /dev/null +++ b/scripts/seed-c2s.ts @@ -0,0 +1,1320 @@ +/** + * Complex Care Solutions (Tenant 10) Seed Script + * + * Populates tenant 10 with all content for complexcaresolutions.de: + * - Site Settings + * - Social Links (2) + * - Testimonials (3) + * - Navigation + * - Contact Form + * - Pages (10) + * - Blog Post (1) + * + * Run with: npx tsx scripts/seed-c2s.ts + */ + +import { getPayload } from 'payload' +import config from '../src/payload.config' + +const TENANT_ID = 10 + +// ── Rich Text Helpers (Lexical format) ────────────────────── + +function createRichText(content: string | string[]): object { + const paragraphs = Array.isArray(content) ? content : [content] + return { + root: { + type: 'root', + children: paragraphs.map((text) => ({ + type: 'paragraph', + children: [{ type: 'text', text }], + })), + direction: 'ltr', + format: '', + indent: 0, + version: 1, + }, + } +} + +function createRichTextComplex( + blocks: Array< + | { type: 'heading'; tag: string; text: string } + | { type: 'paragraph'; text: string } + | { type: 'paragraph-bold'; text: string } + | { type: 'bullets'; items: string[] } + >, +): object { + return { + root: { + type: 'root', + children: blocks.map((block) => { + if (block.type === 'heading') { + return { + type: 'heading', + tag: block.tag, + children: [{ type: 'text', text: block.text }], + } + } + if (block.type === 'paragraph-bold') { + return { + type: 'paragraph', + children: [{ type: 'text', text: block.text, format: 1 }], + } + } + if (block.type === 'bullets') { + return { + type: 'list', + listType: 'bullet', + children: block.items.map((item) => ({ + type: 'listitem', + children: [{ type: 'text', text: item }], + })), + } + } + return { + type: 'paragraph', + children: [{ type: 'text', text: block.text }], + } + }), + direction: 'ltr', + format: '', + indent: 0, + version: 1, + }, + } +} + +// ── Helper: Upsert ────────────────────────────────────────── + +async function upsert( + payload: any, + collection: string, + where: Record, + data: Record, +): Promise { + const existing = await payload.find({ + collection, + where: { ...where, tenant: { equals: TENANT_ID } }, + limit: 1, + }) + + if (existing.docs.length > 0) { + const doc = await payload.update({ + collection, + id: existing.docs[0].id, + data: { ...data, tenant: TENANT_ID }, + }) + return doc.id as number + } + + const doc = await payload.create({ + collection, + data: { ...data, tenant: TENANT_ID }, + }) + return doc.id as number +} + +// ── Main Seed Function ────────────────────────────────────── + +async function seed() { + console.log('🚀 Starting c2s (Tenant 10) Seed...\n') + + const payload = await getPayload({ config }) + + // Verify tenant exists + const tenant = await payload.findByID({ collection: 'tenants', id: TENANT_ID }) + if (!tenant) { + console.error(`❌ Tenant ${TENANT_ID} not found!`) + process.exit(1) + } + console.log(`✓ Tenant "${tenant.name}" (ID: ${TENANT_ID}) found\n`) + + // ════════════════════════════════════════════ + // 1. SITE SETTINGS + // ════════════════════════════════════════════ + console.log('--- 1. Site Settings ---') + + await upsert(payload, 'site-settings', {}, { + siteName: 'complex care solutions', + siteTagline: 'Die besten Lösungen in der komplexen Patientenversorgung', + contact: { + email: 'kontakt@complexcaresolutions.de', + phone: '0800 80 44 100', + fax: '0800 80 44 190', + }, + address: { + street: 'Hans-Böckler-Str. 19', + zip: '46236', + city: 'Bottrop', + state: 'Nordrhein-Westfalen', + country: 'Deutschland', + }, + footer: { + copyrightText: '© 2026 complex care solutions GmbH. Alle Rechte vorbehalten.', + showSocialLinks: true, + }, + seo: { + defaultMetaTitle: 'complex care solutions – Komplexe Patientenversorgung', + defaultMetaDescription: + 'Hochwertige medizinische Lösungen für komplexe Versorgungsbedürfnisse. Zweitmeinungen, Beratung und innovative Lösungen für Patienten, Ärzte und Versicherungen.', + }, + }) + console.log('✓ Site Settings created/updated') + + // ════════════════════════════════════════════ + // 2. SOCIAL LINKS + // ════════════════════════════════════════════ + console.log('\n--- 2. Social Links ---') + + const socialLinks = [ + { platform: 'linkedin' as const, url: 'https://de.linkedin.com/company/complex-care-solutions-gmbh' }, + { platform: 'facebook' as const, url: 'https://www.facebook.com/complex-care-solutions-GmbH' }, + ] + + for (const link of socialLinks) { + const id = await upsert(payload, 'social-links', { platform: { equals: link.platform } }, { + ...link, + isActive: true, + }) + console.log(`✓ Social Link "${link.platform}" (ID: ${id})`) + } + + // ════════════════════════════════════════════ + // 3. TESTIMONIALS + // ════════════════════════════════════════════ + console.log('\n--- 3. Testimonials ---') + + const testimonials = [ + { + quote: 'Nach einer Gewebeprobe wurde ein Basalzellkarzinom diagnostiziert, welches sich aber an einer ungünstigen Stelle meines Körpers befand. Eine OP kam aufgrund dessen nicht infrage. Ich war unsicher über die alternativen Behandlungsmethoden. Dank der Experten von complex care solutions fand ich die für mich richtige Therapie.', + author: 'Patient', + role: 'Dermatologie', + rating: 5, + isActive: true, + order: 1, + }, + { + quote: 'Die radiologische Zweitmeinung, die ich erhielt, war ein echter Lebensretter! Sie haben eine Anomalie entdeckt, die mein ursprünglicher Radiologe übersehen hatte, und ich konnte rechtzeitig behandelt werden. Ich kann diesen Service nur empfehlen!', + author: 'Patient', + role: 'Radiologie', + rating: 5, + isActive: true, + order: 2, + }, + { + quote: 'Seit einiger Zeit bemerkte ich ein Herzstolpern in meiner Brust. Untersuchungen ergaben jedoch keine Erkrankung. Erst der Experte von complex care solutions vermutete als Ursache den andauernden Stress in meinem Job. Nun konnte ich durch gezielte Entspannungsübungen etwas dagegen tun.', + author: 'Patient', + role: 'Kardiologie', + rating: 5, + isActive: true, + order: 3, + }, + ] + + const testimonialIds: number[] = [] + for (const t of testimonials) { + const id = await upsert(payload, 'testimonials', { order: { equals: t.order } }, t) + testimonialIds.push(id) + console.log(`✓ Testimonial #${t.order} (ID: ${id})`) + } + + // ════════════════════════════════════════════ + // 4. NAVIGATION + // ════════════════════════════════════════════ + console.log('\n--- 4. Navigation ---') + + await upsert(payload, 'navigations', {}, { + title: 'Hauptnavigation', + mainMenu: [ + { + label: 'Patienten', + type: 'submenu', + submenu: [ + { label: 'Übersicht', linkType: 'custom', url: '/patienten' }, + { label: 'Zweitmeinung', linkType: 'custom', url: '/zweitmeinung' }, + ], + }, + { label: 'Ärzte', type: 'custom', url: '/aerzte' }, + { label: 'Versicherungen', type: 'custom', url: '/versicherungen' }, + { label: 'Über uns', type: 'custom', url: '/ueber-uns' }, + { label: 'Motivation', type: 'custom', url: '/motivation' }, + { label: 'Kontakt', type: 'custom', url: '/kontakt' }, + ], + footerMenu: [ + { label: 'Datenschutz', linkType: 'custom', url: '/datenschutzerklaerung' }, + { label: 'Impressum', linkType: 'custom', url: '/impressum' }, + ], + }) + console.log('✓ Navigation created/updated') + + // ════════════════════════════════════════════ + // 5. CONTACT FORM + // ════════════════════════════════════════════ + console.log('\n--- 5. Contact Form ---') + + const existingForm = await payload.find({ + collection: 'forms', + where: { tenant: { equals: TENANT_ID } }, + limit: 1, + }) + + if (existingForm.docs.length > 0) { + console.log(`✓ Contact form already exists (ID: ${existingForm.docs[0].id})`) + } else { + const form = await payload.create({ + collection: 'forms', + data: { + title: 'Kontaktformular', + tenant: TENANT_ID, + fields: [ + { + blockType: 'text', + name: 'name', + label: 'Name', + required: true, + width: 50, + }, + { + blockType: 'email', + name: 'email', + label: 'E-Mail', + required: true, + width: 50, + }, + { + blockType: 'text', + name: 'betreff', + label: 'Betreff', + required: true, + width: 100, + }, + { + blockType: 'textarea', + name: 'nachricht', + label: 'Nachricht', + required: true, + width: 100, + }, + ], + submitButtonLabel: 'Nachricht senden', + confirmationType: 'message', + confirmationMessage: createRichText( + 'Vielen Dank für Ihre Nachricht. Wir melden uns schnellstmöglich bei Ihnen.', + ), + } as any, + }) + console.log(`✓ Contact form created (ID: ${form.id})`) + } + + // ════════════════════════════════════════════ + // 6. PAGES + // ════════════════════════════════════════════ + console.log('\n--- 6. Pages ---') + + // Get form ID for contact-form-block + const contactForm = await payload.find({ + collection: 'forms', + where: { tenant: { equals: TENANT_ID } }, + limit: 1, + }) + const contactFormId = contactForm.docs[0]?.id ?? null + + const pages = [ + // ── Startseite ── + { + title: 'Startseite', + slug: 'home', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'complex care solutions', + subline: 'Als renommiertes Gesundheitsunternehmen im Bereich der komplexen Versorgung sind wir auf Lösungen für Patienten und Krankenkassen spezialisiert. Das individuelle Wohl der Patienten steht dabei für uns an erster Stelle.', + alignment: 'center', + overlay: true, + cta: { + text: 'Kontakt aufnehmen', + link: '/kontakt', + style: 'primary', + }, + }, + { + blockType: 'card-grid-block', + headline: 'Die besten Lösungen in der komplexen Patientenversorgung', + columns: '3', + cards: [ + { + mediaType: 'icon', + icon: 'stethoscope', + iconPosition: 'top', + title: 'Expertenmeinungen', + description: 'Expertenmeinungen für fundierte Entscheidungen.', + }, + { + mediaType: 'icon', + icon: 'heart-handshake', + iconPosition: 'top', + title: 'Spezialisierte Unterstützung', + description: 'Spezialisierte Unterstützung in komplexen Fällen.', + }, + { + mediaType: 'icon', + icon: 'cpu', + iconPosition: 'top', + title: 'Smarte Technologie', + description: 'Smarte Technologie zur bestmöglichen Ressourcennutzung.', + }, + ], + }, + { + blockType: 'text-block', + width: 'medium', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Wir schaffen Mehrwert' }, + { + type: 'paragraph', + text: 'Wir schaffen Zugang zu hochqualifizierten medizinischen Leistungen im Bereich der komplexen Patientenversorgung.', + }, + { + type: 'paragraph', + text: 'Wir helfen, die Qualität komplexer medizinischer Versorgung zu steigern, bei gleichzeitiger Kontrolle der Kosten.', + }, + { + type: 'paragraph', + text: 'Wir helfen, eine qualitativ hochwertige medizinische Leistungserbringung sicherzustellen.', + }, + ]), + }, + { + blockType: 'quote-block', + quote: 'Wir verschreiben uns der Verbesserung des Patientenwohls und agieren unabhängig von den Interessen Dritter. Dabei behalten wir die Realitäten stets im Blick – ethisch und ökonomisch.', + author: 'complex care solutions', + role: 'Unser Versprechen', + style: 'highlighted', + }, + { + blockType: 'cta-block', + headline: 'Kontaktieren Sie uns', + description: 'Erfahren Sie mehr über unsere Leistungen und wie wir Ihnen helfen können.', + backgroundColor: 'dark', + buttons: [ + { + text: 'Kontakt aufnehmen', + link: '/kontakt', + style: 'primary', + }, + { + text: '0800 80 44 100', + link: 'tel:08008044100', + style: 'outline', + }, + ], + }, + ], + seo: { + metaTitle: 'complex care solutions – Komplexe Patientenversorgung', + metaDescription: 'Hochwertige medizinische Lösungen für Patienten und Krankenkassen. Zweitmeinungen, Beratung und innovative Technologie.', + }, + }, + + // ── Patienten ── + { + title: 'Für Patienten', + slug: 'patienten', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Die beste Versorgung für unsere Patienten', + subline: 'Wir bieten hochqualifizierte medizinische Leistungen im Bereich der komplexen Patientenversorgung.', + alignment: 'center', + overlay: true, + }, + { + blockType: 'text-block', + width: 'medium', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Umfassende medizinische Lösungen für die komplexe Patientenversorgung' }, + { + type: 'paragraph', + text: 'Unsere Leistungen für Patienten umfassen medizinische Zweitmeinungen verschiedener Fachbereiche. Für Intensivpflege-Patienten oder Patienten mit Polymedikation bieten wir eine spezielle, vollumfängliche Beratung an.', + }, + ]), + }, + { + blockType: 'card-grid-block', + headline: 'Unsere Leistungen für Patienten', + columns: '3', + cards: [ + { + mediaType: 'icon', + icon: 'file-check', + iconPosition: 'top', + title: 'Medizinische Zweitmeinung', + description: 'Patienten, die ihre gestellte Diagnose oder ihren Therapievorschlag kritisch hinterfragen, bieten wir eine Überprüfung im Rahmen einer hochqualifizierten und unabhängigen ärztlichen Zweitmeinung.', + }, + { + mediaType: 'icon', + icon: 'heart-pulse', + iconPosition: 'top', + title: 'Intensivpflegeberatung', + description: 'Eine neue eingetretene Intensivpflegebedürftigkeit wirft insbesondere bei Angehörigen viele Fragen auf. Wir bieten eine vollumfängliche Beratung zur bestmöglichen Unterstützung.', + }, + { + mediaType: 'icon', + icon: 'pill', + iconPosition: 'top', + title: 'Medikationsanalyse', + description: 'Patienten, die dauerhaft gleichzeitig mehrere Medikamente einnehmen müssen, bieten wir eine umfassende Beratung und strukturierte Analyse der aktuellen Gesamtmedikation.', + }, + ], + }, + { + blockType: 'cta-block', + headline: 'Kontaktieren Sie uns', + description: 'Erfahren Sie mehr über unsere Leistungen und wie wir Ihnen helfen können.', + backgroundColor: 'dark', + buttons: [ + { + text: 'Kontakt aufnehmen', + link: '/kontakt', + style: 'primary', + }, + { + text: '0800 80 44 100', + link: 'tel:08008044100', + style: 'outline', + }, + ], + }, + ], + seo: { + metaTitle: 'Für Patienten – complex care solutions', + metaDescription: 'Medizinische Zweitmeinungen, Intensivpflegeberatung und Medikationsanalyse. Hochqualifizierte Leistungen für Ihre Gesundheit.', + }, + }, + + // ── Zweitmeinung ── + { + title: 'Zweitmeinung', + slug: 'zweitmeinung', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Zweitmeinung rettet Leben', + subline: 'Eine Zweitmeinung kann entscheidend sein, um Überbehandlung und Fehlbehandlung zu vermeiden. Vertrauen Sie auf unsere Experten und erhalten Sie die bestmögliche medizinische Versorgung.', + alignment: 'center', + overlay: true, + cta: { + text: 'Jetzt Zweitmeinung anfordern', + link: '/kontakt', + style: 'primary', + }, + }, + { + blockType: 'card-grid-block', + headline: 'Unsere Fachbereiche für Zweitmeinungen', + columns: '2', + cards: [ + { + mediaType: 'icon', + icon: 'heart', + iconPosition: 'top', + title: 'Kardiologie', + description: 'Erfahren Sie die Meinung renommierter Kardiologen zu Ihrer medizinischen Diagnose und Behandlung. Qualifizierte Kardiologen mit langjähriger Erfahrung bieten Ihnen schnelle und verlässliche Zweitmeinungen.', + }, + { + mediaType: 'icon', + icon: 'flask-conical', + iconPosition: 'top', + title: 'Onkologie', + description: 'Unsere Zweitmeinung im Bereich Onkologie bietet Ihnen eine unabhängige Bewertung Ihrer medizinischen Diagnose durch erfahrene Experten.', + }, + { + mediaType: 'icon', + icon: 'scissors', + iconPosition: 'top', + title: 'Chirurgie', + description: 'Unsere Experten bieten Ihnen eine vertrauenswürdige Zweitmeinung für chirurgische Eingriffe. Wir stellen sicher, dass Sie die bestmögliche Behandlung erhalten.', + }, + { + mediaType: 'icon', + icon: 'activity', + iconPosition: 'top', + title: 'Intensivmedizin', + description: 'In der Intensivmedizin kann eine Zweitmeinung lebensrettend sein. Vertrauen Sie auf unsere hochqualifizierten Experten für Ihre intensivmedizinischen Entscheidungen.', + }, + ], + }, + { + blockType: 'card-grid-block', + headline: 'Eine zweite Meinung für Ihre Gesundheit', + columns: '3', + cards: [ + { + mediaType: 'icon', + icon: 'shield-check', + iconPosition: 'top', + title: 'Unabhängige Expertise', + description: 'Unsere Experten bieten Ihnen unabhängige Expertise und unterstützen Sie bei Ihrer Gesundheitsentscheidung.', + }, + { + mediaType: 'icon', + icon: 'ban', + iconPosition: 'top', + title: 'Unnötige Eingriffe vermeiden', + description: 'Eine zweite Meinung hilft Ihnen, unnötige Eingriffe zu vermeiden und Ihre Gesundheit zu schützen.', + }, + { + mediaType: 'icon', + icon: 'search', + iconPosition: 'top', + title: 'Sicherheit bei der Diagnose', + description: 'Unsere unabhängigen Experten bieten Ihnen Sicherheit bei der Diagnose und helfen Ihnen, die richtige Behandlung zu finden.', + }, + ], + }, + { + blockType: 'cta-block', + headline: 'Holen Sie sich eine Zweitmeinung', + description: 'Erfahren Sie mehr über unsere Zweitmeinungs-Dienstleistungen und wie sie Ihnen helfen können.', + backgroundColor: 'accent', + buttons: [ + { + text: 'Jetzt anfragen', + link: '/kontakt', + style: 'primary', + }, + { + text: '0800 80 44 100', + link: 'tel:08008044100', + style: 'outline', + }, + ], + }, + { + blockType: 'testimonials-block', + title: 'Erfahrungsberichte von Patienten', + subtitle: 'Lesen Sie, wie unsere Kunden von einer Zweitmeinung profitiert haben.', + displayMode: 'selected', + selectedTestimonials: testimonialIds, + layout: 'grid', + columns: '3', + displayOptions: { + showRating: true, + showImage: false, + showCompany: false, + }, + style: { + bg: 'light', + card: 'shadow', + quote: 'icon', + }, + }, + { + blockType: 'cta-block', + headline: 'Kontaktieren Sie uns', + description: 'Erfahren Sie mehr über unsere Leistungen und wie wir Ihnen helfen können.', + backgroundColor: 'dark', + buttons: [ + { + text: 'Kontakt aufnehmen', + link: '/kontakt', + style: 'primary', + }, + ], + }, + ], + seo: { + metaTitle: 'Zweitmeinung – complex care solutions', + metaDescription: 'Medizinische Zweitmeinung in Kardiologie, Onkologie, Chirurgie und Intensivmedizin. Unabhängig, fundiert, vertrauenswürdig.', + }, + }, + + // ── Ärzte ── + { + title: 'Für Ärzte', + slug: 'aerzte', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Kompetente Unterstützung für Sie und Ihre Patienten', + subline: 'Wir helfen Ihnen, Ihre Patienten qualitativ hochwertig zu versorgen.', + alignment: 'center', + overlay: true, + }, + { + blockType: 'text-block', + width: 'medium', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Die beste Unterstützung für Ihre Patienten' }, + { + type: 'paragraph', + text: 'Sie wollen Ihre Patienten auch in Zeiten knapper personeller und zeitlicher Kapazitäten kompetent und hochwertig versorgt wissen?', + }, + ]), + }, + { + blockType: 'card-grid-block', + headline: 'Was wir für Sie tun', + columns: '3', + cards: [ + { + mediaType: 'icon', + icon: 'clipboard-check', + iconPosition: 'top', + title: 'Qualitätsdiagnose', + description: 'Wir helfen Ihnen, eine qualitativ hochwertige Diagnose und Therapie sicherzustellen, auch bei komplexen Fallkonstellationen.', + }, + { + mediaType: 'icon', + icon: 'users', + iconPosition: 'top', + title: 'Lokale Kapazitäten ergänzen', + description: 'Wir ergänzen Ihre begrenzten lokalen ärztlichen Kapazitäten, v.a. in der Radiologie und Pathologie.', + }, + { + mediaType: 'icon', + icon: 'book-open', + iconPosition: 'top', + title: 'Leitlinien-Entwicklung', + description: 'Wir unterstützen mit unserer umfassenden Kompetenz bei der Entwicklung von Leitlinien zum Umgang mit komplexen Versorgungsfällen.', + }, + { + mediaType: 'icon', + icon: 'globe', + iconPosition: 'top', + title: 'Expertennetzwerk', + description: 'Mit unseren renommierten, anspruchsvollen Fachärzten unterschiedlicher Spezialisierungen auf nationaler und internationaler Ebene.', + }, + { + mediaType: 'icon', + icon: 'shield', + iconPosition: 'top', + title: 'Über- und Fehlversorgung minimieren', + description: 'Setzen Sie sich gemeinsam mit uns dafür ein, Über- und Fehlversorgung zu minimieren, damit die Ressourcen dorthin gelangen, wo sie gebraucht werden.', + }, + { + mediaType: 'icon', + icon: 'zap', + iconPosition: 'top', + title: 'Schnelle Prozesse', + description: 'Bei uns finden Sie schnelle und etablierte Prozesse z.B. bei der Gutachtenerstellung. So viel Transparenz und so wenig Bürokratie wie möglich!', + }, + ], + }, + { + blockType: 'cta-block', + headline: 'Werden Sie Teil unseres Expertennetzwerks!', + description: 'Kontaktieren Sie uns und erfahren Sie, wie Sie Ihre Expertise einbringen können.', + backgroundColor: 'accent', + buttons: [ + { + text: 'Kontakt aufnehmen', + link: '/kontakt', + style: 'primary', + }, + { + text: '0800 80 44 100', + link: 'tel:08008044100', + style: 'outline', + }, + ], + }, + ], + seo: { + metaTitle: 'Für Ärzte – complex care solutions', + metaDescription: 'Werden Sie Teil unseres Expertennetzwerks. Unterstützen Sie Ihre Patienten mit hochwertiger Diagnostik und Gutachten.', + }, + }, + + // ── Versicherungen ── + { + title: 'Für Versicherungen', + slug: 'versicherungen', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Für höchste Versorgungsqualität, intelligent und wirtschaftlich', + subline: 'Wir helfen, die Qualität komplexer medizinischer Versorgung zu steigern, nach vernünftigem wirtschaftlichen Maßstab.', + alignment: 'center', + overlay: true, + }, + { + blockType: 'text-block', + width: 'medium', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Hochwertige Lösungen für Ihre Versicherten' }, + { + type: 'paragraph', + text: 'Sie wollen Ihre Versicherten mit hochwertigen Lösungen und intelligenter Beratung bestmöglich versorgen, auch in Zeiten knapper finanzieller und personeller Ressourcen?', + }, + ]), + }, + { + blockType: 'card-grid-block', + headline: 'Unsere Angebote', + columns: '3', + cards: [ + { + mediaType: 'icon', + icon: 'bar-chart', + iconPosition: 'top', + title: 'Datenbasierte Forschung & KI', + description: 'Wir unterstützen Sie durch datenbasierte Forschung und KI bei der Umsetzung von Projekten im Rahmen des §25b SGB V, z.B. durch Predictive Analytics zur Optimierung der Patientensteuerung.', + }, + { + mediaType: 'icon', + icon: 'lightbulb', + iconPosition: 'top', + title: 'Versorgungsinnovationen', + description: 'Wir unterstützen Sie bei der Entwicklung innovativer Leistungsangebote nach §68 SGB V, insbesondere im Bereich digitaler Innovationen für die komplexe Patientenversorgung.', + }, + { + mediaType: 'icon', + icon: 'compass', + iconPosition: 'top', + title: 'Strategie & Organisation', + description: 'Wir unterstützen Sie, innovative Strategien zu entwickeln und umzusetzen und begleiten Sie bei der zukunftsorientierten Entwicklung Ihrer Organisation.', + }, + ], + }, + { + blockType: 'card-grid-block', + headline: 'Ihre Vorteile', + columns: '3', + cards: [ + { + mediaType: 'icon', + icon: 'piggy-bank', + iconPosition: 'top', + title: 'Kostenersparnis', + description: 'Vermeidung unnötiger Kosten durch passgenaue Versorgungsangebote.', + }, + { + mediaType: 'icon', + icon: 'heart', + iconPosition: 'top', + title: 'Bessere Versorgung', + description: 'Bessere und individuellere Versorgung Ihrer Versicherten.', + }, + { + mediaType: 'icon', + icon: 'trophy', + iconPosition: 'top', + title: 'Wettbewerbsvorteil', + description: 'Wettbewerbsvorteil durch effektive Lösungen und zeitgemäße Strategie- und Organisationskonzepte.', + }, + ], + }, + { + blockType: 'text-block', + width: 'medium', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Data Science und KI intelligent nutzen' }, + { + type: 'paragraph', + text: 'Unsere KI-basierten technologischen Lösungen bringen nicht nur Ihnen als Krankenversicherer, sondern auch Ihren Versicherten erhebliche Vorteile. Wir unterstützen Sie gerne als Partner bei der Umsetzung von Projekten nach §25b SGB V.', + }, + { + type: 'bullets', + items: [ + 'Passgenaue Patientenversorgung – Leistungen werden solchen Patientengruppen empfohlen, bei denen sie den größten Nutzen versprechen', + 'Kostenoptimierung – Effiziente Allokation finanzieller und personeller Ressourcen ermöglicht Einsparungen', + 'Steigerung der Patientenzufriedenheit – Eingehen auf die spezifischen Bedürfnisse von Patienten erhöht die Behandlungszufriedenheit', + ], + }, + ]), + }, + { + blockType: 'text-block', + width: 'medium', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Spezialisierte Expertise rund um die komplexe Versorgung' }, + { + type: 'paragraph', + text: 'Wir unterstützen Sie mit medizinischem, pflegerischem, pharmazeutischem und gesundheitsökonomischem Know-How, um bestehende Leistungen und Prozesse in der komplexen Patientenversorgung zu optimieren, z.B. durch eine medizinische Beurteilung im Rahmen eines integrierten Zweitmeinungsverfahrens.', + }, + { + type: 'paragraph', + text: 'Sie wollen Versorgungsinnovationen für Ihre Versicherten im Rahmen von §68 a,b auf den Weg bringen? Wir helfen Ihnen, die richtigen Entscheidungen zur Sicherung einer innovativen und passgenauen Versorgungsqualität zu treffen.', + }, + ]), + }, + { + blockType: 'text-block', + width: 'medium', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Strategie- und Organisationsentwicklung für Krankenkassen' }, + { + type: 'paragraph', + text: 'Mit unserer langjährigen Erfahrung im Bereich der Strategie- und Organisationsentwicklung helfen wir Ihnen in Zeiten knapper finanzieller und personeller Ressourcen, innovative Strategien sowie zukunftsorientierte Organisationsstrukturen und Arbeitsprozesse zu entwickeln und umzusetzen.', + }, + { + type: 'bullets', + items: [ + 'Wie führen Sie Ihre Organisation mit Herz und Verstand durch disruptive Zeiten?', + 'Wie kommen Ihre Teams zu schnelleren und besseren Ergebnissen und überwinden dabei eingefahrene Arbeitsweisen und Silodenken?', + ], + }, + ]), + }, + { + blockType: 'cta-block', + headline: 'Kontaktieren Sie uns', + description: 'Erfahren Sie mehr über unsere Leistungen und wie wir Ihnen helfen können.', + backgroundColor: 'dark', + buttons: [ + { + text: 'Kontakt aufnehmen', + link: '/kontakt', + style: 'primary', + }, + { + text: '0800 80 44 100', + link: 'tel:08008044100', + style: 'outline', + }, + ], + }, + ], + seo: { + metaTitle: 'Für Versicherungen – complex care solutions', + metaDescription: 'Innovative Lösungen für Krankenkassen: Data Science, Versorgungsinnovationen und Strategieberatung für die komplexe Patientenversorgung.', + }, + }, + + // ── Über uns ── + { + title: 'Über uns', + slug: 'ueber-uns', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Gesundheit in den besten Händen', + subline: 'Complex care solutions ist ein renommiertes, wertebasiertes Unternehmen im Bereich der anspruchsvollen und herausfordernden Patientenversorgung.', + alignment: 'center', + overlay: true, + }, + { + blockType: 'text-block', + width: 'medium', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Die Geschichte hinter der Gründung' }, + { + type: 'paragraph', + text: 'Complex care solutions wurde von Martin Porwoll gegründet. Die Geschichte dahinter ist geprägt von seinem Einsatz als Whistleblower im Bottroper Zytostatika-Skandal und seinem Bestreben, die Patientenversorgung zu verbessern und insbesondere Menschen zu helfen, die komplexe gesundheitliche Herausforderungen zu meistern haben.', + }, + ]), + }, + { + blockType: 'text-block', + width: 'medium', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Woran wir glauben' }, + { + type: 'paragraph', + text: 'Die individuell beste Behandlung ist nicht immer die Maximaltherapie. In hochentwickelten Gesundheitssystemen gibt es oft auch eine medizinische Überversorgung, die von den Patienten selbst nicht gewünscht wird.', + }, + { + type: 'paragraph', + text: 'Wir setzen uns leidenschaftlich dafür ein, dass diagnostische und therapeutische Entscheidungen unabhängig von wirtschaftlichen Anreizen getroffen werden.', + }, + { + type: 'paragraph', + text: 'Damit schaffen wir einen Beitrag für Patienten und für unsere Solidargemeinschaft.', + }, + ]), + }, + { + blockType: 'card-grid-block', + headline: 'Was wir mitbringen', + columns: '3', + cards: [ + { + mediaType: 'icon', + icon: 'brain', + iconPosition: 'top', + title: 'Tiefgreifende Expertise', + description: 'Tiefgreifende, langjährige Expertise und Kenntnis unseres Gesundheitssystems.', + }, + { + mediaType: 'icon', + icon: 'network', + iconPosition: 'top', + title: 'Weitreichendes Netzwerk', + description: 'Ein weitreichendes Netzwerk ausgewiesener Experten im medizinischen, pflegerischen, pharmazeutischen und ökonomischen Umfeld.', + }, + { + mediaType: 'icon', + icon: 'heart', + iconPosition: 'top', + title: 'Ethische Grundhaltung', + description: 'Eine zutiefst ethische Grundhaltung und absolute Integrität im Sinne des Patienten und der Solidargemeinschaft.', + }, + ], + }, + { + blockType: 'cta-block', + headline: 'Lernen Sie uns kennen', + description: 'Haben Sie Fragen zu unseren Leistungen? Kontaktieren Sie uns.', + backgroundColor: 'accent', + buttons: [ + { + text: 'Kontakt aufnehmen', + link: '/kontakt', + style: 'primary', + }, + ], + }, + ], + seo: { + metaTitle: 'Über uns – complex care solutions GmbH', + metaDescription: 'Lernen Sie complex care solutions kennen: Unabhängige medizinische Expertise, ethische Grundhaltung und ein weitreichendes Expertennetzwerk.', + }, + }, + + // ── Motivation ── + { + title: 'Motivation', + slug: 'motivation', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Patientenwohl im Mittelpunkt', + subline: 'Wir sind Streiter für das Patientenwohl.', + alignment: 'center', + overlay: true, + }, + { + blockType: 'text-block', + width: 'medium', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Unser Fokus' }, + { + type: 'paragraph', + text: 'Seit der Gründung konzentrieren wir uns darauf, Versorgungsangebote zu optimieren und dabei die Bedürfnisse der Patienten in den Mittelpunkt zu stellen. Wir wollen einen nachhaltigen Beitrag zur Verbesserung der Patientenversorgung und zur Maximierung des Patientenwohls leisten.', + }, + { + type: 'bullets', + items: [ + 'Wir verfolgen einen patientenzentrierten Ansatz.', + 'Wir legen besonderen Wert auf Transparenz, Unabhängigkeit und Qualitätssicherung.', + 'Mit unserem nationalen und internationalen Expertennetzwerk entwickeln wir innovative Lösungen und Dienstleistungen, die auf die individuellen Bedürfnisse von Patienten zugeschnitten sind.', + ], + }, + ]), + }, + { + blockType: 'text-block', + width: 'medium', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Motivation und Geschichte' }, + { + type: 'paragraph', + text: 'Complex care solutions wurde von Martin Porwoll, dem Whistleblower des Bottroper Zytoskandals, gegründet.', + }, + { + type: 'paragraph', + text: 'Aus seinen Erfahrungen und der Erkenntnis um die Bedeutung von Transparenz und Patientenwohl entstand die Idee, ein unabhängiges Unternehmen zu etablieren, das innovative Lösungen für Patienten in komplexen Versorgungssituationen entwickelt.', + }, + ]), + }, + { + blockType: 'quote-block', + quote: 'Der Bottroper Zytoskandal, den ich im Jahr 2016 als Whistleblower aufgedeckt habe, hat mich zutiefst erschüttert. Seitdem habe ich mich dem Auftrag verschrieben, Patienteninteressen im Gesundheitswesen zu vertreten und Menschen mit komplexen gesundheitlichen Herausforderungen bestmöglich zu unterstützen. Complex care solutions steht für einen nachhaltigen Beitrag zur Verbesserung der Patientenversorgung und Maximierung des Patientenwohls.', + author: 'Martin Porwoll', + role: 'Gründer & Geschäftsführer', + style: 'highlighted', + }, + { + blockType: 'text-block', + width: 'medium', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Der Zytoskandal Bottrop und Martin Porwoll' }, + { + type: 'paragraph', + text: 'Die Gründung von complex care solutions ist eng mit der persönlichen Geschichte des Gründers Martin Porwoll verbunden.', + }, + { + type: 'paragraph', + text: 'Im Jahr 2016 deckte er als Whistleblower den sogenannten Zytoskandal in Bottrop auf, bei dem ein Apotheker über Jahre hinweg Krebsmedikamente für tausende Patienten gestreckt hatte, um seinen Gewinn zu maximieren.', + }, + { + type: 'paragraph', + text: 'Dieser Skandal erschütterte das Vertrauen der Öffentlichkeit in das Gesundheitswesen und zeigte auf erschreckende Weise, welche negativen Folgen ökonomische Anreize und fehlende Qualitätskontrollen für das Wohl der Patienten haben können.', + }, + ]), + }, + { + blockType: 'text-block', + width: 'medium', + content: createRichTextComplex([ + { type: 'heading', tag: 'h2', text: 'Martin Porwolls Weg zum Streiter für das Patientenwohl' }, + { + type: 'paragraph', + text: 'Die Erfahrungen mit dem Zytoskandal haben Martin Porwoll zu einem engagierten Kämpfer für das Patientenwohl und gegen Missstände im Gesundheitswesen gemacht.', + }, + { + type: 'paragraph', + text: 'Er erkannte die Notwendigkeit, sich aktiv für die Verbesserung der Patientenversorgung und z.B. gegen Übertherapie einzusetzen.', + }, + { + type: 'paragraph', + text: 'So gründete er Complex care solutions mit dem Ziel, Patienten in komplexen Versorgungssituationen bestmöglich zu unterstützen und ihre Interessen im Gesundheitswesen zu vertreten.', + }, + ]), + }, + { + blockType: 'cta-block', + headline: 'Kontaktieren Sie uns', + description: 'Erfahren Sie mehr über unsere Leistungen und wie wir Ihnen helfen können.', + backgroundColor: 'dark', + buttons: [ + { + text: 'Kontakt aufnehmen', + link: '/kontakt', + style: 'primary', + }, + { + text: '0800 80 44 100', + link: 'tel:08008044100', + style: 'outline', + }, + ], + }, + ], + seo: { + metaTitle: 'Motivation – complex care solutions', + metaDescription: 'Die Geschichte hinter complex care solutions: Vom Zytoskandal-Whistleblower zur unabhängigen Patientenberatung.', + }, + }, + + // ── Kontakt ── + { + title: 'Kontakt', + slug: 'kontakt', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Kontakt', + subline: 'Wir sind für Sie da – nehmen Sie Kontakt mit uns auf.', + alignment: 'center', + overlay: true, + }, + ...(contactFormId + ? [ + { + blockType: 'contact-form-block', + form: contactFormId, + headline: 'Schreiben Sie uns', + description: 'Füllen Sie das Formular aus und wir melden uns schnellstmöglich bei Ihnen.', + showContactInfo: true, + showPhone: true, + showAddress: true, + showSocials: true, + }, + ] + : []), + ], + seo: { + metaTitle: 'Kontakt – complex care solutions', + metaDescription: 'Kontaktieren Sie uns für Ihre medizinische Beratung. Kostenlose Erstberatung unter 0800 80 44 100.', + }, + }, + + // ── Impressum ── + { + title: 'Impressum', + slug: 'impressum', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Impressum', + alignment: 'center', + overlay: true, + }, + { + blockType: 'text-block', + width: 'narrow', + content: createRichTextComplex([ + { type: 'paragraph-bold', text: 'complex care solutions GmbH' }, + { type: 'paragraph', text: 'Hans-Böckler-Str. 19' }, + { type: 'paragraph', text: '46236 Bottrop' }, + { type: 'heading', tag: 'h2', text: 'Handelsregister' }, + { type: 'paragraph', text: 'Handelsregister: HRB 15753' }, + { type: 'paragraph', text: 'Registergericht: Gelsenkirchen' }, + { type: 'heading', tag: 'h2', text: 'Vertreten durch' }, + { type: 'paragraph', text: 'Martin Porwoll' }, + { type: 'heading', tag: 'h2', text: 'Kontakt' }, + { type: 'paragraph', text: 'Telefon: 0800 80 44 100' }, + { type: 'paragraph', text: 'Telefax: 0800 80 44 190' }, + { type: 'paragraph', text: 'E-Mail: kontakt@complexcaresolutions.de' }, + { type: 'heading', tag: 'h2', text: 'Umsatzsteuer-ID' }, + { type: 'paragraph', text: 'Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz: DE334815479' }, + { type: 'heading', tag: 'h2', text: 'Redaktionell verantwortlich' }, + { type: 'paragraph', text: 'Martin Porwoll' }, + { type: 'paragraph', text: 'Hans-Böckler-Str. 19' }, + { type: 'paragraph', text: '46236 Bottrop' }, + { type: 'heading', tag: 'h2', text: 'EU-Streitschlichtung' }, + { type: 'paragraph', text: 'Die Europäische Kommission stellt eine Plattform zur Online-Streitbeilegung (OS) bereit: https://ec.europa.eu/consumers/odr/. Unsere E-Mail-Adresse finden Sie oben im Impressum.' }, + { type: 'heading', tag: 'h2', text: 'Verbraucherstreitbeilegung/Universalschlichtungsstelle' }, + { type: 'paragraph', text: 'Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.' }, + ]), + }, + ], + seo: { + metaTitle: 'Impressum – complex care solutions GmbH', + metaDescription: 'Impressum der complex care solutions GmbH, Hans-Böckler-Str. 19, 46236 Bottrop.', + }, + }, + + // ── Datenschutzerklärung ── + { + title: 'Datenschutzerklärung', + slug: 'datenschutzerklaerung', + status: 'published', + layout: [ + { + blockType: 'hero-block', + headline: 'Datenschutzerklärung', + alignment: 'center', + overlay: true, + }, + { + blockType: 'html-embed-block', + title: 'Alfright Datenschutzgenerator', + code: '
\n', + maxWidth: 'full', + }, + ], + seo: { + metaTitle: 'Datenschutzerklärung – complex care solutions GmbH', + metaDescription: 'DSGVO-konforme Datenschutzerklärung für complexcaresolutions.de, bereitgestellt von alfright.eu.', + }, + }, + ] + + let pageCount = 0 + for (const page of pages) { + const id = await upsert(payload, 'pages', { slug: { equals: page.slug } }, page) + console.log(`✓ Page "${page.title}" (slug: ${page.slug}, ID: ${id})`) + pageCount++ + } + + // ════════════════════════════════════════════ + // 7. BLOG POST + // ════════════════════════════════════════════ + console.log('\n--- 7. Blog Post ---') + + const blogPostContent = createRichTextComplex([ + { + type: 'paragraph', + text: 'In der medizinischen Behandlung von Sterbenden, Krebspatienten im Endstadium und Patienten mit anderen lebensbedrohenden Erkrankungen, ist es nicht immer einfach zu wissen, wann genug Medizin ist. Umso wichtiger ist es, dass Mediziner, Patienten, Angehörige und Krankenkassen am gleichen Strang ziehen.', + }, + { + type: 'paragraph', + text: 'Die letzte Erinnerung an meinen Großvater ist ein gespenstisches Bild. Es hat sich vor dreißig Jahren in mein Gehirn gebrannt. Der sehr alte Mann liegt sterbenskrank auf der Intensivstation, sein Körper an allerlei lärmende Maschinen angeschlossen. Das Gesicht ist fahl und aufgedunsen, ohne jede Regung.', + }, + { + type: 'paragraph', + text: 'Lebt Großvater noch? fragte ich mich damals und suchte nach einer Antwort. Später, als sein Tod offiziell war, stand in der Familie eine andere Frage im Zentrum: Haben die Maschinen Großvater am friedlichen Sterben gehindert? Einige äußerten sogar den Verdacht, die Ärzte hätten aus rein finanziellen Interessen das Leben qualvoll verlängert.', + }, + { + type: 'paragraph', + text: 'Diese Gedanken sind Ausdruck davon, dass wir Großvaters Behandlung als übertrieben und unnötig empfanden. Heute würde man von Übertherapie am Lebensende sprechen. Dass das ein weitverbreitetes Problem ist, ist unbestritten.', + }, + { + type: 'paragraph', + text: 'Das Zuviel an Medizin in der letzten Lebensphase hat nicht nur mit unserer Angst vor dem Tod, sondern auch mit Versagensängsten der Ärzte und finanziellen Fehlanreizen zu tun.', + }, + { + type: 'paragraph', + text: 'Eine intensive medizinische Versorgung am Lebensende, die nur den Prozess des Sterbens verlängert, lehnen die meisten Menschen ab. Laut den Ergebnissen einer Umfrage in den USA trifft das auch für die Ärzte zu – zumindest immer dann, wenn diese selbst im Sterben liegen.', + }, + { + type: 'paragraph', + text: 'Bei ihren Patienten scheinen viele Mediziner aber nach einer anderen Maxime vorzugehen. Jedenfalls bekundeten etliche der an der Erhebung beteiligten Ärzte die Bereitschaft, sterbenskranke Patienten auch dann noch aggressiv zu behandeln, wenn sich diese ausdrücklich gegen solche Maßnahmen ausgesprochen hatten.', + }, + { + type: 'paragraph', + text: 'Knapp 90 Prozent der befragten Ärzte wünschten im Falle einer eigenen tödlichen Erkrankung keine aggressiven Therapien. Weshalb sie dann ihren Patienten eine solche Behandlung zumuten würden, geht aus der Erhebung nicht hervor. Laut den Studienautoren liegt dies unter anderem daran, dass die moderne Medizin auf "Maximal-Therapie für alle" gerichtet ist.', + }, + { + type: 'paragraph', + text: 'Diese Kräfte sind auch bei Krebskranken wirksam. Das zeigt eine weitere Studie aus den USA, deren Ergebnisse auf Deutschland übertragbar sind. Für ihre Analyse haben die Forscher die Therapien von mehr als 100.000 Patienten mit Krebs im Spätstadium studiert. Diese hatten bei der Diagnosestellung bereits Fernmetastasen und waren innerhalb eines Monats tot.', + }, + { + type: 'paragraph', + text: 'Trotz der terminalen Prognose erhielten etliche Patienten noch medizinische Behandlungen: Operationen, Chemo-, Antikörper- und Strahlentherapien oder Hormone.', + }, + { + type: 'paragraph', + text: 'Die Schuld an dieser Entwicklung nur bei den Ärzten zu suchen, ist nicht gänzlich richtig. Der Behandlungsentscheid steht auf zwei Säulen: der medizinischen Indikation und der Zustimmung des Patienten.', + }, + { + type: 'paragraph', + text: 'In vielen Ländern müssen Patienten darum kämpfen, das medizinisch Notwendige zu erhalten. In Deutschland und anderen Ländern, die über einen hohen medizinischen Versorgungsstandard verfügen, ist das Gegenteil der Fall. Denn nicht nur zu wenig, auch zu viel Medizin ist unethisch. Übertherapie ist nicht noch bessere Medizin oder noch mehr vom Guten, sie schadet.', + }, + { + type: 'paragraph', + text: 'Die von der complex care solutions GmbH angebotenen Zweitmeinungsverfahren sind der Schlüssel zur Korrektur von Fehlentwicklungen und Fehlanreizen auf individueller und institutioneller Ebene, sowie einem transparenten Umgang mit Interessenkonflikten.', + }, + { + type: 'paragraph', + text: 'Im Zweitmeinungsverfahren werden im Erstgespräch die Patienten und deren Angehörige in der zumeist sehr leidvollen Situation aufgefangen und ohne Zeitdruck die momentane medizinische aber vor allem menschliche Situation eruiert. In der anschließenden fachmedizinischen Begutachtung wird der Fokus auf die medizinische Sinnhaftigkeit der geplanten oder durchgeführten Behandlungen gelegt.', + }, + { + type: 'paragraph', + text: 'Überversorgung spielt allerdings nicht nur am Sterbebett eine Rolle, sondern ist in der Medizin generell von erheblicher Bedeutung. Auch auf diesem Feld kann ein konsequent etabliertes Zweitmeinungsverfahren vor entsprechenden medizinischen Prozeduren den Patienten zu einer aufgeklärten und rationalen Entscheidung befähigen und vor wachsender Übertherapie schützen.', + }, + ]) + + await upsert( + payload, + 'posts', + { slug: { equals: 'geheimnis-der-guten-fuersorge' } }, + { + title: 'Geheimnis der guten Fürsorge – warum Übertherapie ein gesellschaftliches Problem ist', + slug: 'geheimnis-der-guten-fuersorge', + type: 'blog', + status: 'published', + publishedAt: new Date('2024-09-15').toISOString(), + excerpt: 'In der medizinischen Behandlung von Sterbenden und Krebspatienten im Endstadium ist es nicht immer einfach zu wissen, wann genug Medizin ist. Über Übertherapie als gesellschaftliches Problem.', + content: blogPostContent, + seo: { + metaTitle: 'Geheimnis der guten Fürsorge – Übertherapie als Problem', + metaDescription: 'Warum Übertherapie ein gesellschaftliches Problem ist und wie Zweitmeinungsverfahren helfen können.', + }, + }, + ) + console.log('✓ Blog post "Geheimnis der guten Fürsorge" created/updated') + + // ════════════════════════════════════════════ + // DONE + // ════════════════════════════════════════════ + console.log('\n✅ Seed complete! All content for tenant 10 (c2s) has been created.') + console.log('\nSummary:') + console.log(' - Site Settings: 1') + console.log(` - Social Links: ${socialLinks.length}`) + console.log(` - Testimonials: ${testimonials.length}`) + console.log(' - Navigation: 1') + console.log(' - Contact Form: 1') + console.log(` - Pages: ${pageCount}`) + console.log(' - Blog Posts: 1') + + process.exit(0) +} + +seed().catch((err) => { + console.error('❌ Seed failed:', err) + process.exit(1) +}) From 52a266d72de58f9c511006950db6c145cf31495f Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sun, 1 Mar 2026 22:14:44 +0000 Subject: [PATCH 2/3] docs: add telegram media bot plan and sensualmoment design docs - Telegram media bot implementation plan and prompt - sensualmoment.de design prototypes (color scheme, prototype, design doc) Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-01-telegram-media-bot.md | 1155 +++++++++++++++++++ docs/sensualmoments/farbschema.html | 477 ++++++++ docs/sensualmoments/prototype.html | 928 +++++++++++++++ docs/sensualmoments/sensualmomentsdesign.md | 467 ++++++++ prompts/PROMPT_TELEGRAM_MEDIA_BOT.md | 749 ++++++++++++ 5 files changed, 3776 insertions(+) create mode 100644 docs/plans/2026-03-01-telegram-media-bot.md create mode 100644 docs/sensualmoments/farbschema.html create mode 100644 docs/sensualmoments/prototype.html create mode 100644 docs/sensualmoments/sensualmomentsdesign.md create mode 100644 prompts/PROMPT_TELEGRAM_MEDIA_BOT.md diff --git a/docs/plans/2026-03-01-telegram-media-bot.md b/docs/plans/2026-03-01-telegram-media-bot.md new file mode 100644 index 0000000..711f1cc --- /dev/null +++ b/docs/plans/2026-03-01-telegram-media-bot.md @@ -0,0 +1,1155 @@ +# Telegram Media Upload Bot — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a Telegram bot that uploads images from chat to Payload CMS Media collection with multi-tenant support. + +**Architecture:** Standalone Node.js service using Grammy (Telegram bot framework) with long-polling. Authenticates against Payload REST API, downloads images from Telegram servers, uploads via multipart/form-data. Runs as PM2 process on sv-payload alongside existing CMS. + +**Tech Stack:** Node.js 22, TypeScript (strict), Grammy, native fetch/FormData, PM2, pnpm + +--- + +### Task 1: Project Scaffold + +**Files:** +- Create: `/home/payload/telegram-media-bot/package.json` +- Create: `/home/payload/telegram-media-bot/tsconfig.json` +- Create: `/home/payload/telegram-media-bot/.gitignore` +- Create: `/home/payload/telegram-media-bot/.env.example` + +**Step 1: Create project directory and init** + +```bash +mkdir -p /home/payload/telegram-media-bot +cd /home/payload/telegram-media-bot +pnpm init +``` + +**Step 2: Write package.json** + +```json +{ + "name": "telegram-media-bot", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "tsc --noEmit" + } +} +``` + +**Step 3: Install dependencies** + +```bash +pnpm add grammy dotenv +pnpm add -D typescript @types/node tsx +``` + +**Step 4: Write tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +**Step 5: Write .gitignore** + +``` +node_modules/ +dist/ +.env +logs/ +*.log +``` + +**Step 6: Write .env.example** + +```env +# Telegram Bot +TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 + +# Zugelassene Telegram User-IDs (kommasepariert) +ALLOWED_USER_IDS=123456789,987654321 + +# Payload CMS +PAYLOAD_API_URL=https://cms.c2sgmbh.de/api +PAYLOAD_ADMIN_EMAIL=admin@example.com +PAYLOAD_ADMIN_PASSWORD=your-secure-password + +# Standard-Tenant (wird verwendet wenn kein Tenant gewählt) +DEFAULT_TENANT_ID=4 + +# Logging +LOG_LEVEL=info + +# Node Environment +NODE_ENV=production +``` + +**Step 7: Create directory structure** + +```bash +mkdir -p src/{payload,telegram,middleware,utils} +mkdir -p logs +``` + +**Step 8: Commit** + +```bash +cd /home/payload/telegram-media-bot +git init +git add -A +git commit -m "chore: project scaffold with deps and config" +``` + +--- + +### Task 2: Config & Logger + +**Files:** +- Create: `src/config.ts` +- Create: `src/utils/logger.ts` + +**Step 1: Write src/config.ts** + +Typed config that validates all env vars on import. `ALLOWED_USER_IDS` parsed as `number[]`. Missing required vars → process.exit(1) with clear error. + +```typescript +import 'dotenv/config'; + +interface Config { + telegram: { + botToken: string; + allowedUserIds: number[]; + }; + payload: { + apiUrl: string; + email: string; + password: string; + }; + defaultTenantId: number; + logLevel: string; + nodeEnv: string; +} + +function requireEnv(key: string): string { + const value = process.env[key]; + if (!value) { + console.error(`❌ Missing required environment variable: ${key}`); + process.exit(1); + } + return value; +} + +export const config: Config = { + telegram: { + botToken: requireEnv('TELEGRAM_BOT_TOKEN'), + allowedUserIds: requireEnv('ALLOWED_USER_IDS') + .split(',') + .map((id) => { + const num = Number(id.trim()); + if (Number.isNaN(num)) { + console.error(`❌ Invalid user ID in ALLOWED_USER_IDS: "${id}"`); + process.exit(1); + } + return num; + }), + }, + payload: { + apiUrl: requireEnv('PAYLOAD_API_URL'), + email: requireEnv('PAYLOAD_ADMIN_EMAIL'), + password: requireEnv('PAYLOAD_ADMIN_PASSWORD'), + }, + defaultTenantId: Number(process.env.DEFAULT_TENANT_ID || '4'), + logLevel: process.env.LOG_LEVEL || 'info', + nodeEnv: process.env.NODE_ENV || 'development', +}; +``` + +**Step 2: Write src/utils/logger.ts** + +Console-based logger with levels, timestamps, module tags. + +```typescript +const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const; +type LogLevel = keyof typeof LEVELS; + +let minLevel: number = LEVELS.info; + +export function setLogLevel(level: LogLevel): void { + minLevel = LEVELS[level]; +} + +function formatTimestamp(): string { + return new Date().toISOString().replace('T', ' ').slice(0, 19); +} + +function log(level: LogLevel, module: string, message: string, data?: unknown): void { + if (LEVELS[level] < minLevel) return; + const prefix = `[${formatTimestamp()}] [${level.toUpperCase()}] [${module}]`; + if (data !== undefined) { + console[level === 'debug' ? 'log' : level](`${prefix} ${message}`, data); + } else { + console[level === 'debug' ? 'log' : level](`${prefix} ${message}`); + } +} + +export function createLogger(module: string) { + return { + debug: (msg: string, data?: unknown) => log('debug', module, msg, data), + info: (msg: string, data?: unknown) => log('info', module, msg, data), + warn: (msg: string, data?: unknown) => log('warn', module, msg, data), + error: (msg: string, data?: unknown) => log('error', module, msg, data), + }; +} +``` + +**Step 3: Verify build** + +```bash +pnpm lint +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat: add typed config validation and logger utility" +``` + +--- + +### Task 3: Download Helper & Payload Client + +**Files:** +- Create: `src/utils/download.ts` +- Create: `src/payload/client.ts` + +**Step 1: Write src/utils/download.ts** + +Native fetch download with 30s timeout, 20MB size limit. + +```typescript +import { createLogger } from './logger.js'; + +const log = createLogger('Download'); +const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20 MB + +export async function downloadFile(url: string): Promise<{ buffer: Buffer; mimeType: string }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 30_000); + + try { + const response = await fetch(url, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Download failed: HTTP ${response.status}`); + } + + const contentLength = Number(response.headers.get('content-length') || '0'); + if (contentLength > MAX_FILE_SIZE) { + throw new Error(`Datei zu groß (${(contentLength / 1024 / 1024).toFixed(1)} MB). Maximum: 20 MB`); + } + + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength > MAX_FILE_SIZE) { + throw new Error(`Datei zu groß (${(arrayBuffer.byteLength / 1024 / 1024).toFixed(1)} MB). Maximum: 20 MB`); + } + + const mimeType = response.headers.get('content-type') || 'application/octet-stream'; + log.debug(`Downloaded ${arrayBuffer.byteLength} bytes, type: ${mimeType}`); + + return { buffer: Buffer.from(arrayBuffer), mimeType }; + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + throw new Error('Download-Timeout: Server hat nicht innerhalb von 30 Sekunden geantwortet'); + } + throw error; + } finally { + clearTimeout(timeout); + } +} +``` + +**Step 2: Write src/payload/client.ts** + +Full PayloadClient class: login, token caching, JWT decode for expiry, auto-refresh, retry on 401, upload/list/delete media with tenant scoping. + +```typescript +import { config } from '../config.js'; +import { createLogger } from '../utils/logger.js'; + +const log = createLogger('PayloadClient'); + +interface MediaUploadOptions { + alt: string; + tenantId: number; + caption?: string; +} + +interface MediaDoc { + id: number; + url: string; + filename: string; + alt: string; + mimeType: string; + filesize: number; + width?: number; + height?: number; + sizes?: Record; + createdAt: string; +} + +interface MediaListResponse { + docs: MediaDoc[]; + totalDocs: number; +} + +interface TenantDoc { + id: number; + name: string; + slug: string; +} + +function decodeJwtExpiry(token: string): number { + const parts = token.split('.'); + if (parts.length !== 3) return 0; + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString()); + return payload.exp || 0; +} + +class PayloadClient { + private token: string | null = null; + private tokenExpiry: number = 0; + private readonly apiUrl: string; + + constructor() { + this.apiUrl = config.payload.apiUrl; + } + + async login(): Promise { + log.info('Logging in to Payload CMS...'); + const response = await fetch(`${this.apiUrl}/users/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: config.payload.email, + password: config.payload.password, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Payload login failed (${response.status}): ${text}`); + } + + const data = await response.json(); + this.token = data.token; + this.tokenExpiry = decodeJwtExpiry(data.token); + log.info(`Login successful, token expires at ${new Date(this.tokenExpiry * 1000).toISOString()}`); + } + + async getToken(): Promise { + const now = Math.floor(Date.now() / 1000); + const buffer = 300; // 5 min buffer + + if (!this.token || this.tokenExpiry <= now + buffer) { + await this.login(); + } + + return this.token!; + } + + private async authFetch(url: string, init: RequestInit = {}, retry = true): Promise { + const token = await this.getToken(); + const headers = new Headers(init.headers); + headers.set('Authorization', `JWT ${token}`); + + const response = await fetch(url, { ...init, headers }); + + if (response.status === 401 && retry) { + log.warn('Got 401, re-authenticating...'); + this.token = null; + return this.authFetch(url, init, false); + } + + return response; + } + + async uploadMedia(file: Buffer, filename: string, mimeType: string, options: MediaUploadOptions): Promise { + const formData = new FormData(); + formData.append('alt', options.alt); + if (options.caption) formData.append('caption', options.caption); + formData.append('tenant', String(options.tenantId)); + + const blob = new Blob([file], { type: mimeType }); + formData.append('file', blob, filename); + + log.info(`Uploading ${filename} (${(file.byteLength / 1024).toFixed(1)} KB) to tenant ${options.tenantId}`); + + const response = await this.authFetch(`${this.apiUrl}/media`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Upload failed (${response.status}): ${text}`); + } + + const data = await response.json(); + log.info(`Upload successful: ID ${data.doc.id}, ${data.doc.filename}`); + return data.doc; + } + + async listMedia(tenantId: number, limit = 5): Promise { + const params = new URLSearchParams({ + 'where[tenant][equals]': String(tenantId), + sort: '-createdAt', + limit: String(limit), + }); + + const response = await this.authFetch(`${this.apiUrl}/media?${params}`); + if (!response.ok) { + throw new Error(`List media failed (${response.status})`); + } + + return response.json(); + } + + async deleteMedia(mediaId: number): Promise { + const response = await this.authFetch(`${this.apiUrl}/media/${mediaId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(`Delete media failed (${response.status})`); + } + + log.info(`Deleted media ID ${mediaId}`); + } + + async listTenants(): Promise { + const response = await this.authFetch(`${this.apiUrl}/tenants?limit=50`); + if (!response.ok) { + throw new Error(`List tenants failed (${response.status})`); + } + + const data = await response.json(); + return data.docs; + } + + async checkHealth(): Promise { + try { + const response = await this.authFetch(`${this.apiUrl}/users/me`); + return response.ok; + } catch { + return false; + } + } + + getTokenExpiry(): Date | null { + return this.tokenExpiry > 0 ? new Date(this.tokenExpiry * 1000) : null; + } +} + +export const payloadClient = new PayloadClient(); +export type { MediaDoc, MediaUploadOptions, MediaListResponse, TenantDoc }; +``` + +**Step 3: Verify build** + +```bash +pnpm lint +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat: add download helper and Payload API client with token caching" +``` + +--- + +### Task 4: Auth Middleware & Bot Setup + +**Files:** +- Create: `src/middleware/auth.ts` +- Create: `src/bot.ts` + +**Step 1: Write src/middleware/auth.ts** + +Grammy middleware that checks `ctx.from.id` against whitelist. + +```typescript +import { type Context, type NextFunction } from 'grammy'; +import { config } from '../config.js'; +import { createLogger } from '../utils/logger.js'; + +const log = createLogger('Auth'); + +export async function authMiddleware(ctx: Context, next: NextFunction): Promise { + const userId = ctx.from?.id; + + if (!userId || !config.telegram.allowedUserIds.includes(userId)) { + log.warn(`Unauthorized access attempt from user ${userId || 'unknown'}`); + await ctx.reply('⛔ Du bist nicht autorisiert, diesen Bot zu verwenden.'); + return; + } + + await next(); +} +``` + +**Step 2: Write src/bot.ts** + +Grammy bot with session, auth middleware, rate limiting map. + +```typescript +import { Bot, session, type Context, type SessionFlavor } from 'grammy'; +import { config } from './config.js'; +import { authMiddleware } from './middleware/auth.js'; +import { createLogger } from './utils/logger.js'; + +const log = createLogger('Bot'); + +interface SessionData { + selectedTenantId: number; + selectedTenantName: string; +} + +type BotContext = Context & SessionFlavor; + +// Rate limiting: track uploads per user +const uploadCounts = new Map(); + +function checkRateLimit(userId: number): { allowed: boolean; retryAfter?: number } { + const now = Date.now(); + const entry = uploadCounts.get(userId); + + if (!entry || entry.resetAt <= now) { + uploadCounts.set(userId, { count: 1, resetAt: now + 60_000 }); + return { allowed: true }; + } + + if (entry.count >= 10) { + const retryAfter = Math.ceil((entry.resetAt - now) / 1000); + return { allowed: false, retryAfter }; + } + + entry.count++; + return { allowed: true }; +} + +function createBot(): Bot { + const bot = new Bot(config.telegram.botToken); + + // Session middleware + bot.use( + session({ + initial: (): SessionData => ({ + selectedTenantId: config.defaultTenantId, + selectedTenantName: 'Default', + }), + }), + ); + + // Auth middleware + bot.use(authMiddleware); + + log.info('Bot instance created'); + return bot; +} + +export { createBot, checkRateLimit }; +export type { BotContext, SessionData }; +``` + +**Step 3: Verify build** + +```bash +pnpm lint +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat: add auth middleware and bot setup with session and rate limiting" +``` + +--- + +### Task 5: Keyboards & Command Handlers + +**Files:** +- Create: `src/telegram/keyboards.ts` +- Create: `src/telegram/handlers.ts` + +**Step 1: Write src/telegram/keyboards.ts** + +Inline keyboard for tenant selection, dynamically loaded from API. + +```typescript +import { InlineKeyboard } from 'grammy'; +import { payloadClient, type TenantDoc } from '../payload/client.js'; +import { createLogger } from '../utils/logger.js'; + +const log = createLogger('Keyboards'); + +let cachedTenants: TenantDoc[] = []; +let tenantsCacheExpiry = 0; + +async function getTenants(): Promise { + const now = Date.now(); + if (cachedTenants.length > 0 && tenantsCacheExpiry > now) { + return cachedTenants; + } + + try { + cachedTenants = await payloadClient.listTenants(); + tenantsCacheExpiry = now + 5 * 60 * 1000; // 5 min cache + log.info(`Loaded ${cachedTenants.length} tenants`); + } catch (error) { + log.error('Failed to load tenants', error); + if (cachedTenants.length > 0) return cachedTenants; // stale fallback + throw error; + } + + return cachedTenants; +} + +export async function buildTenantKeyboard(): Promise { + const tenants = await getTenants(); + const keyboard = new InlineKeyboard(); + + tenants.forEach((tenant, i) => { + keyboard.text(tenant.name, `tenant:${tenant.id}`); + if (i % 2 === 1) keyboard.row(); // 2 per row + }); + + // If odd number of tenants, close the last row + if (tenants.length % 2 === 1) keyboard.row(); + + return keyboard; +} + +export function getTenantName(tenantId: number): string { + const tenant = cachedTenants.find((t) => t.id === tenantId); + return tenant?.name || `Tenant ${tenantId}`; +} +``` + +**Step 2: Write src/telegram/handlers.ts** + +All command handlers (/start, /tenant, /list, /status, /help), photo handler, document handler, album handler, callback query handler. + +```typescript +import type { Bot } from 'grammy'; +import type { BotContext } from '../bot.js'; +import { payloadClient } from '../payload/client.js'; +import { downloadFile } from '../utils/download.js'; +import { createLogger } from '../utils/logger.js'; +import { checkRateLimit } from '../bot.js'; +import { buildTenantKeyboard, getTenantName } from './keyboards.js'; + +const log = createLogger('Handlers'); +const startTime = Date.now(); + +const ALLOWED_MIME_TYPES = new Set([ + 'image/jpeg', 'image/png', 'image/webp', 'image/avif', + 'image/gif', 'image/svg+xml', +]); + +const ALLOWED_EXTENSIONS = new Set([ + 'jpg', 'jpeg', 'png', 'webp', 'avif', 'gif', 'svg', +]); + +// Album debounce: collect media_group messages +const albumBuffers = new Map; timer: ReturnType }>(); + +export function registerHandlers(bot: Bot): void { + // Commands + bot.command('start', handleStart); + bot.command('tenant', handleTenant); + bot.command('list', handleList); + bot.command('status', handleStatus); + bot.command('help', handleHelp); + + // Callback queries (tenant selection) + bot.callbackQuery(/^tenant:(\d+)$/, handleTenantCallback); + + // Photo messages + bot.on('message:photo', handlePhoto); + + // Document messages (for original quality images) + bot.on('message:document', handleDocument); + + log.info('All handlers registered'); +} + +async function handleStart(ctx: BotContext): Promise { + const tenantName = getTenantName(ctx.session.selectedTenantId); + await ctx.reply( + `🤖 *Payload Media Upload Bot*\n\n` + + `Schicke mir ein Bild und ich lade es in die Payload CMS Media-Bibliothek hoch.\n\n` + + `📌 Aktueller Tenant: *${tenantName}*\n` + + `📋 Befehle:\n` + + `/tenant \\- Tenant wechseln\n` + + `/list \\- Letzte 5 Uploads anzeigen\n` + + `/status \\- Bot\\- und API\\-Status\n` + + `/help \\- Hilfe anzeigen`, + { parse_mode: 'MarkdownV2' }, + ); +} + +async function handleTenant(ctx: BotContext): Promise { + try { + const keyboard = await buildTenantKeyboard(); + await ctx.reply('🏢 Wähle einen Tenant:', { reply_markup: keyboard }); + } catch { + await ctx.reply('❌ Konnte Tenants nicht laden. Versuche es später erneut.'); + } +} + +async function handleTenantCallback(ctx: BotContext): Promise { + const match = ctx.callbackQuery?.data?.match(/^tenant:(\d+)$/); + if (!match) return; + + const tenantId = Number(match[1]); + ctx.session.selectedTenantId = tenantId; + ctx.session.selectedTenantName = getTenantName(tenantId); + + await ctx.answerCallbackQuery(); + await ctx.editMessageText(`✅ Tenant gewechselt zu: *${ctx.session.selectedTenantName}* \\(ID: ${tenantId}\\)`, { + parse_mode: 'MarkdownV2', + }); +} + +async function handleList(ctx: BotContext): Promise { + try { + const result = await payloadClient.listMedia(ctx.session.selectedTenantId, 5); + + if (result.docs.length === 0) { + await ctx.reply('📭 Keine Medien für diesen Tenant gefunden.'); + return; + } + + const lines = result.docs.map((doc, i) => { + const date = new Date(doc.createdAt).toLocaleDateString('de-DE'); + return `${i + 1}. 📎 *${doc.filename}* (ID: ${doc.id})\n 📅 ${date} | 📐 ${doc.width || '?'}×${doc.height || '?'}`; + }); + + await ctx.reply( + `📋 Letzte ${result.docs.length} Uploads (${ctx.session.selectedTenantName}):\n\n${lines.join('\n\n')}`, + ); + } catch (error) { + log.error('List failed', error); + await ctx.reply('❌ Konnte Medien nicht laden.'); + } +} + +async function handleStatus(ctx: BotContext): Promise { + const uptime = Math.floor((Date.now() - startTime) / 1000); + const hours = Math.floor(uptime / 3600); + const minutes = Math.floor((uptime % 3600) / 60); + + let apiStatus = '❌ Nicht erreichbar'; + try { + const healthy = await payloadClient.checkHealth(); + apiStatus = healthy ? '✅ Erreichbar' : '❌ Nicht erreichbar'; + } catch { + apiStatus = '❌ Fehler bei Verbindung'; + } + + const tokenExpiry = payloadClient.getTokenExpiry(); + const tokenStatus = tokenExpiry + ? `✅ Gültig bis ${tokenExpiry.toLocaleString('de-DE')}` + : '⚠️ Kein Token'; + + await ctx.reply( + `📊 Bot-Status\n\n` + + `⏱️ Uptime: ${hours}h ${minutes}m\n` + + `🌐 Payload API: ${apiStatus}\n` + + `📌 Tenant: ${ctx.session.selectedTenantName} (ID: ${ctx.session.selectedTenantId})\n` + + `🔑 Token: ${tokenStatus}`, + ); +} + +async function handleHelp(ctx: BotContext): Promise { + await ctx.reply( + `📖 *Hilfe*\n\n` + + `*Bild hochladen:*\n` + + `Sende ein Bild als Foto oder als Dokument\\. ` + + `Die Bildunterschrift wird als Alt\\-Text verwendet\\.\n\n` + + `💡 *Tipp:* Sende Bilder als _Dokument_ für Originalqualität\\. ` + + `Telegram komprimiert Fotos automatisch\\.\n\n` + + `*Befehle:*\n` + + `/start \\- Begrüßung\n` + + `/tenant \\- Ziel\\-Tenant wechseln\n` + + `/list \\- Letzte 5 Uploads\n` + + `/status \\- API\\- und Bot\\-Status\n` + + `/help \\- Diese Hilfe\n\n` + + `*Bulk\\-Upload:*\n` + + `Sende mehrere Bilder als Album \\– sie werden nacheinander hochgeladen\\.`, + { parse_mode: 'MarkdownV2' }, + ); +} + +async function uploadSinglePhoto( + ctx: BotContext, + fileId: string, + caption: string | undefined, +): Promise { + const rateCheck = checkRateLimit(ctx.from!.id); + if (!rateCheck.allowed) { + await ctx.reply(`⏳ Zu viele Uploads. Bitte warte ${rateCheck.retryAfter} Sekunden.`); + return; + } + + const statusMsg = await ctx.reply('⏳ Bild wird hochgeladen...'); + + try { + const file = await ctx.api.getFile(fileId); + const fileUrl = `https://api.telegram.org/file/bot${ctx.api.token}/${file.file_path}`; + + const { buffer, mimeType } = await downloadFile(fileUrl); + + const ext = file.file_path?.split('.').pop() || 'jpg'; + const filename = `telegram-${Date.now()}.${ext}`; + const alt = caption || `Upload via Telegram – ${new Date().toLocaleString('de-DE')}`; + + const doc = await payloadClient.uploadMedia(buffer, filename, mimeType, { + alt, + tenantId: ctx.session.selectedTenantId, + caption, + }); + + const sizeNames = doc.sizes ? Object.keys(doc.sizes).join(', ') : 'werden generiert'; + + await ctx.api.editMessageText( + ctx.chat!.id, + statusMsg.message_id, + `✅ Upload erfolgreich!\n\n` + + `📎 ID: ${doc.id}\n` + + `📁 Dateiname: ${doc.filename}\n` + + `🔗 URL: ${doc.url}\n` + + `🏷️ Tenant: ${getTenantName(ctx.session.selectedTenantId)}\n` + + `📐 Größen: ${sizeNames}`, + ); + } catch (error) { + log.error('Upload failed', error); + const message = error instanceof Error ? error.message : 'Unbekannter Fehler'; + await ctx.api.editMessageText( + ctx.chat!.id, + statusMsg.message_id, + `❌ Upload fehlgeschlagen: ${message}`, + ); + } +} + +async function handlePhoto(ctx: BotContext): Promise { + const photos = ctx.message?.photo; + if (!photos || photos.length === 0) return; + + const mediaGroupId = ctx.message?.media_group_id; + + // Album handling + if (mediaGroupId) { + handleAlbumMessage(ctx, photos[photos.length - 1].file_id, mediaGroupId); + return; + } + + // Single photo: last element = highest resolution + const bestPhoto = photos[photos.length - 1]; + await uploadSinglePhoto(ctx, bestPhoto.file_id, ctx.message?.caption); +} + +async function handleDocument(ctx: BotContext): Promise { + const doc = ctx.message?.document; + if (!doc) return; + + const filename = doc.file_name || 'unknown'; + const ext = filename.split('.').pop()?.toLowerCase() || ''; + + if (!ALLOWED_EXTENSIONS.has(ext)) { + await ctx.reply('⚠️ Format nicht unterstützt. Erlaubt: JPG, PNG, WebP, AVIF, GIF, SVG'); + return; + } + + if (doc.mime_type && !ALLOWED_MIME_TYPES.has(doc.mime_type)) { + await ctx.reply('⚠️ MIME-Type nicht unterstützt. Erlaubt: JPG, PNG, WebP, AVIF, GIF, SVG'); + return; + } + + const mediaGroupId = ctx.message?.media_group_id; + if (mediaGroupId) { + handleAlbumMessage(ctx, doc.file_id, mediaGroupId); + return; + } + + await uploadSinglePhoto(ctx, doc.file_id, ctx.message?.caption); +} + +function handleAlbumMessage(ctx: BotContext, fileId: string, mediaGroupId: string): void { + let album = albumBuffers.get(mediaGroupId); + + if (!album) { + album = { photos: [], timer: setTimeout(() => processAlbum(ctx, mediaGroupId), 500) }; + albumBuffers.set(mediaGroupId, album); + } + + album.photos.push({ fileId, caption: ctx.message?.caption }); +} + +async function processAlbum(ctx: BotContext, mediaGroupId: string): Promise { + const album = albumBuffers.get(mediaGroupId); + albumBuffers.delete(mediaGroupId); + + if (!album || album.photos.length === 0) return; + + const total = Math.min(album.photos.length, 10); + if (album.photos.length > 10) { + await ctx.reply('⚠️ Maximal 10 Bilder pro Album. Die ersten 10 werden hochgeladen.'); + } + + const statusMsg = await ctx.reply(`⏳ Album erkannt: ${total} Bilder werden hochgeladen...`); + const results: string[] = []; + let successCount = 0; + + for (let i = 0; i < total; i++) { + const photo = album.photos[i]; + const rateCheck = checkRateLimit(ctx.from!.id); + + if (!rateCheck.allowed) { + results.push(`${i + 1}. ⏳ Rate-Limit erreicht`); + break; + } + + try { + const file = await ctx.api.getFile(photo.fileId); + const fileUrl = `https://api.telegram.org/file/bot${ctx.api.token}/${file.file_path}`; + const { buffer, mimeType } = await downloadFile(fileUrl); + + const ext = file.file_path?.split('.').pop() || 'jpg'; + const filename = `telegram-${Date.now()}-${i + 1}.${ext}`; + const alt = photo.caption || `Album-Upload ${i + 1}/${total} – ${new Date().toLocaleString('de-DE')}`; + + const doc = await payloadClient.uploadMedia(buffer, filename, mimeType, { + alt, + tenantId: ctx.session.selectedTenantId, + }); + + results.push(`${i + 1}. ✅ ID: ${doc.id} – ${doc.filename}`); + successCount++; + } catch (error) { + const msg = error instanceof Error ? error.message : 'Fehler'; + results.push(`${i + 1}. ❌ ${msg}`); + } + + // Progress update every 3 images + if ((i + 1) % 3 === 0 && i + 1 < total) { + await ctx.api.editMessageText( + ctx.chat!.id, + statusMsg.message_id, + `📤 ${i + 1}/${total} hochgeladen...`, + ); + } + } + + await ctx.api.editMessageText( + ctx.chat!.id, + statusMsg.message_id, + `📦 Album-Upload abgeschlossen: ${successCount}/${total} erfolgreich\n\n${results.join('\n')}`, + ); +} +``` + +**Step 3: Verify build** + +```bash +pnpm lint +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat: add command handlers, photo/document/album upload, tenant keyboard" +``` + +--- + +### Task 6: Entry Point & PM2 Config + +**Files:** +- Create: `src/index.ts` +- Create: `ecosystem.config.cjs` + +**Step 1: Write src/index.ts** + +Entry point: import config (validates env), set log level, create bot, register handlers, start with graceful shutdown. + +```typescript +import { config } from './config.js'; +import { setLogLevel } from './utils/logger.js'; +import { createLogger } from './utils/logger.js'; +import { createBot } from './bot.js'; +import { registerHandlers } from './telegram/handlers.js'; + +const log = createLogger('Main'); + +setLogLevel(config.logLevel as 'debug' | 'info' | 'warn' | 'error'); + +async function main(): Promise { + log.info('Starting Telegram Media Upload Bot...'); + log.info(`Environment: ${config.nodeEnv}`); + log.info(`Payload API: ${config.payload.apiUrl}`); + log.info(`Default Tenant: ${config.defaultTenantId}`); + log.info(`Allowed Users: ${config.telegram.allowedUserIds.join(', ')}`); + + const bot = createBot(); + registerHandlers(bot); + + // Graceful shutdown + const shutdown = async (signal: string) => { + log.info(`Received ${signal}, shutting down...`); + bot.stop(); + // Give ongoing uploads up to 60s to finish + setTimeout(() => { + log.warn('Forced shutdown after timeout'); + process.exit(1); + }, 60_000); + }; + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); + + // Start bot + await bot.start({ + onStart: () => log.info('Bot is running! Listening for messages...'), + }); +} + +main().catch((error) => { + log.error('Fatal error', error); + process.exit(1); +}); +``` + +**Step 2: Write ecosystem.config.cjs** + +```javascript +module.exports = { + apps: [{ + name: 'telegram-media-bot', + script: './dist/index.js', + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '256M', + env: { + NODE_ENV: 'production', + }, + error_file: './logs/error.log', + out_file: './logs/out.log', + merge_logs: true, + time: true, + }], +}; +``` + +**Step 3: Full build verification** + +```bash +pnpm lint # tsc --noEmit +pnpm build # tsc → dist/ +ls dist/ # verify output files exist +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat: add entry point with graceful shutdown and PM2 config" +``` + +--- + +### Task 7: README & Final Verification + +**Files:** +- Create: `README.md` + +**Step 1: Write README.md** + +Brief README with setup instructions, commands, and deployment. + +**Step 2: Full verification cycle** + +```bash +pnpm lint # No errors +pnpm build # Clean build +ls -la dist/ # All files present +``` + +**Step 3: Final commit** + +```bash +git add -A +git commit -m "docs: add README with setup and deployment instructions" +``` + +--- + +### Task 8: Deploy & Manual Test + +**Step 1: Create .env from .env.example** + +```bash +cp .env.example .env +# Fill with real values (bot token, payload credentials, user IDs) +``` + +**Step 2: Test in dev mode** + +```bash +pnpm dev +# In Telegram: /start, /tenant, send photo, /list, /status +``` + +**Step 3: Production start** + +```bash +pnpm build +pm2 start ecosystem.config.cjs +pm2 save +pm2 list # Verify telegram-media-bot is online +``` + +**Step 4: Verify in PM2** + +```bash +pm2 logs telegram-media-bot --lines 20 +``` diff --git a/docs/sensualmoments/farbschema.html b/docs/sensualmoments/farbschema.html new file mode 100644 index 0000000..04a71c9 --- /dev/null +++ b/docs/sensualmoments/farbschema.html @@ -0,0 +1,477 @@ + + + + + +Finales Farbschema – Bordeaux-Dominant + + + + +
+

Finales Farbschema

+

Bordeaux-Dominant · Warm & Sinnlich

+
Freigegeben
+ + +
+
Dark Wine
#2A1520
+
Bordeaux
#8B3A4A
+
Blush Nude
#D4A9A0
+
Deep Navy
#151B2B
+
Crème
#F8F4F0
+
Espresso
#3D2F30
+
+ + +
+
+
+
+
Dark Wine
+
Basis · Haupthintergrund
+
#2A1520
+
Hero, Header, dunkle Sektionen, Haupthintergrund der Seite
+
+
+ +
+
+
+
Blush Nude
+
Akzent 1 · Interaktion
+
#D4A9A0
+
Buttons, Hover-Effekte, Links, Icons, aktive Elemente
+
+
+ +
+
+
+
Bordeaux
+
Akzent 2 · Headlines auf Hell
+
#8B3A4A
+
Überschriften auf hellen Flächen, Trennlinien, sekundäre Buttons
+
+
+ +
+
+
+
Deep Navy
+
Kontrast · Sektionswechsel
+
#151B2B
+
Testimonial-Bereich, Footer, Sektionswechsel für Tiefe
+
+
+ +
+
+
+
Crème
+
Neutral · Helle Flächen
+
#F8F4F0
+
Textblöcke, Formulare, helle Sektionen, Kartenhintrergründe
+
+
+ +
+
+
+
Espresso
+
Text · Fließtext auf Hell
+
#3D2F30
+
Fließtext auf Crème-Hintergrund (statt reinem Schwarz)
+
+
+
+ + +
+
+
Hero / Dunkle Sektionen
+
Headline auf Dark Wine
+
Fließtext in Crème auf dem Haupthintergrund der Website.
+
+
+
Helle Sektionen
+
Headline in Bordeaux
+
Espresso-Fließtext auf Crème-Hintergrund – warm und gut lesbar.
+
+
+
Navy-Kontrast
+
Blush auf Navy
+
Testimonials und Footer – kühler Gegenpol zu den warmen Tönen.
+
+
+ + +
+

Anwendungsregeln

+
+
Hintergrund
+
Dark Wine als Standard, Navy für Sektionswechsel
+
+
+
Buttons primär
+
Blush Nude mit Dark Wine Text
+
+
+
Buttons sekundär
+
Outline in Blush oder Bordeaux
+
+
+
Headlines dunkel
+
Crème auf dunklem Grund
+
+
+
Headlines hell
+
Bordeaux auf Crème
+
+
+
Fließtext dunkel
+
Crème auf dunklem Grund (Opacity 80–85 %)
+
+
+
Fließtext hell
+
Espresso auf Crème
+
+
+
Links / Hover
+
Blush Nude, Hover: leicht aufgehellt
+
+
+
Trennlinien
+
Bordeaux mit 20–30 % Opacity
+
+
+
Formulare
+
Crème-Hintergrund, Espresso-Text, Blush-Fokusrand
+
+
+ + +
+ +/* Farbschema – Bordeaux-Dominant · Boudoir Website */ +:root { + --color-base: #2A1520; /* Dark Wine – Haupthintergrund */ + --color-accent-1: #D4A9A0; /* Blush Nude – Buttons, Links, Hover */ + --color-accent-2: #8B3A4A; /* Bordeaux – Headlines auf Hell */ + --color-contrast: #151B2B; /* Deep Navy – Sektionswechsel */ + --color-neutral: #F8F4F0; /* Crème – Helle Flächen */ + --color-text: #3D2F30; /* Espresso – Fließtext auf Hell */ +} +
+ +
+ +
+ + + + \ No newline at end of file diff --git a/docs/sensualmoments/prototype.html b/docs/sensualmoments/prototype.html new file mode 100644 index 0000000..207bdc5 --- /dev/null +++ b/docs/sensualmoments/prototype.html @@ -0,0 +1,928 @@ + + + + + +Sensual Moment Photography + + + + + + + + + + +
+
+
+

+ Sensual + Moment +

+

Boudoir Photography · Dein Moment der Selbstliebe

+ Dein Shooting buchen +
+
+ Entdecken + + + +
+
+ + +
+
+
+
+ +

Jede Frau verdient es, sich selbst zu feiern

+
+

Ich glaube daran, dass wahre Schönheit nicht inszeniert werden muss – sie muss nur sichtbar gemacht werden. In meinem Studio schaffe ich einen geschützten Raum, in dem du dich fallen lassen kannst.

+

Mit einfühlsamer Anleitung und einem Blick für das Besondere entstehen Bilder, die deine Stärke, Sinnlichkeit und Einzigartigkeit einfangen – authentisch und mit Respekt.

+

Kein Shooting gleicht dem anderen, denn keine Frau gleicht der anderen.

+
— Dein Name
+
+
+
+ + +
+
+ +

Momente der Selbstliebe

+
+

Jedes Bild erzählt eine Geschichte von Mut, Verletzlichkeit und Stärke. Mit Einverständnis meiner Kundinnen teile ich hier ausgewählte Arbeiten.

+
+
+
Klassisch
+
Artistisch
+
Elegant
+
Natürlich
+
Dramatisch
+
Sinnlich
+
Intim
+
Mutig
+
+ +
+ + +
+ +

Was meine Kundinnen sagen

+
+
+
+
"
+

Ich habe mich noch nie so schön gefühlt. Die Atmosphäre war so vertrauensvoll, dass ich komplett loslassen konnte. Die Bilder sind unfassbar.

+ Sandra, 42 +
+
+
"
+

Anfangs war ich nervös, aber nach fünf Minuten fühlte es sich an wie ein Abend mit einer guten Freundin. Die Ergebnisse haben mich zu Tränen gerührt.

+ Katrin, 38 +
+
+
"
+

Ein Geschenk an mich selbst, das ich jeder Frau empfehlen würde. Professionell, einfühlsam und mit unglaublichem Blick für Details.

+ Maria, 51 +
+
+
+ + +
+
+ +

Pakete & Preise

+
+
+
+
+
Entdecken
+
Ab 299 €
+
    +
  • 60 Min. Shooting
  • +
  • Styling-Beratung vorab
  • +
  • 10 bearbeitete Bilder
  • +
  • Private Online-Galerie
  • +
  • 5 Feinabzüge 13×18
  • +
+ Anfragen +
+ +
+
Zelebrieren
+
Ab 799 €
+
    +
  • Halber Tag (4 Std.)
  • +
  • Styling + Visagistin
  • +
  • Alle bearbeiteten Bilder
  • +
  • Luxus-Leinenalbum
  • +
  • 3 Wandbilder nach Wahl
  • +
  • Behind-the-Scenes Video
  • +
+ Anfragen +
+
+
+ + +
+
+ +

Gedanken & Geschichten

+
+
+
+
+
+
Februar 2026
+

Warum sich jede Frau ein Boudoir-Shooting gönnen sollte

+

Es geht nicht um perfekte Posen – es geht darum, sich selbst mit neuen Augen zu sehen…

+
+
+
+
Januar 2026
+

Was ziehe ich bloß an? Dein Style-Guide fürs Shooting

+

Die richtige Garderobe kann den Unterschied machen. Hier sind meine besten Tipps…

+
+
+
+
Dezember 2025
+

Behind the Scenes: So entsteht dein persönliches Fotobuch

+

Vom ersten Klick bis zum fertigen Album – ein Blick hinter die Kulissen…

+
+
+
+ + +
+
+ +

Bereit für deinen Moment?

+
+

+ Schreib mir unverbindlich – ich melde mich innerhalb von 24 Stunden bei dir. + Gemeinsam besprechen wir in einem kostenlosen Vorgespräch, wie dein perfektes Shooting aussehen kann. +

+
+
+
E-Mail
+ +
+
+
Telefon
+ +
+
+
Studio
+
Musterstraße 12, 12345 Stadt
+
+
+
Social
+ +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + + + + + + \ No newline at end of file diff --git a/docs/sensualmoments/sensualmomentsdesign.md b/docs/sensualmoments/sensualmomentsdesign.md new file mode 100644 index 0000000..a7c604e --- /dev/null +++ b/docs/sensualmoments/sensualmomentsdesign.md @@ -0,0 +1,467 @@ +# Design-Briefing: sensualmoment.de + +## Projekt-Übersicht + +**Website:** sensualmoment.de +**Branche:** Boudoir-Fotografie +**Zielgruppe:** Frauen, 35–55 Jahre, die sich ein hochwertiges, intimes Fotoshooting als Akt der Selbstliebe gönnen möchten +**Tonalität:** Warm, einladend, luxuriös aber nicht einschüchternd. Empowerment statt Sexualisierung. Die Kundin soll sich sicher, wertgeschätzt und inspiriert fühlen. +**CMS:** Payload CMS (bereits vorhanden) +**Design-Referenz:** Die beigefügte Datei `sensualmoment-prototype.html` enthält den vollständigen visuellen Prototyp mit allen Sektionen, Farben, Typografie und Layoutstrukturen. + +--- + +## 1. Seitenstruktur & Payload-Collections + +### 1.1 Globale Elemente + +#### Navigation (Payload Global: `navigation`) + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `logo` | Upload (SVG/PNG) | Logo-Wordmark in Blush-Variante für dunklen Hintergrund | +| `menuItems` | Array | Menüpunkte mit `label` (Text) und `link` (Relationship oder URL) | + +Verhalten: Fixiert am oberen Rand, transparent über dem Hero. Nach 80px Scroll wechselt der Hintergrund zu Dark Wine (#2A1520) mit 95% Deckkraft und Backdrop-Blur. Menüpunkte sind in Josefin Sans, Uppercase, gesperrt. + +#### Footer (Payload Global: `footer`) + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `tagline` | Text | Kurzbeschreibung unter dem Logo | +| `navigationColumns` | Array of Groups | Spalten mit `title` + Array aus `label`/`link` | +| `socialLinks` | Array | Plattform + URL | +| `legalLinks` | Array | Impressum, Datenschutz, AGB als `label`/`link` | + +Hintergrund: Deep Navy (#151B2B). Vierspaltig: Logo-Spalte (breit), drei schmale Navigationsspalten. + +--- + +### 1.2 Einzelne Seiten + +--- + +#### SEITE: Startseite (Homepage) + +**Route:** `/` +**Payload:** Page-Collection mit Slug `home`, Layout-Builder oder feste Felder + +##### Sektion 1 — Hero + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `headline` | Text | Hauptüberschrift (Standard: „Sensual Moment") | +| `subline` | Text | Untertitel (Standard: „Boudoir Photography · Dein Moment der Selbstliebe") | +| `ctaLabel` | Text | Button-Text (Standard: „Dein Shooting buchen") | +| `ctaLink` | Relationship/URL | Ziel des Buttons | +| `backgroundImage` | Upload (optional) | Hintergrundbild, wird mit Dark-Wine-Overlay versehen | + +Layout: Fullscreen (100vh), zentrierter Inhalt. Hintergrund ist Dark Wine mit zwei subtilen radialen Gradienten (Bordeaux + Blush, je sehr niedrige Deckkraft). Scroll-Indikator am unteren Rand mit Puls-Animation. Einblendung des Inhalts mit fadeUp-Animation. + +##### Sektion 2 — Über mich (Kurzversion) + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `portrait` | Upload | Fotografen-Portrait, hochformat (3:4) | +| `sectionLabel` | Text | „Über mich" | +| `headline` | Rich Text | z.B. „Jede Frau verdient es, *sich selbst zu feiern*" | +| `bodyText` | Rich Text | 2–3 Absätze persönliche Vorstellung | +| `signature` | Text | Name der Fotografin | +| `linkToFullPage` | URL | Link zur vollständigen Über-mich-Seite | + +Layout: Zweispaltiges Grid (1:1) auf Crème-Hintergrund. Bild links mit abgerundeter Ecke oben rechts (border-radius: 0 120px 0 0). Text rechts. Max-Width 1280px, zentriert. + +##### Sektion 3 — Galerie-Vorschau + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `sectionLabel` | Text | „Portfolio" | +| `headline` | Rich Text | z.B. „Momente der *Selbstliebe*" | +| `description` | Text | Kurzbeschreibung | +| `images` | Array of Uploads | 8 Galerie-Bilder mit optionalem `category`-Label | +| `linkToGallery` | URL | „Alle Arbeiten ansehen" | + +Layout: Dark Wine Hintergrund, volle Breite. Asymmetrisches 4-Spalten-Grid mit 4px Gap. Das 3. Bild spannt 2 Zeilen, das 6. Bild spannt 2 Spalten. Hover-Effekt: dunkler Gradient von unten mit Category-Label das einblendet. + +##### Sektion 4 — Testimonials + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `sectionLabel` | Text | „Erfahrungen" | +| `headline` | Rich Text | z.B. „Was meine Kundinnen *sagen*" | +| `testimonials` | Relationship zu Testimonials-Collection | 3 Stück auf der Startseite | + +Layout: Crème-Hintergrund, zentrierter Header. Drei Karten im Grid mit weißem Hintergrund, feinem Blush-Border. Große Anführungszeichen in Playfair Display als Dekorelement. + +##### Sektion 5 — Pakete-Vorschau + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `sectionLabel` | Text | „Investition in dich" | +| `headline` | Rich Text | „Pakete & *Preise*" | +| `packages` | Relationship zu Pakete-Collection | 3 Pakete | + +Layout: Deep Navy Hintergrund. Drei Karten nebeneinander. Mittlere Karte ist „featured" (Blush-Border + Label „Beliebtestes Paket" als Pill oben). Hover: Border heller, translateY(-4px). + +##### Sektion 6 — Blog/Journal-Vorschau + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `sectionLabel` | Text | „Journal" | +| `headline` | Rich Text | „Gedanken & *Geschichten*" | +| `posts` | Relationship zu Blog-Collection | 3 neueste Beiträge | + +Layout: Crème-Hintergrund. Drei Karten mit Vorschaubild (4:3), Datum, Titel, Excerpt. Hover: Titel wechselt zu Bordeaux. + +##### Sektion 7 — Kontakt-CTA + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `headline` | Rich Text | „Bereit für deinen *Moment*?" | +| `description` | Text | Einladungstext | +| `contactInfo` | Group | E-Mail, Telefon, Adresse, Social-Links | +| `formFields` | Definiert im Code | Name, E-Mail, Telefon, Paket-Interesse, Nachricht | + +Layout: Dark Wine, zweispaltig. Links: Text + Kontaktinfos. Rechts: Formular mit Underline-Inputs (kein Border, nur border-bottom). Submit-Button in Blush mit Dark-Wine-Text. + +--- + +#### SEITE: Über mich + +**Route:** `/ueber-mich` +**Payload:** Page-Collection mit Slug `ueber-mich` + +| Sektion | Felder | Layout | +|---------|--------|--------| +| Hero-Bereich | Großes Portrait + Headline + Kurztext | Zweispaltig, Dark Wine BG | +| Meine Geschichte | Rich Text mit Zwischenüberschriften | Schmale Spalte (max 720px), Crème BG | +| Meine Werte | 3–4 Werte-Blöcke (Icon/Titel/Text) | Grid auf Crème | +| Persönliches | Lockerer Text, ggf. mit persönlichen Fotos | Crème, freies Layout | +| CTA | „Lass uns kennenlernen" → Kontaktseite | Dark Wine Band | + +--- + +#### SEITE: Galerie + +**Route:** `/galerie` +**Payload Collection:** `gallery-images` + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `image` | Upload | Das Bild selbst | +| `title` | Text (optional) | Bildtitel | +| `category` | Select | Klassisch / Artistisch / Elegant / Natürlich / Dramatisch / Sinnlich | +| `featured` | Boolean | Auf Startseite zeigen | +| `order` | Number | Sortierreihenfolge | + +Layout: Masonry- oder asymmetrisches Grid. Filtermöglichkeit nach Kategorie (animierte Tabs). Dark Wine Hintergrund. Lightbox bei Klick. + +--- + +#### SEITE: Pakete & Preise + +**Route:** `/pakete` +**Payload Collection:** `packages` + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `name` | Text | Paketname (Entdecken / Erleben / Zelebrieren) | +| `priceLabel` | Text | z.B. „Ab 499 €" | +| `features` | Array of Text | Leistungsliste | +| `featured` | Boolean | Hervorgehobenes Paket | +| `ctaLabel` | Text | Button-Text | +| `order` | Number | Reihenfolge | + +Zusätzliche Sektionen auf der Seite: Einleitungstext oben, FAQ-Accordion unten (Payload Collection `faqs` mit `question`/`answer`), CTA-Band zum Kontaktformular. + +--- + +#### SEITE: Blog / Journal + +**Route:** `/journal` +**Payload Collection:** `blog-posts` + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `title` | Text | Beitragstitel | +| `slug` | Text (auto) | URL-Pfad | +| `publishDate` | Date | Veröffentlichungsdatum | +| `coverImage` | Upload | Vorschaubild (4:3) | +| `excerpt` | Textarea | Kurztext für Vorschau | +| `content` | Rich Text / Blocks | Voller Artikelinhalt | +| `category` | Select | Tipps / Behind the Scenes / Persönlich | + +Übersichtsseite: Grid mit Karten (wie Startseiten-Vorschau, aber mehr Beiträge, paginiert). Einzelseite: Schmale Lesespalte (max 720px), großes Header-Bild, elegant typografiert. + +--- + +#### SEITE: Kontakt + +**Route:** `/kontakt` +**Payload:** Page mit eingebettetem Formular oder Payload Forms Plugin + +Identisch mit der Kontakt-Sektion auf der Startseite, aber als eigenständige Seite mit optionaler Karte/Anfahrt und erweitertem Text. + +--- + +#### SEITE: FAQ + +**Route:** `/faq` +**Payload Collection:** `faqs` + +| Feld | Typ | Beschreibung | +|------|-----|-------------| +| `question` | Text | Frage | +| `answer` | Rich Text | Antwort | +| `category` | Select | Vor dem Shooting / Während / Nachher / Allgemein | +| `order` | Number | Reihenfolge | + +Layout: Accordion-Elemente, gruppiert nach Kategorie. Crème-Hintergrund. + +--- + +#### Pflichtseiten (Rechtlich) + +| Seite | Route | Payload | +|-------|-------|---------| +| Impressum | `/impressum` | Page mit Rich-Text-Feld | +| Datenschutz | `/datenschutz` | Page mit Rich-Text-Feld | +| AGB | `/agb` | Page mit Rich-Text-Feld | + +Einfaches Layout: Schmale Textspalte auf Crème, sachliche Typografie. + +--- + +### 1.3 Payload Collections – Zusammenfassung + +| Collection | Slug | Verwendung | +|------------|------|------------| +| Pages | `pages` | Alle statischen Seiten (Home, Über mich, Kontakt, Legal) | +| Gallery Images | `gallery-images` | Galerie-Bilder mit Kategorien | +| Packages | `packages` | Shooting-Pakete mit Preisen | +| Testimonials | `testimonials` | Kundinnenstimmen (`quote`, `authorName`, `authorAge`, `featured`) | +| Blog Posts | `blog-posts` | Journal-Beiträge | +| FAQs | `faqs` | Häufige Fragen | +| Media | `media` | Standard Payload Media-Collection | + +| Global | Slug | Verwendung | +|--------|------|------------| +| Navigation | `navigation` | Logo + Menüpunkte | +| Footer | `footer` | Footer-Inhalte | +| Site Settings | `site-settings` | SEO-Defaults, Social-Media-Links, Kontaktdaten | + +--- + +## 2. Farbschema + +### 2.1 Die sechs Kernfarben + +``` +┌─────────────────┬───────────┬────────────────────────────────────────────┐ +│ Name │ Hex │ Verwendung │ +├─────────────────┼───────────┼────────────────────────────────────────────┤ +│ Dark Wine │ #2A1520 │ Haupthintergrund dunkler Sektionen, │ +│ │ │ Hero, Kontakt, scrolled Navigation │ +├─────────────────┼───────────┼────────────────────────────────────────────┤ +│ Blush Nude │ #D4A9A0 │ Primäre Akzentfarbe: Buttons, Links, │ +│ │ │ Hover-States, Logo auf dunklem Grund, │ +│ │ │ Ornamente, Trennlinien │ +├─────────────────┼───────────┼────────────────────────────────────────────┤ +│ Bordeaux │ #8B3A4A │ Headlines auf hellem Hintergrund, │ +│ │ │ Hover-Farbe für Blog-Titel, Akzente │ +├─────────────────┼───────────┼────────────────────────────────────────────┤ +│ Deep Navy │ #151B2B │ Sektionswechsel (Pakete-Bereich), Footer │ +├─────────────────┼───────────┼────────────────────────────────────────────┤ +│ Crème │ #F8F4F0 │ Helle Flächen, Formulare, Fließtext- │ +│ │ │ Hintergrund, Cards │ +├─────────────────┼───────────┼────────────────────────────────────────────┤ +│ Espresso │ #3D2F30 │ Fließtext auf hellem Hintergrund │ +└─────────────────┴───────────┴────────────────────────────────────────────┘ +``` + +### 2.2 Abgeleitete Werte (CSS Custom Properties) + +```css +:root { + --dark-wine: #2A1520; + --blush: #D4A9A0; + --bordeaux: #8B3A4A; + --navy: #151B2B; + --creme: #F8F4F0; + --espresso: #3D2F30; + + /* Transparenzvarianten für Overlays, Borders, Hintergründe */ + --blush-soft: rgba(212, 169, 160, 0.15); + --blush-medium: rgba(212, 169, 160, 0.30); + --blush-border: rgba(212, 169, 160, 0.20); + --blush-hover-bg: rgba(212, 169, 160, 0.10); +} +``` + +### 2.3 Farbzuordnung nach Sektion + +| Sektion | Hintergrund | Text | Akzente | +|---------|------------|------|---------| +| Navigation (gescrollt) | Dark Wine 95% | Blush | Blush Underline-Hover | +| Hero | Dark Wine + radiale Gradienten | Blush | Blush (CTA-Border) | +| Über mich | Crème | Espresso | Bordeaux (Headline), Blush (Divider) | +| Galerie | Dark Wine | Blush | Blush-soft (Bild-Platzhalter) | +| Testimonials | Crème | Espresso | Blush (Anführungszeichen), Bordeaux (Autorin) | +| Pakete | Deep Navy | Crème/Blush | Blush (Borders, CTA, Featured-Badge) | +| Blog | Crème | Espresso | Bordeaux (Hover), Blush (Divider) | +| Kontakt | Dark Wine | Blush | Blush (Submit-Button Hintergrund) | +| Footer | Deep Navy | Blush | Blush bei 30–60% Opacity | + +### 2.4 Interaktionszustände + +| Element | Default | Hover | Active/Focus | +|---------|---------|-------|-------------| +| CTA-Buttons (dunkel) | Transparent + Blush-Border 30% | Blush-BG 10% + Border 100% | — | +| CTA-Buttons (Pakete) | Transparent + Blush-Border 30% | Blush-BG solid + Dark-Wine-Text | — | +| Submit-Button | Blush-BG + Dark-Wine-Text | Helleres Blush (#E0B8B0) + Schatten + translateY(-2px) | — | +| Nav-Links | Blush | Opacity 0.8 + Underline von links (width 0→100%) | — | +| Blog-Titel | Espresso | Bordeaux | — | +| Karten | Ohne Schatten | Box-Shadow + translateY(-4px) | — | +| Form-Inputs | Border-bottom Blush 20% | — | Border-bottom Blush 100% | + +--- + +## 3. Typografie + +### 3.1 Schriftfamilien + +| Rolle | Font | Gewichte | Quelle | +|-------|------|----------|--------| +| **Display / Headlines** | Playfair Display | 400, 500, 600, 700 + Italic | Google Fonts | +| **Fließtext / Body** | Cormorant Garamond | 300, 400, 500 + Italic | Google Fonts | +| **UI / Labels / Navigation** | Josefin Sans | 200, 300, 400 | Google Fonts | + +### 3.2 Typografie-Hierarchie + +| Element | Font | Größe | Gewicht | Sonstiges | +|---------|------|-------|---------|-----------| +| Hero-Headline | Playfair Display | clamp(3.5rem, 8vw, 7rem) | 400 | Kursiv für zweite Zeile | +| Sektion-Titel (h2) | Playfair Display | clamp(2rem, 4vw, 3.2rem) | 400 | `` = Italic | +| Sektion-Label | Josefin Sans | 0.6rem | 300 | Uppercase, letter-spacing: 0.4em, opacity: 0.5 | +| Fließtext | Cormorant Garamond | 1.2rem | 300 | line-height: 1.8 | +| Navigation | Josefin Sans | 0.72rem | 300 | Uppercase, letter-spacing: 0.18em | +| Buttons/CTAs | Josefin Sans | 0.62–0.68rem | 300 | Uppercase, letter-spacing: 0.2–0.25em | +| Testimonial-Text | Cormorant Garamond | 1.05rem | 300 Italic | line-height: 1.75 | +| Karten-Titel | Playfair Display | 1.2–1.4rem | 400 | — | +| Footer-Links | Cormorant Garamond | 0.9rem | 300 | Opacity: 0.6, Hover: 1.0 | +| Rechtliches/Meta | Josefin Sans | 0.55–0.58rem | 300 | letter-spacing: 0.1–0.15em | + +### 3.3 Typografie-Prinzipien + +Die drei Schriften haben klar getrennte Rollen: Playfair Display erzeugt visuelle Spannung und Eleganz in Headlines, wird aber nie für kleine Texte eingesetzt. Cormorant Garamond ist die „Stimme" der Seite und transportiert den Fließtext mit Leichtigkeit (Gewicht 300). Josefin Sans fungiert als funktionale Schrift für UI-Elemente und wird ausschließlich in Uppercase mit großzügiger Sperrung eingesetzt, um einen Kontrast zur organischen Serif-Welt zu schaffen. + +Headlines nutzen häufig eine Kombination aus Regular und Italic, wobei ein Wort oder eine Phrase kursiv gesetzt wird, um einen visuellen Rhythmus zu erzeugen (z.B. „Momente der *Selbstliebe*"). Das Pattern zieht sich durch die gesamte Seite. + +--- + +## 4. Designsprache & Gestaltungsprinzipien + +### 4.1 Gesamteindruck + +Die Seite vermittelt **luxuriöse Intimität** — sie fühlt sich an wie ein warmer, geschützter Raum. Dunkel genug für Atmosphäre, aber nie düster. Die Farbwelt ist bewusst Bordeaux-dominant mit warmen Hauttönen, nicht kühl oder technisch. Alles strahlt Vertrauen und Wertschätzung aus. + +### 4.2 Layout-Prinzipien + +**Großzügiger Weißraum:** Sektionen haben 120px Padding vertikal (80px auf Mobile). Nichts fühlt sich gedrängt an. + +**Rhythmischer Wechsel zwischen Hell und Dunkel:** Die Seite alterniert konsequent: +Hero (dunkel) → Über mich (hell) → Galerie (dunkel) → Testimonials (hell) → Pakete (dunkel/navy) → Blog (hell) → Kontakt (dunkel) → Footer (dunkel/navy) + +**Asymmetrie mit System:** Das Galerie-Grid bricht bewusst aus dem gleichmäßigen Raster aus (ein Bild spannt 2 Zeilen, eines 2 Spalten). Das erzeugt visuelles Interesse ohne Chaos. + +**Max-Width für Inhalte:** Textsektionen begrenzen sich auf 1100–1280px. Die Galerie darf volle Breite nutzen. + +### 4.3 Dekorative Elemente + +| Element | Beschreibung | +|---------|-------------| +| Trennlinien | 48px breit, 1px hoch, Farbe Blush. Unter Sektion-Titeln. | +| Hero-Ornament | 60px Linie mit Gradient (transparent → Blush → transparent), über dem Titel | +| Anführungszeichen | Großes „ in Playfair Display (2rem), Farbe Blush, in Testimonial-Karten | +| Featured-Badge | Pill-Shape, Blush-Hintergrund, Dark-Wine-Text, über der Paketkarte positioniert | +| Bild-Ecke | Das Über-mich-Portrait hat einen border-radius nur oben rechts (0 120px 0 0) | + +### 4.4 Animationen & Transitions + +| Effekt | Beschreibung | Dauer | +|--------|-------------|-------| +| Scroll-Reveal | Elemente mit Klasse `.reveal` faden von unten ein (translateY 30px → 0, opacity 0 → 1) | 0.8s ease | +| Hero-Einblendung | Gesamter Hero-Content fährt von unten ein | 1.2s ease-out | +| Nav-Transition | Padding, Hintergrund, Schatten ändern sich beim Scrollen | 0.5s ease | +| Scroll-Indikator | Puls-Animation (Opacity + translateY) | 2s infinite | +| Hover-Underline | Linie unter Nav-Links wächst von links (width 0 → 100%) | 0.3s ease | +| Karten-Hover | Box-Shadow einblenden + translateY(-4px) | 0.4s | +| Galerie-Hover | Dunkler Gradient von unten einblenden + Label einblenden (delay 0.1s) | 0.4s | +| Button-Hover | Background-Change + ggf. Schatten + translateY(-2px) | 0.4s | + +Alle Animationen sind subtil und unterstützend. Keine springenden, aufmerksamkeitsheischenden Effekte. Die IntersectionObserver-Schwelle liegt bei 15%. + +### 4.5 Responsive-Verhalten + +| Breakpoint | Änderungen | +|------------|------------| +| ≤ 900px | Navigation: Hamburger-Menü (Mobile-Menu noch zu implementieren). Alle Grids fallen auf 1 Spalte. Galerie wird 2-spaltig ohne Span-Varianten. Padding reduziert auf 80px/24px. Kontakt-Sektion wird einspaltig gestapelt. Footer wird 2-spaltig. | + +### 4.6 Bildsprache (Hinweis für Content) + +Alle Platzhalter im Prototyp sind als dezente Blush-soft-Flächen angelegt. Die echten Bilder sollten folgenden Charakter haben: warme Töne, weiches Licht, natürliche Posen, Fokus auf Stärke und Anmut. Keine kühlen oder harten Kontraste. Die Bildwelt sollte die Farbwelt der Seite widerspiegeln — warme Hauttöne, dunkle Hintergründe, gelegentliche Stoffe in Bordeaux oder Crème. + +--- + +## 5. Logo-Spezifikation + +### 5.1 Aufbau + +Das Logo ist ein reines Wordmark (keine Bildmarke, kein Symbol): + +``` +Sensual ← Playfair Display, Regular, groß + Moment ← Playfair Display, Italic, kleiner, rechtsbündig unter "Sensual" + PHOTOGRAPHY ← Josefin Sans, Light, sehr klein, Uppercase, stark gesperrt, rechtsbündig +``` + +### 5.2 Farbvarianten + +| Variante | Text | Hintergrund | Einsatz | +|----------|------|-------------|---------| +| **Primary** | Blush (#D4A9A0) | Dark Wine (#2A1520) | Navigation, Hero, Kontakt | +| Navy | Blush (#D4A9A0) | Deep Navy (#151B2B) | Footer, Pakete-Bereich | +| Hell | Bordeaux (#8B3A4A) | Crème (#F8F4F0) | Helle Sektionen, Drucksachen | +| Invertiert | Crème (#F8F4F0) | Bordeaux (#8B3A4A) | Spezielle Akzente, Social Media | + +Das Logo existiert als Canva-Design mit allen 4 Varianten (Design-ID: DAHCgZTIQkg). Für die Website sollte es als SVG exportiert werden. + +--- + +## 6. Technische Hinweise für Payload + +### 6.1 Empfohlene Payload-Konfiguration + +Die Seite hat einen klaren Wechsel zwischen dunklen und hellen Sektionen. Im Payload-Admin sollte es für Page-Layouts einen Block-Builder geben, bei dem jeder Block ein Feld `theme` hat (`light` | `dark` | `navy`), das automatisch die richtige Farbkombination anwendet. + +### 6.2 Rich-Text-Kursiv-Konvention + +Überall auf der Seite werden Kursivierungen in Headlines als Stilmittel eingesetzt. Das Payload-Rich-Text-Feld muss Italic unterstützen und im Frontend als `` mit Playfair Display Italic gerendert werden. + +### 6.3 SEO-Felder + +Jede Page und jeder Blog-Post sollte folgende Felder haben: `metaTitle`, `metaDescription`, `ogImage` (Upload). Payload bietet dafür ein SEO-Plugin an. + +### 6.4 Formular-Handling + +Das Kontaktformular sollte über Payload Forms oder eine eigene Collection (`form-submissions`) verarbeitet werden. Felder: Name, E-Mail, Telefon (optional), Paket-Interesse (Select), Nachricht. E-Mail-Benachrichtigung an die Fotografin konfigurieren. + +--- + +## 7. Dateireferenzen + +| Datei | Beschreibung | +|-------|-------------| +| `sensualmoment-prototype.html` | Vollständiger visueller Prototyp mit allen Sektionen und CSS | +| `farbschema-final.html` | Interaktive Farbschema-Dokumentation | +| Canva Design DAHCgZTIQkg | Logo in allen 4 Farbvarianten | diff --git a/prompts/PROMPT_TELEGRAM_MEDIA_BOT.md b/prompts/PROMPT_TELEGRAM_MEDIA_BOT.md new file mode 100644 index 0000000..5690103 --- /dev/null +++ b/prompts/PROMPT_TELEGRAM_MEDIA_BOT.md @@ -0,0 +1,749 @@ +# Telegram Media Upload Bot – Payload CMS Integration + +## Kontext + +- **Projektverzeichnis:** `/home/payload/telegram-media-bot` (auf sv-payload, LXC 700, IP: 10.10.181.100) +- **Alternative:** Neues GitHub Repository `complexcaresolutions/telegram-media-bot` +- **Tech-Stack:** Node.js 22 LTS, TypeScript, grammy (Telegram Bot Framework), PM2 +- **Ziel-System:** Payload CMS Multi-Tenant (Production: `https://cms.c2sgmbh.de`, Staging: `https://pl.porwoll.tech`) +- **Betriebssystem:** Debian/Ubuntu (LXC Container im Proxmox-Cluster) +- **Package Manager:** pnpm + +--- + +## Projektbeschreibung + +Erstelle einen Telegram Bot, der es autorisierten Benutzern ermöglicht, Bilder direkt aus dem Telegram-Chat in die Media Collection des Payload CMS hochzuladen. Der Bot authentifiziert sich gegen die Payload REST-API, empfängt Bilder über die Telegram Bot API, lädt sie von den Telegram-Servern herunter und leitet sie per `multipart/form-data` an den Payload Media-Endpoint weiter. Dabei wird die Multi-Tenant-Isolation strikt eingehalten. + +--- + +## Technische Referenzen + +### Payload CMS REST-API + +**Base URLs:** +- Production: `https://cms.c2sgmbh.de/api` +- Staging: `https://pl.porwoll.tech/api` +- Swagger UI: `https://cms.c2sgmbh.de/api/docs` + +**Authentifizierung – Login:** +```bash +curl -X POST "https://cms.c2sgmbh.de/api/users/login" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "admin@example.com", + "password": "your-password" + }' +``` + +**Response:** +```json +{ + "message": "Auth Passed", + "user": { "id": 1, "email": "admin@example.com", "isSuperAdmin": true }, + "token": "eyJhbGciOiJIUzI1NiIs..." +} +``` + +**Token verwenden:** +```bash +curl "https://cms.c2sgmbh.de/api/media" \ + -H "Authorization: JWT eyJhbGciOiJIUzI1NiIs..." +``` + +### Media Upload Endpoint + +```bash +curl -X POST "https://cms.c2sgmbh.de/api/media" \ + -H "Authorization: JWT " \ + -F "file=@/path/to/image.jpg" \ + -F "alt=Beschreibungstext" \ + -F "tenant=1" +``` + +**WICHTIG:** Das `tenant`-Feld ist **Pflicht** bei jedem Upload. Ohne korrekte Tenant-Zuordnung gibt die API 403 Forbidden zurück. Die Tenant-Isolation ist ein Kernprinzip des gesamten Systems. + +### Verfügbare Tenants + +| ID | Name | Slug | Domain | +|----|------|------|--------| +| 1 | porwoll.de | porwoll | porwoll.de | +| 4 | Complex Care Solutions GmbH | c2s | complexcaresolutions.de | +| 5 | Gunshin | gunshin | gunshin.de | +| (weitere Tenants ggf. dynamisch über API abrufen) | + +### Media Collection – Automatische Bildverarbeitung + +Payload generiert automatisch folgende responsive Größen beim Upload: + +| Size | Auflösung | Format | +|------|-----------|--------| +| thumbnail | 150×150 | Original + AVIF | +| small | 300×300 | Original + AVIF | +| medium | 600×600 | Original + AVIF | +| large | 1200×1200 | Original + AVIF | +| xlarge | 1920×1920 | Original + AVIF | +| 2k | 2560×2560 | Original + AVIF | +| og | 1200×630 | Original (Social Media) | + +→ Der Bot muss sich NICHT um Bildgrößen kümmern. Payload erledigt das serverseitig. + +### Rate Limiting (Payload API) + +| Limiter | Limit | Fenster | +|---------|-------|---------| +| publicApiLimiter | 60 Requests | 1 Minute | +| authLimiter | 5 Requests | 15 Minuten | + +→ Der Bot sollte Token cachen und nicht bei jedem Upload neu einloggen. + +--- + +## Aufgaben + +### 1. Projekt-Setup + +#### 1.1 Projektstruktur erstellen + +``` +telegram-media-bot/ +├── src/ +│ ├── index.ts # Entry Point, Bot-Start +│ ├── bot.ts # Grammy Bot-Instanz + Handler +│ ├── config.ts # Environment-Konfiguration (typisiert) +│ ├── payload/ +│ │ ├── client.ts # Payload API Client (Login, Token-Management) +│ │ └── media.ts # Media Upload Logik +│ ├── telegram/ +│ │ ├── handlers.ts # Message Handler (Photo, Document, Commands) +│ │ └── keyboards.ts # Inline Keyboards (Tenant-Auswahl etc.) +│ ├── middleware/ +│ │ └── auth.ts # User-Whitelist Middleware +│ └── utils/ +│ ├── logger.ts # Logging Utility +│ └── download.ts # Telegram File Download Helper +├── .env.example # Template für Environment Variables +├── .env # (gitignored) Actual Config +├── package.json +├── tsconfig.json +├── ecosystem.config.cjs # PM2 Konfiguration +├── .gitignore +└── README.md +``` + +**Akzeptanzkriterien:** +- [ ] `pnpm init` ausgeführt +- [ ] TypeScript konfiguriert (strict mode) +- [ ] Alle Dependencies installiert +- [ ] `.gitignore` enthält `node_modules`, `.env`, `dist` + +#### 1.2 Dependencies installieren + +```bash +pnpm add grammy dotenv +pnpm add -D typescript @types/node tsx +``` + +**Hinweis:** `grammy` ist das bevorzugte Telegram Bot Framework (modern, TypeScript-first, aktiv maintained). Alternativ wäre `node-telegram-bot-api` möglich, aber `grammy` hat bessere TypeScript-Unterstützung. + +#### 1.3 TypeScript Konfiguration + +**Datei:** `tsconfig.json` + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +#### 1.4 Package.json Scripts + +```json +{ + "name": "telegram-media-bot", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "lint": "tsc --noEmit" + } +} +``` + +--- + +### 2. Konfiguration + +#### 2.1 Environment Variables + +**Datei:** `.env.example` + +```env +# Telegram Bot +TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 + +# Zugelassene Telegram User-IDs (kommasepariert) +ALLOWED_USER_IDS=123456789,987654321 + +# Payload CMS +PAYLOAD_API_URL=https://cms.c2sgmbh.de/api +PAYLOAD_ADMIN_EMAIL=admin@example.com +PAYLOAD_ADMIN_PASSWORD=your-secure-password + +# Standard-Tenant (wird verwendet wenn kein Tenant gewählt) +DEFAULT_TENANT_ID=1 + +# Logging +LOG_LEVEL=info + +# Node Environment +NODE_ENV=production +``` + +#### 2.2 Typisierte Config + +**Datei:** `src/config.ts` + +```typescript +// Alle Environment Variables typisiert laden und validieren. +// Bei fehlenden Pflicht-Variablen: Prozess mit Error beenden. +// ALLOWED_USER_IDS als number[] parsen (kommasepariert). +// Validation beim Import, nicht lazy. + +interface Config { + telegram: { + botToken: string; + allowedUserIds: number[]; + }; + payload: { + apiUrl: string; + email: string; + password: string; + }; + defaultTenantId: number; + logLevel: string; + nodeEnv: string; +} +``` + +**Akzeptanzkriterien:** +- [ ] Config wird beim Start validiert +- [ ] Fehlende Pflicht-Variablen = sofortiger Exit mit klarer Fehlermeldung +- [ ] `ALLOWED_USER_IDS` wird als `number[]` geparst + +--- + +### 3. Payload API Client + +#### 3.1 Authentifizierung mit Token-Caching + +**Datei:** `src/payload/client.ts` + +Implementiere einen Payload API Client mit folgender Logik: + +1. **Login:** `POST /api/users/login` mit Email/Password +2. **Token speichern** (In-Memory, NICHT auf Disk) +3. **Token-Expiry tracken:** JWT decodieren (ohne Verifikation, nur Payload lesen), `exp` Feld prüfen +4. **Auto-Refresh:** Vor jedem API-Call prüfen ob Token noch gültig (mit 5 Min. Buffer). Falls abgelaufen → neu einloggen. +5. **Retry-Logik:** Bei 401-Response einmal neu einloggen und Request wiederholen. + +```typescript +class PayloadClient { + private token: string | null = null; + private tokenExpiry: number = 0; + + async getToken(): Promise { /* ... */ } + async login(): Promise { /* ... */ } + async uploadMedia(file: Buffer, filename: string, options: MediaUploadOptions): Promise { /* ... */ } + async listMedia(tenantId: number, limit?: number): Promise { /* ... */ } + async deleteMedia(mediaId: number): Promise { /* ... */ } +} + +interface MediaUploadOptions { + alt: string; + tenantId: number; + caption?: string; +} + +interface MediaResponse { + id: number; + url: string; + filename: string; + alt: string; + sizes: Record; +} +``` + +**WICHTIG – Multi-Tenant:** +- Jeder API-Call der Media betrifft MUSS `tenant` als Feld mitsenden +- Bei Reads: `?where[tenant][equals]=` als Query-Parameter +- Bei Writes: `tenant` im Request-Body + +**Akzeptanzkriterien:** +- [ ] Login funktioniert, Token wird gecacht +- [ ] Token wird vor Ablauf automatisch erneuert +- [ ] 401 Response triggert Re-Login + Retry +- [ ] Alle API-Calls enthalten korrekte Tenant-Filterung + +#### 3.2 Media Upload Funktion + +**Datei:** `src/payload/media.ts` + +```typescript +// Upload einer Bilddatei an POST /api/media +// Content-Type: multipart/form-data +// +// Felder: +// file: Die Bilddatei (Buffer) mit korrektem filename + mimetype +// alt: Alt-Text (string) +// tenant: Tenant-ID (number) +// +// Die Payload API generiert automatisch alle responsiven Größen. +// Response enthält die vollständige Media-Resource inkl. aller Size-URLs. +``` + +Für den multipart Upload verwende die native `FormData` API (ab Node.js 18+ verfügbar) oder `form-data` Package. **Kein axios nötig** – nutze native `fetch`. + +**Akzeptanzkriterien:** +- [ ] Upload funktioniert mit JPG, PNG, WebP, AVIF +- [ ] Alt-Text wird korrekt gesetzt +- [ ] Tenant-Zuordnung funktioniert +- [ ] Response wird korrekt geparst + +--- + +### 4. Telegram Bot + +#### 4.1 Bot-Instanz und Middleware + +**Datei:** `src/bot.ts` + +```typescript +import { Bot, Context, session } from 'grammy'; + +interface SessionData { + selectedTenantId: number; + selectedTenantName: string; +} + +type BotContext = Context & { session: SessionData }; + +// Bot erstellen mit Grammy +// Session-Middleware für Tenant-Auswahl pro User +// Auth-Middleware für User-Whitelist +``` + +#### 4.2 Auth Middleware (User-Whitelist) + +**Datei:** `src/middleware/auth.ts` + +```typescript +// Middleware die prüft ob ctx.from.id in ALLOWED_USER_IDS enthalten ist. +// Falls nicht: Antwort "⛔ Du bist nicht autorisiert, diesen Bot zu verwenden." +// und ctx.next() NICHT aufrufen. +// +// WICHTIG: Auch in Gruppen-Chats nur auf autorisierte User reagieren. +``` + +**Akzeptanzkriterien:** +- [ ] Nicht-autorisierte User erhalten Fehlermeldung +- [ ] Autorisierte User können alle Funktionen nutzen +- [ ] Middleware blockiert alle Handler, nicht nur einzelne + +#### 4.3 Command Handler + +**Datei:** `src/telegram/handlers.ts` + +Implementiere folgende Befehle: + +**`/start`** +- Begrüßungsnachricht mit Kurzanleitung +- Zeige aktuell gewählten Tenant +- Text: +``` +🤖 Payload Media Upload Bot + +Schicke mir ein Bild und ich lade es in die Payload CMS Media-Bibliothek hoch. + +📌 Aktueller Tenant: [Tenant-Name] +📋 Befehle: +/tenant - Tenant wechseln +/list - Letzte 5 Uploads anzeigen +/status - Bot- und API-Status +/help - Hilfe anzeigen +``` + +**`/tenant`** +- Zeige Inline-Keyboard mit allen verfügbaren Tenants +- Tenants dynamisch von der API laden: `GET /api/tenants` (Auth erforderlich) +- Nach Auswahl: Tenant in Session speichern +- Bestätigung: `✅ Tenant gewechselt zu: [Name] (ID: [ID])` + +**`/list`** +- Zeige die letzten 5 hochgeladenen Medien des aktuellen Tenants +- API: `GET /api/media?where[tenant][equals]=&sort=-createdAt&limit=5` +- Ausgabe als Liste mit Thumbnail-URL, Dateiname, Datum + +**`/status`** +- Zeige: + - Bot-Uptime + - Payload API erreichbar? (Quick-Check: `GET /api/users/me`) + - Aktueller Tenant + - Token-Status (gültig bis...) + +**`/help`** +- Ausführliche Hilfe mit allen Befehlen und Nutzungshinweisen + +#### 4.4 Photo Handler (Kern-Funktionalität) + +**Datei:** `src/telegram/handlers.ts` (fortgesetzt) + +```typescript +// Handler für bot.on('message:photo') +// +// Ablauf: +// 1. Höchste verfügbare Auflösung wählen: +// ctx.message.photo ist ein Array von PhotoSize-Objekten, +// sortiert nach Größe. Letztes Element = höchste Auflösung. +// +// 2. File-Info abrufen: +// const file = await ctx.api.getFile(photo.file_id) +// Download-URL: https://api.telegram.org/file/bot/ +// +// 3. Bild herunterladen (als Buffer): +// fetch() auf die Download-URL +// +// 4. Alt-Text bestimmen: +// - Falls Caption vorhanden (ctx.message.caption) → als Alt-Text verwenden +// - Falls nicht → Generiere: "Upload via Telegram – [Datum] [Uhrzeit]" +// +// 5. Statusmeldung senden: +// "⏳ Bild wird hochgeladen..." +// +// 6. An Payload API hochladen: +// payloadClient.uploadMedia(buffer, filename, { alt, tenantId }) +// +// 7. Erfolgsmeldung: +// "✅ Upload erfolgreich! +// 📎 ID: [id] +// 📁 Dateiname: [filename] +// 🔗 URL: [url] +// 🏷️ Tenant: [tenant-name] +// 📐 Größen: thumbnail, small, medium, large, xlarge, 2k, og" +// +// 8. Bei Fehler: +// "❌ Upload fehlgeschlagen: [Fehlermeldung]" +// Logge den vollständigen Error serverseitig. +``` + +**Akzeptanzkriterien:** +- [ ] Bilder werden in höchster Auflösung heruntergeladen +- [ ] Caption wird als Alt-Text verwendet (falls vorhanden) +- [ ] Statusmeldung wird gesendet BEVOR der Upload startet +- [ ] Erfolgsmeldung enthält Media-ID und URL +- [ ] Fehler werden sauber abgefangen und dem User angezeigt +- [ ] Tenant-Zuordnung ist korrekt + +#### 4.5 Document Handler (Erweitert) + +```typescript +// Handler für bot.on('message:document') +// +// Akzeptiere nur Bildformate: jpg, jpeg, png, webp, avif, gif, svg +// Bei nicht unterstütztem Format: +// "⚠️ Format nicht unterstützt. Erlaubt: JPG, PNG, WebP, AVIF, GIF, SVG" +// +// Vorteil von Document-Upload gegenüber Photo: +// Telegram komprimiert Bilder die als Foto gesendet werden. +// Als Dokument gesendet bleibt die Originalqualität erhalten. +// → Dem User diesen Tipp in /help erklären. +``` + +#### 4.6 Album/Bulk Handler + +```typescript +// Handler für mehrere Bilder gleichzeitig (Media Group / Album) +// +// Telegram sendet Alben als einzelne Messages mit gleicher media_group_id. +// Sammle alle Messages mit gleicher media_group_id über ein kurzes Zeitfenster +// (500ms Debounce), dann lade alle Bilder sequentiell hoch. +// +// Status: "⏳ Album erkannt: [N] Bilder werden hochgeladen..." +// Pro Bild: Fortschritt melden: "📤 [X]/[N] hochgeladen..." +// Am Ende: Zusammenfassung aller Upload-IDs +``` + +#### 4.7 Inline Keyboards + +**Datei:** `src/telegram/keyboards.ts` + +```typescript +// Tenant-Auswahl Keyboard +// Dynamisch aus API geladen (GET /api/tenants) +// Format: 2 Buttons pro Reihe +// Jeder Button: callback_data = "tenant:" +// +// Beispiel: +// [ [porwoll.de] [C2S] ] +// [ [Gunshin] [BlogWoman] ] + +// Callback Query Handler: +// Bei "tenant:" → Session updaten, Bestätigung senden +``` + +--- + +### 5. Utilities + +#### 5.1 Logger + +**Datei:** `src/utils/logger.ts` + +```typescript +// Einfacher Logger mit Levels: debug, info, warn, error +// Format: [TIMESTAMP] [LEVEL] [MODULE] Message +// Beispiel: [2026-03-01 14:30:00] [INFO] [PayloadClient] Login erfolgreich +// +// Kein externes Logging-Framework nötig – console.log basiert reicht. +// LOG_LEVEL aus config bestimmt Mindest-Level. +``` + +#### 5.2 Download Helper + +**Datei:** `src/utils/download.ts` + +```typescript +// Funktion zum Herunterladen einer Datei von einer URL als Buffer. +// Nutze native fetch(). +// Timeout: 30 Sekunden +// Max. Dateigröße: 20 MB (Telegram-Limit) +// Bei Fehler: Spezifische Error-Messages (Timeout, Too Large, Network Error) + +async function downloadFile(url: string): Promise<{ buffer: Buffer; mimeType: string }> { /* ... */ } +``` + +--- + +### 6. PM2 Konfiguration & Deployment + +#### 6.1 PM2 Ecosystem File + +**Datei:** `ecosystem.config.cjs` + +```javascript +module.exports = { + apps: [{ + name: 'telegram-media-bot', + script: './dist/index.js', + instances: 1, // NUR 1 Instanz! Telegram Long-Polling verträgt kein Clustering + autorestart: true, + watch: false, + max_memory_restart: '256M', + env: { + NODE_ENV: 'production' + }, + error_file: './logs/error.log', + out_file: './logs/out.log', + merge_logs: true, + time: true + }] +}; +``` + +**WICHTIG:** Der Bot nutzt Long-Polling (kein Webhook). Deshalb darf nur EINE Instanz laufen. Mehrere Instanzen führen zu Konflikten bei der Telegram API. + +#### 6.2 Deployment auf sv-payload + +Der Bot läuft als zusätzlicher PM2-Prozess auf **sv-payload (LXC 700, 10.10.181.100)**, wo bereits das Payload CMS per PM2 managed wird. + +```bash +# Auf sv-payload (als user 'payload') +cd /home/payload +git clone git@github.com:complexcaresolutions/telegram-media-bot.git +cd telegram-media-bot +pnpm install +cp .env.example .env +# → .env mit echten Werten befüllen + +# Build und Start +pnpm build +pm2 start ecosystem.config.cjs +pm2 save +``` + +#### 6.3 GitHub Actions Workflow (Optional) + +**Datei:** `.github/workflows/deploy.yml` + +```yaml +name: Deploy Telegram Bot +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1 + with: + host: 37.24.237.181 # Externe IP (UDM Pro SE) + username: payload + key: ${{ secrets.STAGING_SSH_KEY }} + port: 22122 # SSH-Port über Port-Forwarding + script: | + cd /home/payload/telegram-media-bot + git pull origin main + pnpm install --frozen-lockfile + pnpm build + pm2 restart telegram-media-bot +``` + +**SSH-Zugang:** Externer Zugang über UDM Pro SE Port-Forwarding (Port 22122 → sv-payload:22). Der SSH-Key `STAGING_SSH_KEY` ist bereits als GitHub Secret konfiguriert. + +--- + +### 7. Sicherheit + +#### 7.1 Maßnahmen + +1. **User-Whitelist:** Nur explizit erlaubte Telegram User-IDs dürfen den Bot nutzen +2. **Token-Sicherheit:** Payload JWT wird nur im Memory gehalten, nie auf Disk geschrieben +3. **Environment Variables:** Alle Secrets in `.env`, nie im Code +4. **Rate Limiting:** Maximal 10 Uploads pro Minute pro User (Bot-seitig implementiert) +5. **Dateigrößen-Limit:** Telegram begrenzt auf 20 MB, zusätzlich Bot-seitiges Limit von 20 MB +6. **Dateityp-Validierung:** Nur erlaubte MIME-Types akzeptieren (image/jpeg, image/png, image/webp, image/avif, image/gif, image/svg+xml) +7. **Kein Webhook-Modus:** Long-Polling vermeidet die Notwendigkeit eines öffentlich erreichbaren Endpoints + +#### 7.2 Telegram Bot erstellen + +1. Öffne Telegram und suche `@BotFather` +2. Sende `/newbot` +3. Name: `CCS Media Upload Bot` (oder ähnlich) +4. Username: `ccs_media_upload_bot` (muss eindeutig sein und auf `bot` enden) +5. Token sichern → in `.env` als `TELEGRAM_BOT_TOKEN` eintragen +6. Optional via BotFather: + - `/setdescription` – Bot-Beschreibung setzen + - `/setcommands` – Bot-Befehle registrieren: + ``` + start - Bot starten und Hilfe anzeigen + tenant - Ziel-Tenant wechseln + list - Letzte Uploads anzeigen + status - Bot- und API-Status prüfen + help - Ausführliche Hilfe + ``` + +--- + +### 8. Error Handling & Edge Cases + +#### 8.1 Zu behandelnde Szenarien + +| Szenario | Verhalten | +|----------|-----------| +| Payload API nicht erreichbar | User informieren, Retry nach 30s, Log Error | +| JWT abgelaufen während Upload | Auto-Relogin + Retry (max. 1x) | +| Telegram File Download fehlschlägt | User informieren mit spezifischem Error | +| Bild zu groß (>20 MB) | `⚠️ Datei zu groß. Maximum: 20 MB` | +| Nicht unterstütztes Format | `⚠️ Format nicht unterstützt. Erlaubt: JPG, PNG, WebP, AVIF, GIF, SVG` | +| Kein Tenant gewählt | Default-Tenant verwenden, User informieren | +| Payload 403 (Tenant-Problem) | `❌ Zugriff verweigert. Prüfe die Tenant-Zuordnung.` | +| Payload 429 (Rate Limit) | `⏳ Zu viele Anfragen. Bitte warte [X] Sekunden.` | +| Bot-Start ohne gültige Config | Sofortiger Exit mit klarem Fehlertext | +| Album mit >10 Bildern | Hinweis dass max. 10 gleichzeitig verarbeitet werden | + +#### 8.2 Graceful Shutdown + +```typescript +// Bei SIGINT/SIGTERM: +// 1. Bot-Polling stoppen +// 2. Laufende Uploads abwarten (max. 60s Timeout) +// 3. Prozess beenden +// PM2 sendet SIGINT, dann nach Timeout SIGKILL. +``` + +--- + +## Erfolgskriterien (Gesamt) + +- [ ] `pnpm lint` (tsc --noEmit) ohne Errors +- [ ] `pnpm build` erfolgreich +- [ ] Bot startet und verbindet sich mit Telegram +- [ ] `/start` zeigt Begrüßung +- [ ] `/tenant` zeigt Inline-Keyboard mit Tenants aus der API +- [ ] Tenant-Wechsel funktioniert und wird in Session gespeichert +- [ ] Bild-Upload (als Foto) → Bild erscheint in Payload CMS Media Collection mit korrektem Tenant +- [ ] Bild-Upload (als Dokument) → Bild in Originalqualität hochgeladen +- [ ] Caption wird als Alt-Text übernommen +- [ ] Album-Upload funktioniert (mehrere Bilder) +- [ ] `/list` zeigt letzte 5 Uploads +- [ ] `/status` zeigt API-Status und Token-Validität +- [ ] Nicht-autorisierte User werden blockiert +- [ ] Fehler werden dem User als verständliche Meldungen angezeigt +- [ ] PM2 managed den Prozess mit Auto-Restart +- [ ] Logs werden in `./logs/` geschrieben + +--- + +## Selbst-Prüfung + +Nach jeder Iteration: +1. `pnpm lint` (tsc --noEmit) +2. `pnpm build` +3. Bei Fehler: korrigieren und wiederholen +4. Manueller Test-Flow: + - Bot starten mit `pnpm dev` + - `/start` senden + - `/tenant` → Tenant wählen + - Bild senden → Upload prüfen + - `/list` → Upload in Liste sichtbar +5. Fortschritt in README.md dokumentieren + +--- + +## Escape Hatch + +Nach 15 Iterationen ohne Fortschritt: +- Dokumentiere was blockiert in BLOCKERS.md +- Liste alle versuchten Ansätze auf +- Schlage 3 alternative Lösungswege vor +- Output BLOCKED + +--- + +## Hinweise für die Implementierung + +1. **Telegram Bot Token** muss vor dem Start via @BotFather erstellt und in `.env` eingetragen werden. +2. **Payload Admin Credentials** müssen einen User mit SuperAdmin-Rechten referenzieren (für Tenant-übergreifenden Zugriff). +3. **Grammy statt node-telegram-bot-api** – Grammy ist moderner, hat bessere TypeScript-Unterstützung und aktive Wartung. +4. **Native fetch statt axios** – Node.js 22 hat native fetch/FormData. Keine zusätzliche HTTP-Library nötig. +5. **Long-Polling statt Webhooks** – Einfacher zu deployen (kein öffentlicher Endpoint nötig), perfekt für den Use Case. +6. **Kein separater LXC-Container** nötig – der Bot läuft als zusätzlicher PM2-Prozess auf sv-payload. + +--- + +## Fertig? + +Wenn ALLE Aufgaben erledigt sind UND alle Erfolgskriterien erfüllt sind: + +TELEGRAM_BOT_COMPLETE From ddeb387143020d1c603685987687a3bb2c2968d8 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Sun, 1 Mar 2026 23:04:49 +0000 Subject: [PATCH 3/3] fix(security): update minimatch override to >=10.2.3 (CVE ReDoS) Fixes two high-severity Dependabot alerts for minimatch ReDoS vulnerabilities (nested extglobs + GLOBSTAR backtracking). Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- pnpm-lock.yaml | 34 +++++++++++++++++----------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 9e54a8e..599fe75 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "unrs-resolver" ], "overrides": { - "minimatch": ">=10.2.1", + "minimatch": ">=10.2.3", "esbuild": ">=0.25.0", "ajv": ">=8.18.0", "ioredis": "5.9.3" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55cd71e..623fbfd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,7 +5,7 @@ settings: excludeLinksFromLockfile: false overrides: - minimatch: '>=10.2.1' + minimatch: '>=10.2.3' esbuild: '>=0.25.0' ajv: '>=8.18.0' ioredis: 5.9.3 @@ -3539,8 +3539,8 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - minimatch@10.2.2: - resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} minimist@1.2.8: @@ -5326,7 +5326,7 @@ snapshots: dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 10.2.2 + minimatch: 10.2.4 transitivePeerDependencies: - supports-color @@ -5347,7 +5347,7 @@ snapshots: ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 10.2.2 + minimatch: 10.2.4 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -6403,7 +6403,7 @@ snapshots: '@typescript-eslint/types': 8.56.0 '@typescript-eslint/visitor-keys': 8.56.0 debug: 4.4.3 - minimatch: 10.2.2 + minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 ts-api-utils: 2.4.0(typescript@5.9.3) @@ -7341,7 +7341,7 @@ snapshots: eslint: 9.39.3 eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3))(eslint@9.39.3) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.3) eslint-plugin-react: 7.37.5(eslint@9.39.3) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.3) @@ -7374,7 +7374,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3))(eslint@9.39.3) transitivePeerDependencies: - supports-color @@ -7389,7 +7389,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.3): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.56.0(eslint@9.39.3)(typescript@5.9.3))(eslint@9.39.3))(eslint@9.39.3))(eslint@9.39.3): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -7404,7 +7404,7 @@ snapshots: hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 - minimatch: 10.2.2 + minimatch: 10.2.4 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 @@ -7432,7 +7432,7 @@ snapshots: hasown: 2.0.2 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 - minimatch: 10.2.2 + minimatch: 10.2.4 object.fromentries: 2.0.8 safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 @@ -7460,7 +7460,7 @@ snapshots: estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 - minimatch: 10.2.2 + minimatch: 10.2.4 object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 @@ -7514,7 +7514,7 @@ snapshots: is-glob: 4.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 - minimatch: 10.2.2 + minimatch: 10.2.4 natural-compare: 1.4.0 optionator: 0.9.4 transitivePeerDependencies: @@ -7759,7 +7759,7 @@ snapshots: dependencies: foreground-child: 3.3.1 jackspeak: 3.4.3 - minimatch: 10.2.2 + minimatch: 10.2.4 minipass: 7.1.3 package-json-from-dist: 1.0.1 path-scurry: 1.11.1 @@ -7769,7 +7769,7 @@ snapshots: fs.realpath: 1.0.0 inflight: 1.0.6 inherits: 2.0.4 - minimatch: 10.2.2 + minimatch: 10.2.4 once: 1.4.0 path-is-absolute: 1.0.1 @@ -8591,7 +8591,7 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 - minimatch@10.2.2: + minimatch@10.2.4: dependencies: brace-expansion: 5.0.2 @@ -9143,7 +9143,7 @@ snapshots: readdir-glob@1.1.3: dependencies: - minimatch: 10.2.2 + minimatch: 10.2.4 readdirp@3.6.0: dependencies: