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