fix(dev): Vite-API-Proxy, Auth, Stripe-Mails und Backend-Erweiterungen

- Client: API-Basis-URL (joinApiUrl, /v1-Falle), Vite strictPort + Proxy 127.0.0.1, Nicht-JSON-Fehler

- Server: /api-404 ohne Wildcard-Bug, SPA-Fallback, Auth-Middleware, Cron, Mailer, Crypto

- Routen: OAuth-State, Email/Stripe/Analytics; client/.env.example

Made-with: Cursor
This commit is contained in:
2026-04-03 00:23:01 +02:00
parent 61008b63bb
commit ecae89a79d
33 changed files with 1663 additions and 550 deletions

View File

@@ -5,6 +5,7 @@
import express from 'express'
import Stripe from 'stripe'
import { Client, Users } from 'node-appwrite'
import { asyncHandler, ValidationError, NotFoundError } from '../middleware/errorHandler.mjs'
import { validate, rules } from '../middleware/validate.mjs'
import { limiters } from '../middleware/rateLimit.mjs'
@@ -12,13 +13,55 @@ import { respond } from '../utils/response.mjs'
import { subscriptions, submissions } from '../services/database.mjs'
import { config } from '../config/index.mjs'
import { log } from '../middleware/logger.mjs'
import { requireAuth } from '../middleware/auth.mjs'
import { loadEmailTemplate, renderTemplate, sendPlainEmail } from '../utils/mailer.mjs'
const router = express.Router()
async function resolveUserEmail(userId, stripeCustomerId) {
if (userId) {
try {
const c = new Client()
.setEndpoint(config.appwrite.endpoint)
.setProject(config.appwrite.projectId)
.setKey(config.appwrite.apiKey)
const u = await new Users(c).get(userId)
if (u.email) return u.email
} catch (e) {
log.warn('Appwrite Users.get failed', { userId, error: e.message })
}
}
if (stripeCustomerId) {
try {
const cust = await stripe.customers.retrieve(String(stripeCustomerId))
if (cust && !cust.deleted && cust.email) return cust.email
} catch (e) {
log.warn('Stripe customer retrieve failed', { error: e.message })
}
}
return null
}
const stripe = new Stripe(config.stripe.secretKey)
function requireAuthUnlessStripeWebhook(req, res, next) {
if (req.path === '/webhook' && req.method === 'POST') {
return next()
}
return requireAuth(req, res, next)
}
router.use(requireAuthUnlessStripeWebhook)
/**
* Plan configuration
*/
const PLAN_DISPLAY_NAMES = {
basic: 'Basic',
pro: 'Pro',
business: 'Business',
free: 'Free',
}
const PLANS = {
basic: {
name: 'Basic',
@@ -63,12 +106,12 @@ router.post('/checkout',
limiters.auth,
validate({
body: {
userId: [rules.required('userId')],
plan: [rules.required('plan'), rules.isIn('plan', ['basic', 'pro', 'business'])],
},
}),
asyncHandler(async (req, res) => {
const { userId, plan, email } = req.body
const userId = req.appwriteUser.id
const { plan, email } = req.body
const planConfig = PLANS[plan]
if (!planConfig) {
@@ -76,7 +119,7 @@ router.post('/checkout',
}
// Check for existing subscription
const existing = await subscriptions.getByUser(userId)
const existing = await subscriptions.getByUser(userId, req.appwriteUser?.email)
let customerId = existing?.stripeCustomerId
// Create checkout session
@@ -124,31 +167,26 @@ router.post('/checkout',
* Get user's subscription status
*/
router.get('/status', asyncHandler(async (req, res) => {
const { userId } = req.query
const userId = req.appwriteUser.id
if (!userId) {
throw new ValidationError('userId ist erforderlich')
}
const sub = await subscriptions.getByUser(userId)
if (!sub) {
// No subscription - return trial info
return respond.success(res, {
status: 'trial',
plan: 'pro',
features: PLANS.pro.features,
trialEndsAt: null, // Would calculate from user creation date
cancelAtPeriodEnd: false,
})
}
const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
const topKey = config.topSubscriptionPlan
const plan = sub.plan || topKey
const features =
PLANS[plan]?.features ||
PLANS[topKey]?.features ||
PLANS.business.features
respond.success(res, {
status: sub.status,
plan: sub.plan,
features: PLANS[sub.plan]?.features || PLANS.basic.features,
status: sub.status || 'active',
plan,
planDisplayName: PLAN_DISPLAY_NAMES[plan] || PLAN_DISPLAY_NAMES[topKey] || 'Business',
isFreeTier: Boolean(sub.isFreeTier),
emailsUsedThisMonth: sub.emailsUsedThisMonth ?? 0,
emailsLimit: sub.emailsLimit ?? -1,
features,
currentPeriodEnd: sub.currentPeriodEnd,
cancelAtPeriodEnd: sub.cancelAtPeriodEnd || false,
cancelAtPeriodEnd: Boolean(sub.cancelAtPeriodEnd),
})
}))
@@ -157,15 +195,10 @@ router.get('/status', asyncHandler(async (req, res) => {
* Create Stripe Customer Portal session
*/
router.post('/portal',
validate({
body: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => {
const { userId } = req.body
const userId = req.appwriteUser.id
const sub = await subscriptions.getByUser(userId)
const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
if (!sub?.stripeCustomerId) {
throw new NotFoundError('Subscription')
@@ -185,15 +218,10 @@ router.post('/portal',
* Cancel subscription at period end
*/
router.post('/cancel',
validate({
body: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => {
const { userId } = req.body
const userId = req.appwriteUser.id
const sub = await subscriptions.getByUser(userId)
const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
if (!sub?.stripeSubscriptionId) {
throw new NotFoundError('Subscription')
@@ -216,15 +244,10 @@ router.post('/cancel',
* Reactivate cancelled subscription
*/
router.post('/reactivate',
validate({
body: {
userId: [rules.required('userId')],
},
}),
asyncHandler(async (req, res) => {
const { userId } = req.body
const userId = req.appwriteUser.id
const sub = await subscriptions.getByUser(userId)
const sub = await subscriptions.getByUser(userId, req.appwriteUser?.email)
if (!sub?.stripeSubscriptionId) {
throw new NotFoundError('Subscription')
@@ -304,6 +327,29 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(
})
log.info(`Subscription aktualisiert: ${subscription.id}`)
try {
const to = await resolveUserEmail(sub.userId, subscription.customer)
if (to) {
const plan = subscription.metadata?.plan || sub.plan || 'current'
const periodEnd = subscription.current_period_end
? new Date(subscription.current_period_end * 1000).toISOString()
: ''
const tpl = loadEmailTemplate('subscription-updated')
const text = renderTemplate(tpl, {
plan: String(plan),
status: String(subscription.status || ''),
periodEndLine: periodEnd ? `Current period ends: ${periodEnd}` : '',
})
await sendPlainEmail({
to,
subject: 'MailFlow — Subscription updated',
text,
})
}
} catch (e) {
log.warn('subscription.updated email skipped', { error: e.message })
}
}
break
}
@@ -318,6 +364,23 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(
})
log.info(`Subscription gelöscht: ${subscription.id}`)
try {
const to = await resolveUserEmail(sub.userId, subscription.customer)
if (to) {
const tpl = loadEmailTemplate('subscription-ended')
const text = renderTemplate(tpl, {
endedDate: new Date().toISOString(),
})
await sendPlainEmail({
to,
subject: 'MailFlow — Your subscription has ended',
text,
})
}
} catch (e) {
log.warn('subscription.deleted email skipped', { error: e.message })
}
}
break
}
@@ -327,7 +390,27 @@ router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(
log.warn(`Zahlung fehlgeschlagen: ${invoice.id}`, {
customer: invoice.customer,
})
// TODO: Send notification email
try {
let metaUserId
if (invoice.subscription) {
const subStripe = await stripe.subscriptions.retrieve(invoice.subscription)
metaUserId = subStripe.metadata?.userId
}
const to = await resolveUserEmail(metaUserId, invoice.customer)
if (to) {
const tpl = loadEmailTemplate('payment-failed')
const text = renderTemplate(tpl, {
invoiceId: String(invoice.id || ''),
})
await sendPlainEmail({
to,
subject: 'MailFlow — Payment failed, please update billing',
text,
})
}
} catch (e) {
log.warn('invoice.payment_failed email skipped', { error: e.message })
}
break
}