From e6755d2042ec3404d6c74b625453e35d8b878558 Mon Sep 17 00:00:00 2001
From: Remi <73385395+itsrubberduck@users.noreply.github.com>
Date: Wed, 17 Sep 2025 15:44:41 +0200
Subject: [PATCH] Revise landing messaging and add news feed
---
app/pages/api-docs.vue | 2 +
app/pages/datenschutz.vue | 11 +-
app/pages/impressum.vue | 2 +-
app/pages/index.vue | 595 ++++++++++++++----
app/pages/news/[slug].vue | 73 +++
app/pages/news/index.vue | 76 +++
content/news/alpha-prototype.md | 33 +
.../api/service/roadmap-suggestions.post.ts | 60 ++
server/api/service/updates.post.ts | 52 ++
server/api/service/waitlist.post.ts | 54 ++
server/models/RoadmapSuggestion.ts | 25 +
server/models/UpdateSubscriber.ts | 32 +
server/models/WaitlistEntry.ts | 4 +
server/utils/notifications.ts | 103 +++
server/utils/subscribers.ts | 38 ++
shared/utils/news.ts | 238 +++++++
types/nodemailer.d.ts | 5 +
17 files changed, 1286 insertions(+), 117 deletions(-)
create mode 100644 app/pages/news/[slug].vue
create mode 100644 app/pages/news/index.vue
create mode 100644 content/news/alpha-prototype.md
create mode 100644 server/api/service/roadmap-suggestions.post.ts
create mode 100644 server/api/service/updates.post.ts
create mode 100644 server/models/RoadmapSuggestion.ts
create mode 100644 server/models/UpdateSubscriber.ts
create mode 100644 server/utils/notifications.ts
create mode 100644 server/utils/subscribers.ts
create mode 100644 shared/utils/news.ts
create mode 100644 types/nodemailer.d.ts
diff --git a/app/pages/api-docs.vue b/app/pages/api-docs.vue
index 025adf8..aac5ad5 100644
--- a/app/pages/api-docs.vue
+++ b/app/pages/api-docs.vue
@@ -86,8 +86,10 @@
const publicEndpoints = [
{ method: 'POST', path: '/api/service/waitlist', description: 'Person auf die Warteliste setzen. Erfordert Zustimmung zu AGB & Datenschutz.' },
{ method: 'GET', path: '/api/service/waitlist', description: 'Aggregierte Wartelistenstatistiken mit Gesamtanzahl, Wachstum und sichtbarem Puffer abrufen.' },
+ { method: 'POST', path: '/api/service/updates', description: 'E-Mail für Produkt-Updates und neue Features eintragen (Einwilligung erforderlich).' },
{ method: 'GET', path: '/api/service/roadmap', description: 'Roadmap-Items inklusive Community-Durchschnitt, Gesamtstimmen und letzter Aktivität auslesen.' },
{ method: 'POST', path: '/api/service/roadmap', description: 'Wichtigkeit (1–5) für einzelne Roadmap-Punkte voten; speichert jeden Vote mit Zeitstempel.' },
+ { method: 'POST', path: '/api/service/roadmap-suggestions', description: 'Neuen Roadmap-Vorschlag mit optionaler Kontaktadresse einreichen.' },
{ method: 'POST', path: '/api/service/auth/login', description: 'Login mit E-Mail & Passwort. Gibt JWT zurück und setzt Refresh-Cookie.' },
{ method: 'POST', path: '/api/service/auth/register', description: 'Registrierung mit Einladungscode und Einwilligungen.' },
{ method: 'POST', path: '/api/service/auth/refresh', description: 'Access-Token anhand des Refresh-Cookies erneuern.' },
diff --git a/app/pages/datenschutz.vue b/app/pages/datenschutz.vue
index 2f57350..c6a9982 100644
--- a/app/pages/datenschutz.vue
+++ b/app/pages/datenschutz.vue
@@ -21,7 +21,9 @@
2. Verarbeitete Daten
- Warteliste: Name (optional), E-Mail-Adresse, freiwillige Hinweise, Zeitpunkt des Eintrags, Einwilligungen.
+ - Feature-Benachrichtigungen: E-Mail-Adresse, optionaler Name, Opt-in-Zeitpunkt, Zustimmung für Produkt-Updates.
- Nutzerkonto: Name, E-Mail-Adresse, Passwort-Hash, Einladungsstatus, Erstellungs- und Login-Zeitpunkte, Einladungscode-Historie.
+ - Roadmap-Vorschläge: Titel, Beschreibung, optional angegebene Kontaktadresse, Einwilligungen und Zeitstempel.
- Kommunikationsdaten: Sämtliche eingegebenen oder per Push-to-Talk übermittelten Funksprüche (Transkripte, normalisierte Texte, Metaangaben wie Modul, Lesson-ID, Stärke, Entscheidungskontext).
- Technische Protokolle: Geräteinformationen (Browser, OS), Zeitstempel, Request-IDs, Fehlerlogs.
@@ -33,7 +35,7 @@
- Bereitstellung des Dienstes (Art. 6 Abs. 1 lit. b DSGVO): Verwaltung von Warteliste, Accounts und Sessions, Ausgabe von Einladungscodes, Abrechnung zukünftiger Tarife.
- Produktverbesserung & Sicherheit (Art. 6 Abs. 1 lit. f DSGVO): Analyse und Logging sämtlicher Funksprüche, Fehlerüberwachung, Missbrauchsprävention.
- - Kommunikation (Art. 6 Abs. 1 lit. a DSGVO): Versand von Wartelisten-Updates, Einladungen und produktspezifischen Informationen nach ausdrücklicher Einwilligung.
+ - Kommunikation & Community-Feedback (Art. 6 Abs. 1 lit. a DSGVO): Versand von Wartelisten-Updates und Produkt-News nach ausdrücklicher Einwilligung sowie Auswertung eingereichter Roadmap-Vorschläge inkl. optionaler Kontaktaufnahme.
@@ -41,6 +43,8 @@
4. Speicherdauer
- Wartelisteneinträge: bis zum Widerruf oder maximal 24 Monate nach letzter Aktivität.
+ - Feature-Benachrichtigungen: bis zur Abmeldung (Opt-out) oder Widerruf der Einwilligung.
+ - Roadmap-Vorschläge: bis zur Umsetzung oder spätestens 18 Monate nach Einreichung.
- Accountdaten: solange das Nutzerkonto besteht, anschließend entsprechend gesetzlicher Aufbewahrungspflichten.
- Kommunikationslogs: mindestens 12 Monate zur Qualitätsverbesserung; bei Supportfällen oder Missbrauchsvorwürfen bis zur abschließenden Klärung.
@@ -51,6 +55,9 @@
Wir hosten OpenSquawk auf europäischen Cloud-Plattformen (derzeit Hetzner Cloud, Deutschland). Kommunikationsdaten werden in unserer MongoDB-Datenbank gespeichert. Externe KI-Dienstleister (z. B. OpenAI) erhalten ausschließlich pseudonymisierte Texte zur Verarbeitung von TTS/LLM-Funktionen. Es gelten entsprechende Auftragsverarbeitungsverträge. Eine Übermittlung in Drittstaaten erfolgt nur unter Nutzung von EU-Standardvertragsklauseln.
+
+ Hinweis: Formularübermittlungen (Warteliste, Feature-Benachrichtigungen, Roadmap-Vorschläge) lösen eine interne Benachrichtigungs-E-Mail an opensquawk@faktorxmensch.com aus, versendet über den konfigurierten SMTP- oder Transaktionsmail-Anbieter. Enthalten sind ausschließlich die von dir eingegebenen Angaben zur zügigen Bearbeitung.
+
@@ -75,7 +82,7 @@
diff --git a/app/pages/news/[slug].vue b/app/pages/news/[slug].vue
new file mode 100644
index 0000000..78c548a
--- /dev/null
+++ b/app/pages/news/[slug].vue
@@ -0,0 +1,73 @@
+
+
+
+
+ Zurück zu News & Updates
+
+
+
+
+ {{ formatNewsDate(post.publishedAt) }}
+ {{ post.title }}
+ {{ post.readingTime }}
+
+
+
+
+
+
Beitrag nicht gefunden
+
Der angeforderte News-Artikel existiert nicht (mehr). Schau dir stattdessen den aktuellen Feed an.
+
Zur Übersicht
+
+
+
+
+
+
+
+
diff --git a/app/pages/news/index.vue b/app/pages/news/index.vue
new file mode 100644
index 0000000..67e34f5
--- /dev/null
+++ b/app/pages/news/index.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+ Zurück zur Startseite
+
+
News & Updates
+
+ Content-getriebene Updates zu Alpha-Builds, Simulator-Support und Community-Entscheidungen.
+
+
+
Zum aktuellen Feed
+
+
+
+
+
+
+
+
+
{{ formatNewsDate(post.publishedAt) }}
+
{{ post.title }}
+
{{ post.excerpt }}
+
+
+ {{ post.readingTime }}
+ Weiterlesen
+
+
+
+
+ Noch keine Beiträge veröffentlicht. Schau bald wieder vorbei!
+
+
+
+
+
+
+
+
+
diff --git a/content/news/alpha-prototype.md b/content/news/alpha-prototype.md
new file mode 100644
index 0000000..4c1f6b9
--- /dev/null
+++ b/content/news/alpha-prototype.md
@@ -0,0 +1,33 @@
+---
+title: "Alpha-Prototyp bereit für frühe Tests"
+date: "2025-01-20"
+summary: "Unser erster OpenSquawk Alpha-Build läuft lokal für MSFS und zeigt, wohin die Reise Richtung community-getriebener AI-ATC geht."
+---
+
+Wir haben den Alpha-Prototyp von OpenSquawk intern stabilisiert und geben ihn jetzt an neugierige Mitentwickler:innen raus. Der Build kombiniert eine Nuxt-Oberfläche mit Node-Services für Speech-to-Text, Entscheidungslogik und TTS. Alles lässt sich lokal betreiben, solange du ein wenig Terminal-Erfahrung mitbringst.
+
+## Was dich erwartet
+
+- Self-host Setup: `yarn install`, `.env` befüllen, danach `yarn dev` oder `docker compose up` für das volle Paket.
+- Simulator-Fokus: Microsoft Flight Simulator (MSFS) zuerst, X-Plane steht bereits in der Konzeptphase.
+- Zielbild: Community-driven Features, damit Trainingsrichtung VATSIM realitätsnah bleibt und Hosting langfristig bezahlbar wird.
+
+## Startvoraussetzungen
+
+- Grundkenntnisse in Node/Nuxt (Logs lesen, Pakete aktualisieren, `.env` konfigurieren).
+- Zugriff auf MSFS (PC) für die ersten Funkexperimente.
+- Bereitschaft, Issues zu dokumentieren und kleine Tweaks per Pull Request zu liefern.
+
+## Wie du helfen kannst
+
+- Teste den Alpha-Build und melde Findings als Issues – am besten mit Logs oder kurzen Screenshots.
+- Schau in die Issues labeled `help-wanted`, dort liegen konkrete Aufgaben für Node/Nuxt Devs, ATC SMEs, Tester:innen und Infra-/Kosten-Benchmarking.
+- Teile eigene Feature-Ideen direkt in der Roadmap oder schreib uns via [opensquawk@faktorxmensch.com](mailto:opensquawk@faktorxmensch.com).
+
+## Nächste Schritte
+
+- X-Plane Plugin als nächstes Milestone.
+- Lernpfad-Module iterieren (Ground → Departure → Arrival → VATSIM).
+- Hosting-Kosten transparent benchmarken und im Blog teilen.
+
+Danke für jedes Feedback – gemeinsam bringen wir OpenSquawk Richtung „Open-source, low-cost AI ATC für Flugsimulatoren".
diff --git a/server/api/service/roadmap-suggestions.post.ts b/server/api/service/roadmap-suggestions.post.ts
new file mode 100644
index 0000000..2ca0108
--- /dev/null
+++ b/server/api/service/roadmap-suggestions.post.ts
@@ -0,0 +1,60 @@
+import { readBody, createError } from 'h3'
+import { RoadmapSuggestion } from '../../models/RoadmapSuggestion'
+import { sendAdminNotification } from '../../utils/notifications'
+
+interface RoadmapSuggestionBody {
+ title?: string
+ details?: string
+ email?: string
+ allowContact?: boolean
+ consentPrivacy?: boolean
+}
+
+const MIN_TITLE_LENGTH = 4
+const MIN_DETAILS_LENGTH = 20
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+ const title = body.title?.trim()
+ const details = body.details?.trim()
+ const email = body.email?.trim().toLowerCase()
+ const allowContact = Boolean(body.allowContact && email)
+
+ if (!title || title.length < MIN_TITLE_LENGTH) {
+ throw createError({ statusCode: 400, statusMessage: 'Bitte gib einen kurzen Titel (mindestens 4 Zeichen) an.' })
+ }
+
+ if (!details || details.length < MIN_DETAILS_LENGTH) {
+ throw createError({ statusCode: 400, statusMessage: 'Beschreibe deinen Vorschlag mit mindestens 20 Zeichen.' })
+ }
+
+ if (!body.consentPrivacy) {
+ throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung zur Datenschutzerklärung.' })
+ }
+
+ if (body.allowContact && !email) {
+ throw createError({ statusCode: 400, statusMessage: 'Bitte gib eine E-Mail an, damit wir dich kontaktieren können.' })
+ }
+
+ const suggestion = await RoadmapSuggestion.create({
+ title,
+ details,
+ email,
+ allowContact,
+ consentPrivacy: true,
+ })
+
+ const lines = [
+ `Titel: ${title}`,
+ `Beschreibung: ${details}`,
+ `Kontakt: ${email || '—'}`,
+ allowContact ? 'Kontaktaufnahme erwünscht' : 'Keine Kontaktaufnahme erwünscht',
+ ]
+
+ await sendAdminNotification('[OpenSquawk] Neuer Roadmap-Vorschlag', lines.join('\n'))
+
+ return {
+ success: true,
+ suggestionId: suggestion._id.toString(),
+ }
+})
diff --git a/server/api/service/updates.post.ts b/server/api/service/updates.post.ts
new file mode 100644
index 0000000..a3fcf97
--- /dev/null
+++ b/server/api/service/updates.post.ts
@@ -0,0 +1,52 @@
+import { readBody, createError } from 'h3'
+import { registerUpdateSubscriber } from '../../utils/subscribers'
+import { sendAdminNotification } from '../../utils/notifications'
+
+interface UpdatesRequestBody {
+ email?: string
+ name?: string
+ consentPrivacy?: boolean
+ consentMarketing?: boolean
+ source?: string
+}
+
+export default defineEventHandler(async (event) => {
+ const body = await readBody(event)
+ const email = body.email?.trim().toLowerCase()
+ const name = body.name?.trim()
+ const source = body.source?.trim() || 'landing-updates'
+
+ if (!email) {
+ throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
+ }
+
+ if (!body.consentPrivacy) {
+ throw createError({ statusCode: 400, statusMessage: 'Bitte bestätige die Datenschutzerklärung' })
+ }
+
+ if (!body.consentMarketing) {
+ throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung für Produkt-Updates per E-Mail' })
+ }
+
+ const result = await registerUpdateSubscriber({
+ email,
+ name,
+ source,
+ consentPrivacy: true,
+ consentMarketing: true,
+ })
+
+ if (result.created) {
+ const lines = [
+ `E-Mail: ${email}`,
+ name ? `Name: ${name}` : null,
+ `Quelle: ${source}`,
+ ].filter(Boolean)
+ await sendAdminNotification('[OpenSquawk] Neue Updates-Liste', lines.join('\n'))
+ }
+
+ return {
+ success: true,
+ alreadySubscribed: !result.created,
+ }
+})
diff --git a/server/api/service/waitlist.post.ts b/server/api/service/waitlist.post.ts
index afc8771..f46b06f 100644
--- a/server/api/service/waitlist.post.ts
+++ b/server/api/service/waitlist.post.ts
@@ -1,5 +1,7 @@
import { readBody, createError } from 'h3'
import { WaitlistEntry } from '../../models/WaitlistEntry'
+import { sendAdminNotification } from '../../utils/notifications'
+import { registerUpdateSubscriber } from '../../utils/subscribers'
interface WaitlistRequestBody {
email?: string
@@ -8,6 +10,7 @@ interface WaitlistRequestBody {
consentPrivacy?: boolean
consentTerms?: boolean
source?: string
+ wantsProductUpdates?: boolean
}
export default defineEventHandler(async (event) => {
@@ -16,6 +19,7 @@ export default defineEventHandler(async (event) => {
const name = body.name?.trim()
const notes = body.notes?.trim()
const source = body.source?.trim() || 'landing'
+ const wantsProductUpdates = Boolean(body.wantsProductUpdates)
if (!email) {
throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' })
@@ -30,12 +34,41 @@ export default defineEventHandler(async (event) => {
const existing = await WaitlistEntry.findOne({ email })
if (existing) {
+ const previouslyWantedUpdates = Boolean(existing.wantsProductUpdates)
existing.name = name || existing.name
existing.notes = notes || existing.notes
existing.source = source
existing.consentPrivacy = true
existing.consentTerms = true
+ if (wantsProductUpdates && !previouslyWantedUpdates) {
+ existing.wantsProductUpdates = true
+ existing.updatesOptedInAt = now
+ }
await existing.save()
+
+ if (wantsProductUpdates) {
+ const updateResult = await registerUpdateSubscriber({
+ email,
+ name,
+ source: `${source}-waitlist`,
+ consentPrivacy: true,
+ consentMarketing: true,
+ })
+
+ if (!previouslyWantedUpdates && updateResult.created) {
+ const lines = [
+ `E-Mail: ${email}`,
+ name ? `Name: ${name}` : null,
+ notes ? `Notizen: ${notes}` : null,
+ `Quelle: ${source}`,
+ ].filter(Boolean)
+ await sendAdminNotification(
+ '[OpenSquawk] Neue Updates-Liste (via Warteliste)',
+ `${lines.join('\n')}\nOpt-In: Produkt-Updates`,
+ )
+ }
+ }
+
return {
success: true,
alreadyJoined: true,
@@ -51,8 +84,29 @@ export default defineEventHandler(async (event) => {
consentPrivacy: true,
consentTerms: true,
joinedAt: now,
+ wantsProductUpdates,
+ updatesOptedInAt: wantsProductUpdates ? now : undefined,
})
+ if (wantsProductUpdates) {
+ await registerUpdateSubscriber({
+ email,
+ name,
+ source: `${source}-waitlist`,
+ consentPrivacy: true,
+ consentMarketing: true,
+ })
+ }
+
+ const lines = [
+ `E-Mail: ${email}`,
+ name ? `Name: ${name}` : null,
+ notes ? `Notizen: ${notes}` : null,
+ `Quelle: ${source}`,
+ wantsProductUpdates ? 'Opt-In: Produkt-Updates' : 'Opt-In: nur Warteliste',
+ ].filter(Boolean)
+ await sendAdminNotification('[OpenSquawk] Neue Wartelisten-Anmeldung', lines.join('\n'))
+
return {
success: true,
alreadyJoined: false,
diff --git a/server/models/RoadmapSuggestion.ts b/server/models/RoadmapSuggestion.ts
new file mode 100644
index 0000000..86c9524
--- /dev/null
+++ b/server/models/RoadmapSuggestion.ts
@@ -0,0 +1,25 @@
+import mongoose from 'mongoose'
+
+export interface RoadmapSuggestionDocument extends mongoose.Document {
+ title: string
+ details: string
+ email?: string
+ allowContact: boolean
+ consentPrivacy: boolean
+ submittedAt: Date
+}
+
+const roadmapSuggestionSchema = new mongoose.Schema({
+ title: { type: String, required: true, trim: true, maxlength: 160 },
+ details: { type: String, required: true, trim: true, maxlength: 4000 },
+ email: { type: String, trim: true, lowercase: true },
+ allowContact: { type: Boolean, default: false },
+ consentPrivacy: { type: Boolean, default: false },
+ submittedAt: { type: Date, default: () => new Date() },
+})
+
+roadmapSuggestionSchema.index({ submittedAt: -1 })
+
+export const RoadmapSuggestion =
+ (mongoose.models.RoadmapSuggestion as mongoose.Model | undefined) ||
+ mongoose.model('RoadmapSuggestion', roadmapSuggestionSchema)
diff --git a/server/models/UpdateSubscriber.ts b/server/models/UpdateSubscriber.ts
new file mode 100644
index 0000000..e34b349
--- /dev/null
+++ b/server/models/UpdateSubscriber.ts
@@ -0,0 +1,32 @@
+import mongoose from 'mongoose'
+
+export interface UpdateSubscriberDocument extends mongoose.Document {
+ email: string
+ name?: string
+ source: string
+ consentPrivacy: boolean
+ consentMarketing: boolean
+ subscribedAt: Date
+ lastUpdatedAt: Date
+}
+
+const updateSubscriberSchema = new mongoose.Schema({
+ email: { type: String, required: true, lowercase: true, trim: true, unique: true },
+ name: { type: String, trim: true },
+ source: { type: String, default: 'landing-updates' },
+ consentPrivacy: { type: Boolean, default: false },
+ consentMarketing: { type: Boolean, default: false },
+ subscribedAt: { type: Date, default: () => new Date() },
+ lastUpdatedAt: { type: Date, default: () => new Date() },
+})
+
+updateSubscriberSchema.pre('save', function updateTimestamp(this: UpdateSubscriberDocument, next) {
+ if (this.isModified()) {
+ this.set('lastUpdatedAt', new Date())
+ }
+ next()
+})
+
+export const UpdateSubscriber =
+ (mongoose.models.UpdateSubscriber as mongoose.Model | undefined) ||
+ mongoose.model('UpdateSubscriber', updateSubscriberSchema)
diff --git a/server/models/WaitlistEntry.ts b/server/models/WaitlistEntry.ts
index ac0bebc..c8dd27b 100644
--- a/server/models/WaitlistEntry.ts
+++ b/server/models/WaitlistEntry.ts
@@ -9,6 +9,8 @@ export interface WaitlistEntryDocument extends mongoose.Document {
consentTerms: boolean
joinedAt: Date
activatedAt?: Date
+ wantsProductUpdates?: boolean
+ updatesOptedInAt?: Date
}
const waitlistSchema = new mongoose.Schema({
@@ -20,6 +22,8 @@ const waitlistSchema = new mongoose.Schema({
consentTerms: { type: Boolean, default: false },
joinedAt: { type: Date, default: () => new Date() },
activatedAt: { type: Date },
+ wantsProductUpdates: { type: Boolean, default: false },
+ updatesOptedInAt: { type: Date },
})
export const WaitlistEntry =
diff --git a/server/utils/notifications.ts b/server/utils/notifications.ts
new file mode 100644
index 0000000..8eaf7d8
--- /dev/null
+++ b/server/utils/notifications.ts
@@ -0,0 +1,103 @@
+const ADMIN_EMAIL_FALLBACK = 'opensquawk@faktorxmensch.com'
+
+const RESEND_ENDPOINT = 'https://api.resend.com/emails'
+
+async function sendViaResend(subject: string, text: string) {
+ const apiKey = process.env.NOTIFY_RESEND_API_KEY
+ if (!apiKey) {
+ return false
+ }
+
+ const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
+ const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk '
+
+ try {
+ const response = await fetch(RESEND_ENDPOINT, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ from,
+ to,
+ subject,
+ text,
+ }),
+ })
+
+ if (!response.ok) {
+ const errorText = await response.text().catch(() => '')
+ console.error('Failed to send notification via Resend API', response.status, errorText)
+ return false
+ }
+
+ return true
+ } catch (error) {
+ console.error('Error while sending notification via Resend API', error)
+ return false
+ }
+}
+
+async function sendViaSmtp(subject: string, text: string) {
+ const host = process.env.NOTIFY_SMTP_HOST
+ if (!host) {
+ return false
+ }
+
+ const user = process.env.NOTIFY_SMTP_USER
+ const pass = process.env.NOTIFY_SMTP_PASS
+ if (!user || !pass) {
+ console.warn('SMTP notification is configured without credentials – skipping send.')
+ return false
+ }
+
+ const port = Number.parseInt(process.env.NOTIFY_SMTP_PORT || '', 10)
+ const secure = process.env.NOTIFY_SMTP_SECURE === 'true'
+
+ let nodemailer: any = null
+ try {
+ const module = await import('nodemailer')
+ nodemailer = module.default ?? module
+ } catch (error) {
+ console.warn('nodemailer is not available. Skipping SMTP notification.', error)
+ return false
+ }
+
+ const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
+ const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk '
+
+ try {
+ const transporter = nodemailer.createTransport({
+ host,
+ port: Number.isNaN(port) ? secure ? 465 : 587 : port,
+ secure,
+ auth: {
+ user,
+ pass,
+ },
+ })
+
+ await transporter.sendMail({ from, to, subject, text })
+ return true
+ } catch (error) {
+ console.error('Failed to send notification via SMTP', error)
+ return false
+ }
+}
+
+export async function sendAdminNotification(subject: string, text: string) {
+ const sentViaResend = await sendViaResend(subject, text)
+ if (sentViaResend) {
+ return true
+ }
+
+ const sentViaSmtp = await sendViaSmtp(subject, text)
+ if (sentViaSmtp) {
+ return true
+ }
+
+ const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK
+ console.info(`[notify:fallback] ${subject}\nEmpfänger: ${to}\n${text}`)
+ return false
+}
diff --git a/server/utils/subscribers.ts b/server/utils/subscribers.ts
new file mode 100644
index 0000000..e2eaf0e
--- /dev/null
+++ b/server/utils/subscribers.ts
@@ -0,0 +1,38 @@
+import { UpdateSubscriber, UpdateSubscriberDocument } from '../models/UpdateSubscriber'
+
+interface RegisterSubscriberOptions {
+ email: string
+ name?: string
+ source?: string
+ consentPrivacy: boolean
+ consentMarketing: boolean
+}
+
+export async function registerUpdateSubscriber(options: RegisterSubscriberOptions) {
+ const { email, name, source = 'landing-updates', consentPrivacy, consentMarketing } = options
+ const now = new Date()
+
+ const existing = await UpdateSubscriber.findOne({ email })
+
+ if (existing) {
+ existing.name = name || existing.name
+ existing.source = source || existing.source
+ existing.consentPrivacy = existing.consentPrivacy || consentPrivacy
+ existing.consentMarketing = existing.consentMarketing || consentMarketing
+ existing.lastUpdatedAt = now
+ await existing.save()
+ return { created: false, document: existing as UpdateSubscriberDocument }
+ }
+
+ const document = await UpdateSubscriber.create({
+ email,
+ name,
+ source,
+ consentPrivacy,
+ consentMarketing,
+ subscribedAt: now,
+ lastUpdatedAt: now,
+ })
+
+ return { created: true, document }
+}
diff --git a/shared/utils/news.ts b/shared/utils/news.ts
new file mode 100644
index 0000000..a4da2bb
--- /dev/null
+++ b/shared/utils/news.ts
@@ -0,0 +1,238 @@
+export interface NewsPost {
+ slug: string
+ title: string
+ excerpt: string
+ publishedAt: string
+ body: string
+ html: string
+ readingTime: string
+}
+
+interface FrontMatter {
+ title?: string
+ date?: string
+ summary?: string
+ excerpt?: string
+ description?: string
+ readingTime?: string
+}
+
+const rawNewsModules = import.meta.glob('~~/content/news/*.md', {
+ query: '?raw',
+ import: 'default',
+ eager: true,
+}) as Record
+
+const parsedNews: NewsPost[] = Object.entries(rawNewsModules)
+ .map(([path, raw]) => parseNewsFile(path, raw))
+ .filter((entry): entry is NewsPost => Boolean(entry))
+ .sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime())
+
+export function getAllNews(): NewsPost[] {
+ return parsedNews.map((post) => ({ ...post }))
+}
+
+export function getNewsBySlug(slug: string): NewsPost | null {
+ const found = parsedNews.find((post) => post.slug === slug)
+ return found ? { ...found } : null
+}
+
+function parseNewsFile(path: string, raw: string): NewsPost | null {
+ if (!raw) {
+ return null
+ }
+
+ const { body, attributes } = extractFrontMatter(raw)
+ const slug = extractSlug(path)
+ const title = attributes.title?.trim() || toTitleFromSlug(slug)
+ const publishedAt = parseDate(attributes.date) || new Date().toISOString()
+ const cleanBody = body.trim()
+ const excerptSource = attributes.summary || attributes.excerpt || attributes.description || ''
+ const excerpt = truncateText(excerptSource || firstParagraph(cleanBody), 220)
+ const html = markdownToHtml(cleanBody)
+ const readingTime = attributes.readingTime || formatReadingTime(cleanBody)
+
+ return {
+ slug,
+ title,
+ excerpt,
+ publishedAt,
+ body: cleanBody,
+ html,
+ readingTime,
+ }
+}
+
+function extractFrontMatter(raw: string): { body: string; attributes: FrontMatter } {
+ const FRONT_MATTER_REGEX = /^---\n([\s\S]*?)\n---\n?/
+ const match = raw.match(FRONT_MATTER_REGEX)
+ if (!match) {
+ return { body: raw, attributes: {} }
+ }
+ const [, frontMatter] = match
+ const rest = raw.slice(match[0].length)
+ return { body: rest, attributes: parseFrontMatter(frontMatter) }
+}
+
+function parseFrontMatter(source: string): FrontMatter {
+ const attributes: Record = {}
+ const lines = source.split(/\r?\n/)
+ for (const line of lines) {
+ const trimmed = line.trim()
+ if (!trimmed || trimmed.startsWith('#')) continue
+ const colonIndex = trimmed.indexOf(':')
+ if (colonIndex === -1) continue
+ const key = trimmed.slice(0, colonIndex).trim()
+ const rawValue = trimmed.slice(colonIndex + 1).trim()
+ const value = rawValue.replace(/^['"]|['"]$/g, '').trim()
+ if (key) {
+ attributes[key] = value
+ }
+ }
+ return attributes
+}
+
+function extractSlug(path: string): string {
+ const normalized = path.replace(/\\/g, '/').split('/')
+ const file = normalized[normalized.length - 1] || ''
+ return file.replace(/\.md$/i, '')
+}
+
+function toTitleFromSlug(slug: string): string {
+ return slug
+ .split('-')
+ .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
+ .join(' ')
+}
+
+function parseDate(value?: string): string | null {
+ if (!value) return null
+ const parsed = new Date(value)
+ if (Number.isNaN(parsed.getTime())) {
+ return null
+ }
+ return parsed.toISOString()
+}
+
+function firstParagraph(body: string): string {
+ const lines = body.split(/\r?\n/)
+ const collected: string[] = []
+ for (const rawLine of lines) {
+ const line = rawLine.trim()
+ if (!line) {
+ if (collected.length) break
+ continue
+ }
+ if (line.startsWith('#') || line.startsWith('- ')) {
+ if (collected.length) break
+ continue
+ }
+ collected.push(line)
+ if (line.endsWith('.')) break
+ }
+ return collected.join(' ')
+}
+
+function truncateText(text: string, maxLength: number): string {
+ const trimmed = text.trim()
+ if (!trimmed) return ''
+ if (trimmed.length <= maxLength) return trimmed
+ return `${trimmed.slice(0, maxLength - 1).trimEnd()}…`
+}
+
+function formatReadingTime(content: string): string {
+ const words = content.split(/\s+/).filter(Boolean)
+ const minutes = Math.max(1, Math.round(words.length / 180))
+ return `${minutes} Min Lesezeit`
+}
+
+function markdownToHtml(markdown: string): string {
+ const lines = markdown.split(/\r?\n/)
+ let html = ''
+ let inList = false
+
+ const closeList = () => {
+ if (inList) {
+ html += ''
+ inList = false
+ }
+ }
+
+ for (const rawLine of lines) {
+ const line = rawLine.trim()
+ if (!line) {
+ closeList()
+ continue
+ }
+ if (line.startsWith('#')) {
+ closeList()
+ const level = Math.min(line.match(/^#+/)?.[0].length ?? 1, 6)
+ const content = line.replace(/^#+/, '').trim()
+ html += `${formatInline(content)}`
+ continue
+ }
+ if (line.startsWith('- ')) {
+ if (!inList) {
+ html += ''
+ inList = true
+ }
+ html += `- ${formatInline(line.slice(2).trim())}
`
+ continue
+ }
+ closeList()
+ html += `${formatInline(line)}
`
+ }
+
+ closeList()
+ return html
+}
+
+function formatInline(value: string): string {
+ let text = escapeHtml(value)
+ text = text.replace(/\[([^\]]+)]\(([^)]+)\)/g, (_match, label, href) => {
+ const url = escapeAttribute(String(href))
+ return `${String(label)}`
+ })
+ text = text.replace(/`([^`]+)`/g, (_match, code) => `${String(code)}`)
+ text = text.replace(/\*\*([^*]+)\*\*/g, '$1')
+ text = text.replace(/\*([^*]+)\*/g, '$1')
+ return text
+}
+
+function escapeHtml(value: string): string {
+ return value.replace(/[&<>"']/g, (char) => {
+ switch (char) {
+ case '&':
+ return '&'
+ case '<':
+ return '<'
+ case '>':
+ return '>'
+ case '"':
+ return '"'
+ case '\'':
+ return '''
+ default:
+ return char
+ }
+ })
+}
+
+function escapeAttribute(value: string): string {
+ return value.replace(/[&<>"']/g, (char) => {
+ switch (char) {
+ case '&':
+ return '&'
+ case '<':
+ return '<'
+ case '>':
+ return '>'
+ case '"':
+ return '"'
+ case '\'':
+ return '''
+ default:
+ return char
+ }
+ })
+}
diff --git a/types/nodemailer.d.ts b/types/nodemailer.d.ts
new file mode 100644
index 0000000..88cd8b0
--- /dev/null
+++ b/types/nodemailer.d.ts
@@ -0,0 +1,5 @@
+declare module 'nodemailer' {
+ const nodemailer: any
+ export function createTransport(options: any): any
+ export default nodemailer
+}