mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-08-11 12:05:33 +08:00
Revise landing messaging and add news feed
This commit is contained in:
60
server/api/service/roadmap-suggestions.post.ts
Normal file
60
server/api/service/roadmap-suggestions.post.ts
Normal file
@@ -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<RoadmapSuggestionBody>(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(),
|
||||
}
|
||||
})
|
||||
52
server/api/service/updates.post.ts
Normal file
52
server/api/service/updates.post.ts
Normal file
@@ -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<UpdatesRequestBody>(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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
25
server/models/RoadmapSuggestion.ts
Normal file
25
server/models/RoadmapSuggestion.ts
Normal file
@@ -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<RoadmapSuggestionDocument>({
|
||||
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<RoadmapSuggestionDocument> | undefined) ||
|
||||
mongoose.model<RoadmapSuggestionDocument>('RoadmapSuggestion', roadmapSuggestionSchema)
|
||||
32
server/models/UpdateSubscriber.ts
Normal file
32
server/models/UpdateSubscriber.ts
Normal file
@@ -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<UpdateSubscriberDocument>({
|
||||
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<UpdateSubscriberDocument> | undefined) ||
|
||||
mongoose.model<UpdateSubscriberDocument>('UpdateSubscriber', updateSubscriberSchema)
|
||||
@@ -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<WaitlistEntryDocument>({
|
||||
@@ -20,6 +22,8 @@ const waitlistSchema = new mongoose.Schema<WaitlistEntryDocument>({
|
||||
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 =
|
||||
|
||||
103
server/utils/notifications.ts
Normal file
103
server/utils/notifications.ts
Normal file
@@ -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 <no-reply@opensquawk.dev>'
|
||||
|
||||
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 <no-reply@opensquawk.dev>'
|
||||
|
||||
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
|
||||
}
|
||||
38
server/utils/subscribers.ts
Normal file
38
server/utils/subscribers.ts
Normal file
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user