Email Sorter Beta
Ich habe soweit automatisiert the Emails sortieren aber ich muss noch schauen was es fur bugs es gibt wenn die app online ist deswegen wurde ich mit diesen Commit die website veroffentlichen obwohjl es sein konnte das es noch nicht fertig ist und verkaufs bereit
This commit is contained in:
69
server/routes/analytics.mjs
Normal file
69
server/routes/analytics.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Analytics Routes
|
||||
* Track events and conversions for marketing analytics
|
||||
*/
|
||||
|
||||
import express from 'express'
|
||||
import { asyncHandler } from '../middleware/errorHandler.mjs'
|
||||
import { respond } from '../utils/response.mjs'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
/**
|
||||
* POST /api/analytics/track
|
||||
* Track analytics events (page views, conversions, etc.)
|
||||
*
|
||||
* Body:
|
||||
* {
|
||||
* type: 'page_view' | 'signup' | 'trial_start' | 'purchase' | 'email_connected',
|
||||
* userId?: string,
|
||||
* tracking?: {
|
||||
* utm_source?: string,
|
||||
* utm_medium?: string,
|
||||
* utm_campaign?: string,
|
||||
* utm_term?: string,
|
||||
* utm_content?: string,
|
||||
* },
|
||||
* metadata?: Record<string, any>,
|
||||
* timestamp?: string,
|
||||
* page?: string,
|
||||
* referrer?: string,
|
||||
* }
|
||||
*/
|
||||
router.post('/track', asyncHandler(async (req, res) => {
|
||||
const {
|
||||
type,
|
||||
userId,
|
||||
tracking,
|
||||
metadata,
|
||||
timestamp,
|
||||
page,
|
||||
referrer,
|
||||
} = req.body
|
||||
|
||||
// Log analytics event (in production, send to analytics service)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('📊 Analytics Event:', {
|
||||
type,
|
||||
userId,
|
||||
tracking,
|
||||
metadata,
|
||||
timestamp,
|
||||
page,
|
||||
referrer,
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Store in database for analytics dashboard
|
||||
// For now, just log to console
|
||||
// In production, you might want to:
|
||||
// - Store in database
|
||||
// - Send to Google Analytics / Plausible / etc.
|
||||
// - Send to Mixpanel / Amplitude
|
||||
// - Log to external analytics service
|
||||
|
||||
// Return success (client doesn't need to wait)
|
||||
respond.success(res, { received: true })
|
||||
}))
|
||||
|
||||
export default router
|
||||
174
server/routes/api.mjs
Normal file
174
server/routes/api.mjs
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Main API Routes
|
||||
* General API endpoints
|
||||
*/
|
||||
|
||||
import express from 'express'
|
||||
import { asyncHandler, NotFoundError, ValidationError } from '../middleware/errorHandler.mjs'
|
||||
import { validate, schemas, rules } from '../middleware/validate.mjs'
|
||||
import { respond } from '../utils/response.mjs'
|
||||
import { products, questions, submissions, orders } from '../services/database.mjs'
|
||||
import Stripe from 'stripe'
|
||||
import { config } from '../config/index.mjs'
|
||||
|
||||
const router = express.Router()
|
||||
const stripe = new Stripe(config.stripe.secretKey)
|
||||
|
||||
/**
|
||||
* GET /api/products
|
||||
* Get all active products
|
||||
*/
|
||||
router.get('/products', asyncHandler(async (req, res) => {
|
||||
const productList = await products.getActive()
|
||||
respond.success(res, productList)
|
||||
}))
|
||||
|
||||
/**
|
||||
* GET /api/questions
|
||||
* Get questions for a product
|
||||
*/
|
||||
router.get('/questions', asyncHandler(async (req, res) => {
|
||||
const { productSlug } = req.query
|
||||
|
||||
if (!productSlug) {
|
||||
throw new ValidationError('productSlug ist erforderlich', { productSlug: ['Pflichtfeld'] })
|
||||
}
|
||||
|
||||
const product = await products.getBySlug(productSlug)
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundError('Produkt')
|
||||
}
|
||||
|
||||
const questionList = await questions.getByProduct(product.$id)
|
||||
respond.success(res, questionList)
|
||||
}))
|
||||
|
||||
/**
|
||||
* POST /api/submissions
|
||||
* Create a new submission
|
||||
*/
|
||||
router.post('/submissions',
|
||||
validate({
|
||||
body: {
|
||||
productSlug: [rules.required('productSlug')],
|
||||
answers: [rules.required('answers'), rules.isObject('answers')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { productSlug, answers } = req.body
|
||||
|
||||
const product = await products.getBySlug(productSlug)
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundError('Produkt')
|
||||
}
|
||||
|
||||
// Create submission
|
||||
const submission = await submissions.create({
|
||||
productId: product.$id,
|
||||
status: 'draft',
|
||||
customerEmail: answers.email || answers.customer_email || null,
|
||||
customerName: answers.name || answers.customer_name || null,
|
||||
finalSummaryJson: JSON.stringify(answers),
|
||||
priceCents: product.priceCents,
|
||||
currency: product.currency,
|
||||
})
|
||||
|
||||
// Store answers separately
|
||||
await orders.create(submission.$id, { answers })
|
||||
|
||||
respond.created(res, { submissionId: submission.$id })
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/checkout
|
||||
* Create Stripe checkout session for one-time payment
|
||||
*/
|
||||
router.post('/checkout',
|
||||
validate({
|
||||
body: {
|
||||
submissionId: [rules.required('submissionId')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { submissionId } = req.body
|
||||
|
||||
// Get submission
|
||||
const submission = await submissions.create
|
||||
let submissionDoc
|
||||
try {
|
||||
const { db, Collections } = await import('../services/database.mjs')
|
||||
submissionDoc = await db.get(Collections.SUBMISSIONS, submissionId)
|
||||
} catch (error) {
|
||||
throw new NotFoundError('Submission')
|
||||
}
|
||||
|
||||
// Create Stripe checkout session
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: submissionDoc.currency || 'eur',
|
||||
product_data: {
|
||||
name: 'Email Sortierer Service',
|
||||
description: 'Personalisiertes E-Mail-Sortier-Setup',
|
||||
},
|
||||
unit_amount: submissionDoc.priceCents || 4900,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
mode: 'payment',
|
||||
success_url: `${config.frontendUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${config.frontendUrl}/cancel`,
|
||||
metadata: {
|
||||
submissionId,
|
||||
},
|
||||
customer_email: submissionDoc.customerEmail || undefined,
|
||||
})
|
||||
|
||||
respond.success(res, { url: session.url, sessionId: session.id })
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /api/submission/:id
|
||||
* Get submission details
|
||||
*/
|
||||
router.get('/submission/:id', asyncHandler(async (req, res) => {
|
||||
const { id } = req.params
|
||||
|
||||
const { db, Collections } = await import('../services/database.mjs')
|
||||
const submission = await db.get(Collections.SUBMISSIONS, id)
|
||||
|
||||
// Don't expose sensitive data
|
||||
respond.success(res, {
|
||||
id: submission.$id,
|
||||
status: submission.status,
|
||||
createdAt: submission.$createdAt,
|
||||
})
|
||||
}))
|
||||
|
||||
/**
|
||||
* GET /api/config
|
||||
* Get public configuration
|
||||
*/
|
||||
router.get('/config', (req, res) => {
|
||||
respond.success(res, {
|
||||
features: {
|
||||
gmail: Boolean(config.google.clientId),
|
||||
outlook: Boolean(config.microsoft.clientId),
|
||||
ai: Boolean(config.mistral.apiKey),
|
||||
},
|
||||
pricing: {
|
||||
basic: { price: 9, currency: 'EUR', accounts: 1 },
|
||||
pro: { price: 19, currency: 'EUR', accounts: 3 },
|
||||
business: { price: 49, currency: 'EUR', accounts: 10 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
948
server/routes/email.mjs
Normal file
948
server/routes/email.mjs
Normal file
@@ -0,0 +1,948 @@
|
||||
/**
|
||||
* Email Routes
|
||||
* Email account management and sorting
|
||||
*/
|
||||
|
||||
import express from 'express'
|
||||
import { asyncHandler, NotFoundError, AuthorizationError, ValidationError } from '../middleware/errorHandler.mjs'
|
||||
import { validate, rules } from '../middleware/validate.mjs'
|
||||
import { limiters } from '../middleware/rateLimit.mjs'
|
||||
import { respond } from '../utils/response.mjs'
|
||||
import { emailAccounts, emailStats, emailDigests, userPreferences } from '../services/database.mjs'
|
||||
import { config, features } from '../config/index.mjs'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// Lazy load heavy services
|
||||
let gmailServiceClass = null
|
||||
let outlookServiceClass = null
|
||||
let aiSorterInstance = null
|
||||
|
||||
async function getGmailService(accessToken, refreshToken) {
|
||||
if (!gmailServiceClass) {
|
||||
const { GmailService } = await import('../services/gmail.mjs')
|
||||
gmailServiceClass = GmailService
|
||||
}
|
||||
return new gmailServiceClass(accessToken, refreshToken)
|
||||
}
|
||||
|
||||
async function getOutlookService(accessToken) {
|
||||
if (!outlookServiceClass) {
|
||||
const { OutlookService } = await import('../services/outlook.mjs')
|
||||
outlookServiceClass = OutlookService
|
||||
}
|
||||
return new outlookServiceClass(accessToken)
|
||||
}
|
||||
|
||||
async function getAISorter() {
|
||||
if (!aiSorterInstance) {
|
||||
const { AISorterService } = await import('../services/ai-sorter.mjs')
|
||||
aiSorterInstance = new AISorterService()
|
||||
}
|
||||
return aiSorterInstance
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DEMO DATA - Realistic Test Emails
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const DEMO_EMAILS = [
|
||||
{ from: 'boss@company.com', subject: 'Urgent: Meeting tomorrow 9 AM', snippet: 'Hi, we need to discuss the new project urgently...' },
|
||||
{ from: 'billing@amazon.com', subject: 'Your invoice for Order #US123456', snippet: 'Thank you for your order. Please find your invoice attached...' },
|
||||
{ from: 'newsletter@techcrunch.com', subject: 'TechCrunch Weekly: AI Revolution 2026', snippet: 'This week in Tech: OpenAI announces new model...' },
|
||||
{ from: 'noreply@linkedin.com', subject: '5 new connection requests', snippet: 'You have new connection requests from: John Smith, Jane Doe...' },
|
||||
{ from: 'support@stripe.com', subject: 'Payment confirmation: $49.00', snippet: 'Your payment was successfully processed...' },
|
||||
{ from: 'client@acme-corp.com', subject: 'RE: Web development proposal', snippet: 'Thank you for your proposal. We have a few questions...' },
|
||||
{ from: 'noreply@google.com', subject: 'Security alert: New sign-in detected', snippet: 'We detected a new sign-in from Windows Chrome...' },
|
||||
{ from: 'marketing@shopify.com', subject: '50% off today only!', snippet: 'Exclusive offer for our premium customers...' },
|
||||
{ from: 'team@slack.com', subject: 'New message in #general', snippet: '@Max posted a message: Hey team, how\'s it going...' },
|
||||
{ from: 'calendar@gmail.com', subject: 'Invitation: Project meeting (Monday 2 PM)', snippet: 'You have been invited to a meeting...' },
|
||||
{ from: 'john.doe@gmail.com', subject: 'Important: Documents needed by tomorrow', snippet: 'Hi, could you please send the documents by tomorrow morning...' },
|
||||
{ from: 'billing@verizon.com', subject: 'Your phone bill January 2026', snippet: 'Dear customer, your bill is ready for download...' },
|
||||
{ from: 'deals@ebay.com', subject: 'Your Watchlist: Price drops!', snippet: '3 items on your watchlist are now cheaper...' },
|
||||
{ from: 'no-reply@github.com', subject: '[GitHub] Security alert: Dependabot', snippet: 'We found a potential security vulnerability in...' },
|
||||
{ from: 'newsletter@wired.com', subject: 'Wired Weekly: Cloud Computing Trends', snippet: 'The most important tech news of the week...' },
|
||||
{ from: 'support@bank.com', subject: 'Account statement January 2026', snippet: 'Your monthly account statement is ready...' },
|
||||
{ from: 'noreply@twitter.com', subject: '@TechNews replied to your tweet', snippet: 'TechNews: "That\'s an interesting perspective..."' },
|
||||
{ from: 'info@ups.com', subject: 'Your package is on the way!', snippet: 'Tracking number: 1Z999AA10123456784, Expected delivery...' },
|
||||
{ from: 'team@notion.so', subject: 'New comments in your workspace', snippet: 'Anna added a comment to "Project Plan"...' },
|
||||
{ from: 'service@insurance.com', subject: 'Your policy has been updated', snippet: 'Dear customer, we have made changes to your policy...' },
|
||||
]
|
||||
|
||||
/**
|
||||
* POST /api/email/connect
|
||||
* Connect a new email account
|
||||
*/
|
||||
router.post('/connect',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
provider: [rules.required('provider'), rules.isIn('provider', ['gmail', 'outlook', 'demo'])],
|
||||
email: [rules.required('email'), rules.email()],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId, provider, email, accessToken, refreshToken, expiresAt } = req.body
|
||||
|
||||
// Check if account already exists
|
||||
const existingAccounts = await emailAccounts.getByUser(userId)
|
||||
const alreadyConnected = existingAccounts.find(a => a.email === email)
|
||||
|
||||
if (alreadyConnected) {
|
||||
throw new ValidationError('This email account is already connected', {
|
||||
email: ['Already connected'],
|
||||
})
|
||||
}
|
||||
|
||||
// Create account
|
||||
const account = await emailAccounts.create({
|
||||
userId,
|
||||
provider,
|
||||
email,
|
||||
accessToken: accessToken || '',
|
||||
refreshToken: refreshToken || '',
|
||||
expiresAt: expiresAt || 0,
|
||||
isActive: true,
|
||||
lastSync: null,
|
||||
})
|
||||
|
||||
log.success(`Email account connected: ${email} (${provider})`)
|
||||
|
||||
respond.created(res, {
|
||||
accountId: account.$id,
|
||||
email: account.email,
|
||||
provider: account.provider,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/email/connect-demo
|
||||
* Connect a demo email account for testing
|
||||
*/
|
||||
router.post('/connect-demo',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.body
|
||||
const demoEmail = `demo-${userId.slice(0, 8)}@emailsorter.demo`
|
||||
|
||||
// Check if demo account already exists
|
||||
const existingAccounts = await emailAccounts.getByUser(userId)
|
||||
const alreadyConnected = existingAccounts.find(a => a.provider === 'demo')
|
||||
|
||||
if (alreadyConnected) {
|
||||
return respond.success(res, {
|
||||
accountId: alreadyConnected.$id,
|
||||
email: alreadyConnected.email,
|
||||
provider: 'demo',
|
||||
message: 'Demo account already connected',
|
||||
})
|
||||
}
|
||||
|
||||
// Create demo account
|
||||
const account = await emailAccounts.create({
|
||||
userId,
|
||||
provider: 'demo',
|
||||
email: demoEmail,
|
||||
accessToken: 'demo-token',
|
||||
refreshToken: '',
|
||||
expiresAt: 0,
|
||||
isActive: true,
|
||||
lastSync: null,
|
||||
})
|
||||
|
||||
log.success(`Demo account created for user ${userId}`)
|
||||
|
||||
respond.created(res, {
|
||||
accountId: account.$id,
|
||||
email: account.email,
|
||||
provider: 'demo',
|
||||
message: 'Demo account successfully created',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /api/email/accounts
|
||||
* Get user's connected email accounts
|
||||
*/
|
||||
router.get('/accounts', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId is required')
|
||||
}
|
||||
|
||||
const accounts = await emailAccounts.getByUser(userId)
|
||||
|
||||
// Don't expose tokens
|
||||
const safeAccounts = accounts.map(acc => ({
|
||||
id: acc.$id,
|
||||
email: acc.email,
|
||||
provider: acc.provider,
|
||||
connected: true,
|
||||
lastSync: acc.lastSync,
|
||||
isDemo: acc.provider === 'demo',
|
||||
}))
|
||||
|
||||
respond.success(res, safeAccounts)
|
||||
}))
|
||||
|
||||
/**
|
||||
* DELETE /api/email/accounts/:accountId
|
||||
* Disconnect an email account
|
||||
*/
|
||||
router.delete('/accounts/:accountId', asyncHandler(async (req, res) => {
|
||||
const { accountId } = req.params
|
||||
const { userId } = req.query
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId is required')
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
const account = await emailAccounts.get(accountId)
|
||||
|
||||
if (account.userId !== userId) {
|
||||
throw new AuthorizationError('No permission for this account')
|
||||
}
|
||||
|
||||
await emailAccounts.deactivate(accountId)
|
||||
|
||||
log.info(`Email account disconnected: ${account.email}`)
|
||||
|
||||
respond.success(res, null, 'Account successfully disconnected')
|
||||
}))
|
||||
|
||||
/**
|
||||
* GET /api/email/stats
|
||||
* Get email sorting statistics
|
||||
*/
|
||||
router.get('/stats', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId is required')
|
||||
}
|
||||
|
||||
const stats = await emailStats.getByUser(userId)
|
||||
|
||||
if (!stats) {
|
||||
// Return empty stats for new users
|
||||
return respond.success(res, {
|
||||
totalSorted: 0,
|
||||
todaySorted: 0,
|
||||
weekSorted: 0,
|
||||
categories: {},
|
||||
timeSaved: 0,
|
||||
})
|
||||
}
|
||||
|
||||
respond.success(res, {
|
||||
totalSorted: stats.totalSorted || 0,
|
||||
todaySorted: stats.todaySorted || 0,
|
||||
weekSorted: stats.weekSorted || 0,
|
||||
categories: stats.categoriesJson ? JSON.parse(stats.categoriesJson) : {},
|
||||
timeSaved: stats.timeSavedMinutes || 0,
|
||||
})
|
||||
}))
|
||||
|
||||
/**
|
||||
* POST /api/email/sort
|
||||
* Trigger email sorting for an account
|
||||
*
|
||||
* Options:
|
||||
* - maxEmails: Maximum emails to process (default: 500, max: 2000)
|
||||
* - processAll: If true, process entire inbox with pagination
|
||||
*/
|
||||
router.post('/sort',
|
||||
limiters.emailSort,
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
accountId: [rules.required('accountId')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId, accountId, maxEmails = 500, processAll = true } = req.body
|
||||
const effectiveMax = Math.min(maxEmails, 2000) // Cap at 2000 emails
|
||||
|
||||
// Get account
|
||||
const account = await emailAccounts.get(accountId)
|
||||
|
||||
if (account.userId !== userId) {
|
||||
throw new AuthorizationError('No permission for this account')
|
||||
}
|
||||
|
||||
// Get user preferences
|
||||
const prefs = await userPreferences.getByUser(userId)
|
||||
const preferences = prefs?.preferences || {}
|
||||
|
||||
// Get AI sorter
|
||||
const sorter = await getAISorter()
|
||||
let sortedCount = 0
|
||||
const results = { byCategory: {} }
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// DEMO MODE - Sorting with simulated emails
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
if (account.provider === 'demo') {
|
||||
log.info(`Demo sorting started for user ${userId}`)
|
||||
|
||||
// Select random emails from the demo pool
|
||||
const emailCount = Math.min(maxEmails, DEMO_EMAILS.length)
|
||||
const shuffled = [...DEMO_EMAILS].sort(() => Math.random() - 0.5)
|
||||
const emailsToSort = shuffled.slice(0, emailCount)
|
||||
|
||||
// Check if AI is available
|
||||
if (features.ai()) {
|
||||
// Real AI sorting with demo data
|
||||
const classified = await sorter.batchCategorize(emailsToSort, preferences)
|
||||
|
||||
for (const { classification } of classified) {
|
||||
const category = classification.category
|
||||
sortedCount++
|
||||
results.byCategory[category] = (results.byCategory[category] || 0) + 1
|
||||
}
|
||||
|
||||
log.success(`AI sorting completed: ${sortedCount} demo emails`)
|
||||
} else {
|
||||
// Fallback without AI - simulated categorization
|
||||
const categoryDistribution = {
|
||||
vip: 0.1,
|
||||
customers: 0.15,
|
||||
invoices: 0.15,
|
||||
newsletters: 0.2,
|
||||
social: 0.15,
|
||||
promotions: 0.1,
|
||||
security: 0.05,
|
||||
calendar: 0.05,
|
||||
review: 0.05,
|
||||
}
|
||||
|
||||
for (const email of emailsToSort) {
|
||||
// Simple rule-based categorization
|
||||
let category = 'review'
|
||||
const from = email.from.toLowerCase()
|
||||
const subject = email.subject.toLowerCase()
|
||||
|
||||
if (from.includes('boss') || from.includes('john.doe')) {
|
||||
category = 'vip'
|
||||
} else if (from.includes('billing') || subject.includes('invoice') || subject.includes('bill')) {
|
||||
category = 'invoices'
|
||||
} else if (from.includes('newsletter') || subject.includes('weekly') || subject.includes('news')) {
|
||||
category = 'newsletters'
|
||||
} else if (from.includes('linkedin') || from.includes('twitter') || from.includes('slack')) {
|
||||
category = 'social'
|
||||
} else if (from.includes('client') || subject.includes('proposal') || subject.includes('project')) {
|
||||
category = 'customers'
|
||||
} else if (from.includes('security') || subject.includes('security') || subject.includes('sign-in')) {
|
||||
category = 'security'
|
||||
} else if (subject.includes('off') || subject.includes('deal') || from.includes('marketing')) {
|
||||
category = 'promotions'
|
||||
} else if (subject.includes('invitation') || subject.includes('meeting') || subject.includes('calendar')) {
|
||||
category = 'calendar'
|
||||
}
|
||||
|
||||
sortedCount++
|
||||
results.byCategory[category] = (results.byCategory[category] || 0) + 1
|
||||
}
|
||||
|
||||
log.success(`Rule-based sorting completed: ${sortedCount} demo emails`)
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// GMAIL - Real Gmail sorting with native categories
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
else if (account.provider === 'gmail') {
|
||||
if (!features.ai()) {
|
||||
throw new ValidationError('AI sorting is not configured. Please set MISTRAL_API_KEY.')
|
||||
}
|
||||
|
||||
if (!account.accessToken) {
|
||||
throw new ValidationError('Gmail account needs to be reconnected')
|
||||
}
|
||||
|
||||
log.info(`Gmail sorting started for ${account.email}`)
|
||||
|
||||
try {
|
||||
const gmail = await getGmailService(account.accessToken, account.refreshToken)
|
||||
|
||||
// FIRST: Clean up old "EmailSorter/..." labels
|
||||
const deletedLabels = await gmail.cleanupOldLabels()
|
||||
if (deletedLabels > 0) {
|
||||
log.success(`${deletedLabels} old labels cleaned up`)
|
||||
}
|
||||
|
||||
// Create labels only for categories without native Gmail category
|
||||
const categories = sorter.getCategories()
|
||||
const labelMap = {}
|
||||
|
||||
for (const [key, cat] of Object.entries(categories)) {
|
||||
// Wenn Gmail-Kategorie existiert, diese verwenden
|
||||
const gmailCat = sorter.getGmailCategory(key)
|
||||
if (gmailCat) {
|
||||
labelMap[key] = gmailCat // z.B. CATEGORY_SOCIAL
|
||||
} else {
|
||||
// Create custom label (clean name without prefix)
|
||||
try {
|
||||
const label = await gmail.createLabel(cat.name, cat.color)
|
||||
if (label) {
|
||||
labelMap[key] = label.id
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`Failed to create label: ${cat.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch and process ALL emails with pagination
|
||||
let pageToken = null
|
||||
let totalProcessed = 0
|
||||
const batchSize = 100 // Gmail API batch size
|
||||
|
||||
do {
|
||||
// Fetch batch of messages
|
||||
const { messages, nextPageToken } = await gmail.listEmails(
|
||||
batchSize,
|
||||
pageToken,
|
||||
'in:inbox' // All inbox emails
|
||||
)
|
||||
pageToken = nextPageToken
|
||||
|
||||
if (!messages?.length) break
|
||||
|
||||
// Get full email details
|
||||
const emails = await gmail.batchGetEmails(messages.map(m => m.id))
|
||||
|
||||
// Classify emails with AI
|
||||
const classified = await sorter.batchCategorize(
|
||||
emails.map(e => ({
|
||||
from: e.headers?.from || '',
|
||||
subject: e.headers?.subject || '',
|
||||
snippet: e.snippet || '',
|
||||
})),
|
||||
preferences
|
||||
)
|
||||
|
||||
// Apply labels/categories and actions
|
||||
for (let i = 0; i < classified.length; i++) {
|
||||
const email = emails[i]
|
||||
const { category } = classified[i].classification
|
||||
const action = sorter.getCategoryAction(category)
|
||||
|
||||
try {
|
||||
const labelsToAdd = []
|
||||
const labelsToRemove = []
|
||||
|
||||
// Add label/category
|
||||
if (labelMap[category]) {
|
||||
labelsToAdd.push(labelMap[category])
|
||||
}
|
||||
|
||||
// Handle different actions
|
||||
switch (action) {
|
||||
case 'star':
|
||||
// Keep in inbox + add star
|
||||
labelsToAdd.push('STARRED')
|
||||
break
|
||||
|
||||
case 'archive_read':
|
||||
// Archive + mark as read (clean inbox)
|
||||
labelsToRemove.push('INBOX', 'UNREAD')
|
||||
break
|
||||
|
||||
case 'archive':
|
||||
// Just archive (legacy)
|
||||
labelsToRemove.push('INBOX')
|
||||
break
|
||||
|
||||
case 'inbox':
|
||||
default:
|
||||
// Keep in inbox, no changes needed
|
||||
break
|
||||
}
|
||||
|
||||
// Apply label changes
|
||||
if (labelsToAdd.length > 0) {
|
||||
await gmail.addLabels(email.id, labelsToAdd)
|
||||
}
|
||||
if (labelsToRemove.length > 0) {
|
||||
await gmail.removeLabels(email.id, labelsToRemove)
|
||||
}
|
||||
|
||||
sortedCount++
|
||||
results.byCategory[category] = (results.byCategory[category] || 0) + 1
|
||||
} catch (err) {
|
||||
log.warn(`Email sorting failed: ${email.id}`, { error: err.message })
|
||||
}
|
||||
}
|
||||
|
||||
totalProcessed += emails.length
|
||||
log.info(`Processed ${totalProcessed} emails so far...`)
|
||||
|
||||
// Stop if we've hit the limit or there's no more pages
|
||||
if (totalProcessed >= effectiveMax) {
|
||||
log.info(`Reached limit of ${effectiveMax} emails`)
|
||||
break
|
||||
}
|
||||
|
||||
// Small delay between batches to avoid rate limiting
|
||||
if (pageToken) {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
}
|
||||
} while (pageToken && processAll)
|
||||
|
||||
log.success(`Gmail sorting completed: ${sortedCount} emails processed`)
|
||||
} catch (err) {
|
||||
log.error('Gmail sorting failed', { error: err.message })
|
||||
throw new ValidationError(`Gmail error: ${err.message}. Please reconnect account.`)
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// OUTLOOK - Real Outlook sorting
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
else if (account.provider === 'outlook') {
|
||||
if (!features.ai()) {
|
||||
throw new ValidationError('AI sorting is not configured. Please set MISTRAL_API_KEY.')
|
||||
}
|
||||
|
||||
if (!account.accessToken) {
|
||||
throw new ValidationError('Outlook account needs to be reconnected')
|
||||
}
|
||||
|
||||
log.info(`Outlook sorting started for ${account.email}`)
|
||||
|
||||
try {
|
||||
const outlook = await getOutlookService(account.accessToken)
|
||||
|
||||
// Fetch and process ALL emails with pagination
|
||||
let skipToken = null
|
||||
let totalProcessed = 0
|
||||
const batchSize = 100
|
||||
|
||||
do {
|
||||
// Fetch batch of messages
|
||||
const { messages, nextLink } = await outlook.listEmails(batchSize, skipToken)
|
||||
skipToken = nextLink
|
||||
|
||||
if (!messages?.length) break
|
||||
|
||||
// Classify emails with AI
|
||||
const classified = await sorter.batchCategorize(
|
||||
messages.map(e => ({
|
||||
from: e.from?.emailAddress?.address || '',
|
||||
subject: e.subject || '',
|
||||
snippet: e.bodyPreview || '',
|
||||
})),
|
||||
preferences
|
||||
)
|
||||
|
||||
// Apply categories and actions
|
||||
for (let i = 0; i < classified.length; i++) {
|
||||
const email = messages[i]
|
||||
const { category } = classified[i].classification
|
||||
const action = sorter.getCategoryAction(category)
|
||||
const catName = sorter.getLabelName(category)
|
||||
|
||||
try {
|
||||
await outlook.addCategories(email.id, [catName])
|
||||
|
||||
// Handle different actions
|
||||
switch (action) {
|
||||
case 'archive_read':
|
||||
// Archive + mark as read
|
||||
await outlook.archiveEmail(email.id)
|
||||
await outlook.markAsRead(email.id)
|
||||
break
|
||||
|
||||
case 'archive':
|
||||
await outlook.archiveEmail(email.id)
|
||||
break
|
||||
|
||||
case 'star':
|
||||
await outlook.flagEmail(email.id)
|
||||
break
|
||||
}
|
||||
|
||||
sortedCount++
|
||||
results.byCategory[category] = (results.byCategory[category] || 0) + 1
|
||||
} catch (err) {
|
||||
log.warn(`Email sorting failed: ${email.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
totalProcessed += messages.length
|
||||
log.info(`Processed ${totalProcessed} emails so far...`)
|
||||
|
||||
// Stop if we've hit the limit
|
||||
if (totalProcessed >= effectiveMax) {
|
||||
log.info(`Reached limit of ${effectiveMax} emails`)
|
||||
break
|
||||
}
|
||||
|
||||
// Small delay between batches
|
||||
if (skipToken) {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
}
|
||||
} while (skipToken && processAll)
|
||||
|
||||
log.success(`Outlook sorting completed: ${sortedCount} emails processed`)
|
||||
} catch (err) {
|
||||
log.error('Outlook sorting failed', { error: err.message })
|
||||
throw new ValidationError(`Outlook error: ${err.message}. Please reconnect account.`)
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync
|
||||
await emailAccounts.updateLastSync(accountId)
|
||||
|
||||
// Update stats
|
||||
const timeSaved = Math.round(sortedCount * 0.25) // 15 seconds per email
|
||||
await emailStats.increment(userId, {
|
||||
total: sortedCount,
|
||||
today: sortedCount,
|
||||
week: sortedCount,
|
||||
timeSaved,
|
||||
})
|
||||
|
||||
// Update categories in stats
|
||||
try {
|
||||
const currentStats = await emailStats.getByUser(userId)
|
||||
if (currentStats) {
|
||||
const existingCategories = currentStats.categoriesJson
|
||||
? JSON.parse(currentStats.categoriesJson)
|
||||
: {}
|
||||
|
||||
for (const [cat, count] of Object.entries(results.byCategory)) {
|
||||
existingCategories[cat] = (existingCategories[cat] || 0) + count
|
||||
}
|
||||
|
||||
await emailStats.updateCategories(userId, existingCategories)
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Category update failed', { error: err.message })
|
||||
}
|
||||
|
||||
// Calculate inbox cleared (archived emails)
|
||||
const archivedCategories = ['newsletters', 'promotions', 'social']
|
||||
const inboxCleared = archivedCategories.reduce((sum, cat) =>
|
||||
sum + (results.byCategory[cat] || 0), 0
|
||||
)
|
||||
|
||||
// Generate digest highlights (important emails)
|
||||
const highlights = []
|
||||
if (results.byCategory.vip > 0) {
|
||||
highlights.push({ type: 'vip', count: results.byCategory.vip, message: `${results.byCategory.vip} important emails need attention` })
|
||||
}
|
||||
if (results.byCategory.security > 0) {
|
||||
highlights.push({ type: 'security', count: results.byCategory.security, message: `${results.byCategory.security} security notifications` })
|
||||
}
|
||||
if (results.byCategory.invoices > 0) {
|
||||
highlights.push({ type: 'invoices', count: results.byCategory.invoices, message: `${results.byCategory.invoices} invoices received` })
|
||||
}
|
||||
|
||||
// Generate suggestions (potential unsubscribes)
|
||||
const suggestions = []
|
||||
if (results.byCategory.newsletters > 5) {
|
||||
suggestions.push({ type: 'unsubscribe', message: 'Consider unsubscribing from some newsletters to reduce inbox clutter' })
|
||||
}
|
||||
if (results.byCategory.promotions > 10) {
|
||||
suggestions.push({ type: 'unsubscribe', message: 'High volume of promotional emails - review subscriptions' })
|
||||
}
|
||||
|
||||
// Update daily digest
|
||||
try {
|
||||
await emailDigests.updateToday(userId, {
|
||||
stats: results.byCategory,
|
||||
highlights,
|
||||
suggestions,
|
||||
totalSorted: sortedCount,
|
||||
inboxCleared,
|
||||
timeSavedMinutes: timeSaved,
|
||||
})
|
||||
} catch (err) {
|
||||
log.warn('Digest update failed', { error: err.message })
|
||||
}
|
||||
|
||||
log.success(`Sorting completed: ${sortedCount} emails for ${account.email}`)
|
||||
|
||||
respond.success(res, {
|
||||
sorted: sortedCount,
|
||||
inboxCleared,
|
||||
categories: results.byCategory,
|
||||
timeSaved: {
|
||||
minutes: timeSaved,
|
||||
formatted: timeSaved > 0 ? `${timeSaved} minutes` : '< 1 minute',
|
||||
},
|
||||
highlights,
|
||||
suggestions,
|
||||
provider: account.provider,
|
||||
isDemo: account.provider === 'demo',
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/email/sort-demo
|
||||
* Quick demo sorting without account (for testing)
|
||||
*/
|
||||
router.post('/sort-demo', asyncHandler(async (req, res) => {
|
||||
const { count = 10 } = req.body
|
||||
|
||||
log.info(`Quick demo sorting: ${count} emails`)
|
||||
|
||||
// Get AI sorter
|
||||
const sorter = await getAISorter()
|
||||
const results = { byCategory: {} }
|
||||
|
||||
// Select random emails
|
||||
const emailCount = Math.min(count, DEMO_EMAILS.length)
|
||||
const shuffled = [...DEMO_EMAILS].sort(() => Math.random() - 0.5)
|
||||
const emailsToSort = shuffled.slice(0, emailCount)
|
||||
|
||||
if (features.ai()) {
|
||||
// Real AI sorting
|
||||
const classified = await sorter.batchCategorize(emailsToSort, {})
|
||||
|
||||
const sortedEmails = emailsToSort.map((email, i) => ({
|
||||
...email,
|
||||
category: classified[i].classification.category,
|
||||
categoryName: sorter.getLabelName(classified[i].classification.category),
|
||||
confidence: classified[i].classification.confidence,
|
||||
reason: classified[i].classification.reason,
|
||||
}))
|
||||
|
||||
for (const email of sortedEmails) {
|
||||
results.byCategory[email.category] = (results.byCategory[email.category] || 0) + 1
|
||||
}
|
||||
|
||||
respond.success(res, {
|
||||
sorted: emailCount,
|
||||
emails: sortedEmails,
|
||||
categories: results.byCategory,
|
||||
aiEnabled: true,
|
||||
})
|
||||
} else {
|
||||
// Rule-based sorting
|
||||
const sortedEmails = emailsToSort.map(email => {
|
||||
let category = 'review'
|
||||
const from = email.from.toLowerCase()
|
||||
const subject = email.subject.toLowerCase()
|
||||
|
||||
if (from.includes('boss') || from.includes('john.doe')) {
|
||||
category = 'vip'
|
||||
} else if (from.includes('billing') || subject.includes('invoice')) {
|
||||
category = 'invoices'
|
||||
} else if (from.includes('newsletter') || subject.includes('weekly')) {
|
||||
category = 'newsletters'
|
||||
} else if (from.includes('linkedin') || from.includes('twitter')) {
|
||||
category = 'social'
|
||||
} else if (from.includes('client') || subject.includes('proposal')) {
|
||||
category = 'customers'
|
||||
} else if (subject.includes('security') || subject.includes('sign-in')) {
|
||||
category = 'security'
|
||||
} else if (subject.includes('off') || from.includes('marketing')) {
|
||||
category = 'promotions'
|
||||
} else if (subject.includes('invitation') || subject.includes('meeting')) {
|
||||
category = 'calendar'
|
||||
}
|
||||
|
||||
results.byCategory[category] = (results.byCategory[category] || 0) + 1
|
||||
|
||||
return {
|
||||
...email,
|
||||
category,
|
||||
categoryName: sorter.getLabelName(category),
|
||||
}
|
||||
})
|
||||
|
||||
respond.success(res, {
|
||||
sorted: emailCount,
|
||||
emails: sortedEmails,
|
||||
categories: results.byCategory,
|
||||
aiEnabled: false,
|
||||
message: 'AI not configured - rule-based sorting used',
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
/**
|
||||
* POST /api/email/cleanup
|
||||
* Cleanup old EmailSorter labels from Gmail
|
||||
*/
|
||||
router.post('/cleanup',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
accountId: [rules.required('accountId')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId, accountId } = req.body
|
||||
|
||||
const account = await emailAccounts.get(accountId)
|
||||
|
||||
if (account.userId !== userId) {
|
||||
throw new AuthorizationError('No permission for this account')
|
||||
}
|
||||
|
||||
if (account.provider !== 'gmail') {
|
||||
return respond.success(res, { deleted: 0, message: 'Cleanup only available for Gmail' })
|
||||
}
|
||||
|
||||
log.info(`Label cleanup started for ${account.email}`)
|
||||
|
||||
const gmail = await getGmailService(account.accessToken, account.refreshToken)
|
||||
const deleted = await gmail.cleanupOldLabels()
|
||||
|
||||
log.success(`${deleted} old labels deleted`)
|
||||
|
||||
respond.success(res, {
|
||||
deleted,
|
||||
message: deleted > 0
|
||||
? `${deleted} old "EmailSorter/..." labels were deleted`
|
||||
: 'No old labels found'
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /api/email/digest
|
||||
* Get today's sorting digest summary
|
||||
*/
|
||||
router.get('/digest', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId is required')
|
||||
}
|
||||
|
||||
const digest = await emailDigests.getByUserToday(userId)
|
||||
|
||||
if (!digest) {
|
||||
// Return empty digest for new users
|
||||
return respond.success(res, {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
totalSorted: 0,
|
||||
inboxCleared: 0,
|
||||
timeSavedMinutes: 0,
|
||||
stats: {},
|
||||
highlights: [],
|
||||
suggestions: [],
|
||||
hasData: false,
|
||||
})
|
||||
}
|
||||
|
||||
respond.success(res, {
|
||||
date: digest.date,
|
||||
totalSorted: digest.totalSorted,
|
||||
inboxCleared: digest.inboxCleared,
|
||||
timeSavedMinutes: digest.timeSavedMinutes,
|
||||
stats: digest.stats,
|
||||
highlights: digest.highlights,
|
||||
suggestions: digest.suggestions,
|
||||
hasData: true,
|
||||
})
|
||||
}))
|
||||
|
||||
/**
|
||||
* GET /api/email/digest/history
|
||||
* Get digest history for the last N days
|
||||
*/
|
||||
router.get('/digest/history', asyncHandler(async (req, res) => {
|
||||
const { userId, days = 7 } = req.query
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId is required')
|
||||
}
|
||||
|
||||
const digests = await emailDigests.getByUserRecent(userId, parseInt(days))
|
||||
|
||||
// Calculate totals
|
||||
const totals = {
|
||||
totalSorted: digests.reduce((sum, d) => sum + (d.totalSorted || 0), 0),
|
||||
inboxCleared: digests.reduce((sum, d) => sum + (d.inboxCleared || 0), 0),
|
||||
timeSavedMinutes: digests.reduce((sum, d) => sum + (d.timeSavedMinutes || 0), 0),
|
||||
}
|
||||
|
||||
respond.success(res, {
|
||||
days: parseInt(days),
|
||||
digests: digests.map(d => ({
|
||||
date: d.date,
|
||||
totalSorted: d.totalSorted,
|
||||
inboxCleared: d.inboxCleared,
|
||||
timeSavedMinutes: d.timeSavedMinutes,
|
||||
stats: d.stats,
|
||||
})),
|
||||
totals,
|
||||
})
|
||||
}))
|
||||
|
||||
/**
|
||||
* GET /api/email/categories
|
||||
* Get all available categories
|
||||
*/
|
||||
router.get('/categories', asyncHandler(async (req, res) => {
|
||||
const sorter = await getAISorter()
|
||||
const categories = sorter.getCategories()
|
||||
|
||||
const formattedCategories = Object.entries(categories).map(([key, cat]) => ({
|
||||
id: key,
|
||||
name: cat.name,
|
||||
description: cat.description,
|
||||
color: cat.color,
|
||||
action: cat.action,
|
||||
priority: cat.priority,
|
||||
}))
|
||||
|
||||
respond.success(res, formattedCategories)
|
||||
}))
|
||||
|
||||
/**
|
||||
* POST /api/email/webhook/gmail
|
||||
* Gmail push notification webhook
|
||||
*/
|
||||
router.post('/webhook/gmail', asyncHandler(async (req, res) => {
|
||||
// Acknowledge immediately
|
||||
res.status(200).send()
|
||||
|
||||
const { message } = req.body
|
||||
if (!message?.data) return
|
||||
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(message.data, 'base64').toString())
|
||||
log.info(`Gmail Webhook: ${data.emailAddress}`, { historyId: data.historyId })
|
||||
// Queue processing in production
|
||||
} catch (err) {
|
||||
log.error('Gmail Webhook Error', { error: err.message })
|
||||
}
|
||||
}))
|
||||
|
||||
/**
|
||||
* POST /api/email/webhook/outlook
|
||||
* Microsoft Graph webhook
|
||||
*/
|
||||
router.post('/webhook/outlook', asyncHandler(async (req, res) => {
|
||||
// Handle validation request
|
||||
if (req.query.validationToken) {
|
||||
return res.status(200).send(req.query.validationToken)
|
||||
}
|
||||
|
||||
res.status(202).send()
|
||||
|
||||
const { value } = req.body
|
||||
if (!value?.length) return
|
||||
|
||||
for (const notification of value) {
|
||||
log.info(`Outlook Webhook: ${notification.changeType}`, { resource: notification.resource })
|
||||
// Queue processing in production
|
||||
}
|
||||
}))
|
||||
|
||||
export default router
|
||||
388
server/routes/oauth.mjs
Normal file
388
server/routes/oauth.mjs
Normal file
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* OAuth Routes
|
||||
* Gmail and Outlook OAuth2 authentication
|
||||
*/
|
||||
|
||||
import express from 'express'
|
||||
import { OAuth2Client } from 'google-auth-library'
|
||||
import { ConfidentialClientApplication } from '@azure/msal-node'
|
||||
import { asyncHandler, ValidationError, AppError } from '../middleware/errorHandler.mjs'
|
||||
import { respond } from '../utils/response.mjs'
|
||||
import { emailAccounts } from '../services/database.mjs'
|
||||
import { config, features } from '../config/index.mjs'
|
||||
import { log } from '../middleware/logger.mjs'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// Google OAuth client (lazy initialization)
|
||||
let googleClient = null
|
||||
|
||||
function getGoogleClient() {
|
||||
if (!googleClient && features.gmail()) {
|
||||
googleClient = new OAuth2Client(
|
||||
config.google.clientId,
|
||||
config.google.clientSecret,
|
||||
config.google.redirectUri
|
||||
)
|
||||
}
|
||||
return googleClient
|
||||
}
|
||||
|
||||
// Microsoft OAuth client (lazy initialization)
|
||||
let msalClient = null
|
||||
|
||||
function getMsalClient() {
|
||||
if (!msalClient && features.outlook()) {
|
||||
msalClient = new ConfidentialClientApplication({
|
||||
auth: {
|
||||
clientId: config.microsoft.clientId,
|
||||
clientSecret: config.microsoft.clientSecret,
|
||||
authority: 'https://login.microsoftonline.com/common',
|
||||
},
|
||||
})
|
||||
}
|
||||
return msalClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Gmail OAuth scopes
|
||||
*/
|
||||
const GMAIL_SCOPES = [
|
||||
'https://www.googleapis.com/auth/gmail.modify',
|
||||
'https://www.googleapis.com/auth/gmail.labels',
|
||||
'https://www.googleapis.com/auth/userinfo.email',
|
||||
]
|
||||
|
||||
/**
|
||||
* Microsoft Graph scopes
|
||||
*/
|
||||
const OUTLOOK_SCOPES = [
|
||||
'Mail.ReadWrite',
|
||||
'User.Read',
|
||||
'offline_access',
|
||||
]
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// GMAIL OAUTH
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/oauth/gmail/connect
|
||||
* Initiate Gmail OAuth flow
|
||||
*/
|
||||
router.get('/gmail/connect', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId ist erforderlich')
|
||||
}
|
||||
|
||||
if (!features.gmail()) {
|
||||
throw new AppError('Gmail OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED')
|
||||
}
|
||||
|
||||
const client = getGoogleClient()
|
||||
const authUrl = client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: GMAIL_SCOPES,
|
||||
prompt: 'consent',
|
||||
state: JSON.stringify({ userId }),
|
||||
include_granted_scopes: true,
|
||||
})
|
||||
|
||||
respond.success(res, { url: authUrl })
|
||||
}))
|
||||
|
||||
/**
|
||||
* GET /api/oauth/gmail/callback
|
||||
* Gmail OAuth callback
|
||||
*/
|
||||
router.get('/gmail/callback', asyncHandler(async (req, res) => {
|
||||
const { code, state, error, error_description } = req.query
|
||||
|
||||
log.info('Gmail OAuth Callback erhalten', {
|
||||
hasCode: !!code,
|
||||
hasState: !!state,
|
||||
error: error || 'none'
|
||||
})
|
||||
|
||||
if (error) {
|
||||
log.warn('Gmail OAuth abgelehnt', { error, error_description })
|
||||
return res.redirect(`${config.frontendUrl}/settings?error=oauth_denied&message=${encodeURIComponent(error_description || error)}`)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
log.error('Gmail OAuth: Code oder State fehlt')
|
||||
return res.redirect(`${config.frontendUrl}/settings?error=missing_params`)
|
||||
}
|
||||
|
||||
let userId
|
||||
try {
|
||||
const stateData = JSON.parse(state)
|
||||
userId = stateData.userId
|
||||
} catch (e) {
|
||||
log.error('Gmail OAuth: State konnte nicht geparst werden', { state })
|
||||
return res.redirect(`${config.frontendUrl}/settings?error=invalid_state`)
|
||||
}
|
||||
|
||||
// Create a FRESH OAuth client for this request
|
||||
const client = new OAuth2Client(
|
||||
config.google.clientId,
|
||||
config.google.clientSecret,
|
||||
config.google.redirectUri
|
||||
)
|
||||
|
||||
try {
|
||||
log.info('Gmail OAuth: Tausche Code gegen Token...')
|
||||
|
||||
// Exchange code for tokens
|
||||
const { tokens } = await client.getToken(code)
|
||||
|
||||
log.info('Gmail OAuth: Token erhalten', {
|
||||
hasAccessToken: !!tokens.access_token,
|
||||
hasRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expiry_date
|
||||
})
|
||||
|
||||
client.setCredentials(tokens)
|
||||
|
||||
// Get user email
|
||||
const { google } = await import('googleapis')
|
||||
const oauth2 = google.oauth2({ version: 'v2', auth: client })
|
||||
const { data: userInfo } = await oauth2.userinfo.get()
|
||||
|
||||
log.info('Gmail OAuth: User Info erhalten', { email: userInfo.email })
|
||||
|
||||
// Check if account already exists
|
||||
const existingAccounts = await emailAccounts.getByUser(userId)
|
||||
const alreadyConnected = existingAccounts.find(a => a.email === userInfo.email)
|
||||
|
||||
if (alreadyConnected) {
|
||||
// Update existing account with new tokens
|
||||
const { db, Collections } = await import('../services/database.mjs')
|
||||
await db.update(Collections.EMAIL_ACCOUNTS, alreadyConnected.$id, {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || alreadyConnected.refreshToken,
|
||||
expiresAt: tokens.expiry_date || 0,
|
||||
isActive: true,
|
||||
})
|
||||
log.success(`Gmail aktualisiert: ${userInfo.email}`)
|
||||
} else {
|
||||
// Save new account to database
|
||||
await emailAccounts.create({
|
||||
userId,
|
||||
provider: 'gmail',
|
||||
email: userInfo.email,
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || '',
|
||||
expiresAt: tokens.expiry_date || 0,
|
||||
isActive: true,
|
||||
})
|
||||
log.success(`Gmail verbunden: ${userInfo.email}`)
|
||||
}
|
||||
|
||||
res.redirect(`${config.frontendUrl}/settings?gmail=connected&email=${encodeURIComponent(userInfo.email)}`)
|
||||
} catch (tokenError) {
|
||||
log.error('Gmail OAuth Token-Fehler', {
|
||||
error: tokenError.message,
|
||||
code: tokenError.code,
|
||||
response: tokenError.response?.data
|
||||
})
|
||||
|
||||
// Provide more specific error messages
|
||||
let errorMessage = 'token_error'
|
||||
if (tokenError.message.includes('invalid_grant')) {
|
||||
errorMessage = 'invalid_grant'
|
||||
} else if (tokenError.message.includes('invalid_client')) {
|
||||
errorMessage = 'invalid_client'
|
||||
}
|
||||
|
||||
res.redirect(`${config.frontendUrl}/settings?error=${errorMessage}&details=${encodeURIComponent(tokenError.message)}`)
|
||||
}
|
||||
}))
|
||||
|
||||
/**
|
||||
* POST /api/oauth/gmail/refresh
|
||||
* Refresh Gmail access token
|
||||
*/
|
||||
router.post('/gmail/refresh', asyncHandler(async (req, res) => {
|
||||
const { accountId } = req.body
|
||||
|
||||
if (!accountId) {
|
||||
throw new ValidationError('accountId ist erforderlich')
|
||||
}
|
||||
|
||||
const account = await emailAccounts.get(accountId)
|
||||
|
||||
if (account.provider !== 'gmail') {
|
||||
throw new ValidationError('Kein Gmail-Konto')
|
||||
}
|
||||
|
||||
if (!account.refreshToken) {
|
||||
throw new AppError('Kein Refresh Token verfügbar. Konto erneut verbinden.', 400, 'NO_REFRESH_TOKEN')
|
||||
}
|
||||
|
||||
const client = getGoogleClient()
|
||||
client.setCredentials({ refresh_token: account.refreshToken })
|
||||
|
||||
const { credentials } = await client.refreshAccessToken()
|
||||
|
||||
// Update tokens
|
||||
const { db, Collections } = await import('../services/database.mjs')
|
||||
await db.update(Collections.EMAIL_ACCOUNTS, accountId, {
|
||||
accessToken: credentials.access_token,
|
||||
expiresAt: credentials.expiry_date || 0,
|
||||
})
|
||||
|
||||
respond.success(res, {
|
||||
accessToken: credentials.access_token,
|
||||
expiresAt: credentials.expiry_date,
|
||||
})
|
||||
}))
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// OUTLOOK OAUTH
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/oauth/outlook/connect
|
||||
* Initiate Outlook OAuth flow
|
||||
*/
|
||||
router.get('/outlook/connect', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
|
||||
if (!userId) {
|
||||
throw new ValidationError('userId ist erforderlich')
|
||||
}
|
||||
|
||||
if (!features.outlook()) {
|
||||
throw new AppError('Outlook OAuth ist nicht konfiguriert', 503, 'FEATURE_DISABLED')
|
||||
}
|
||||
|
||||
const client = getMsalClient()
|
||||
const authUrl = await client.getAuthCodeUrl({
|
||||
scopes: OUTLOOK_SCOPES,
|
||||
redirectUri: config.microsoft.redirectUri,
|
||||
state: JSON.stringify({ userId }),
|
||||
prompt: 'select_account',
|
||||
})
|
||||
|
||||
respond.success(res, { url: authUrl })
|
||||
}))
|
||||
|
||||
/**
|
||||
* GET /api/oauth/outlook/callback
|
||||
* Outlook OAuth callback
|
||||
*/
|
||||
router.get('/outlook/callback', asyncHandler(async (req, res) => {
|
||||
const { code, state, error, error_description } = req.query
|
||||
|
||||
if (error) {
|
||||
log.warn('Outlook OAuth abgelehnt', { error, error_description })
|
||||
return respond.redirect(res, `${config.frontendUrl}/settings?error=oauth_denied`)
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
throw new ValidationError('Code und State sind erforderlich')
|
||||
}
|
||||
|
||||
const { userId } = JSON.parse(state)
|
||||
const client = getMsalClient()
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokenResponse = await client.acquireTokenByCode({
|
||||
code,
|
||||
scopes: OUTLOOK_SCOPES,
|
||||
redirectUri: config.microsoft.redirectUri,
|
||||
})
|
||||
|
||||
// Get user email from Graph API
|
||||
const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenResponse.accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
const userInfo = await graphResponse.json()
|
||||
|
||||
// Save to database
|
||||
await emailAccounts.create({
|
||||
userId,
|
||||
provider: 'outlook',
|
||||
email: userInfo.mail || userInfo.userPrincipalName,
|
||||
accessToken: tokenResponse.accessToken,
|
||||
refreshToken: '', // MSAL handles token caching differently
|
||||
expiresAt: tokenResponse.expiresOn ? new Date(tokenResponse.expiresOn).getTime() : 0,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
log.success(`Outlook verbunden: ${userInfo.mail || userInfo.userPrincipalName}`)
|
||||
|
||||
respond.redirect(res, `${config.frontendUrl}/settings?outlook=connected`)
|
||||
}))
|
||||
|
||||
/**
|
||||
* POST /api/oauth/outlook/refresh
|
||||
* Refresh Outlook access token
|
||||
*/
|
||||
router.post('/outlook/refresh', asyncHandler(async (req, res) => {
|
||||
const { accountId } = req.body
|
||||
|
||||
if (!accountId) {
|
||||
throw new ValidationError('accountId ist erforderlich')
|
||||
}
|
||||
|
||||
const account = await emailAccounts.get(accountId)
|
||||
|
||||
if (account.provider !== 'outlook') {
|
||||
throw new ValidationError('Kein Outlook-Konto')
|
||||
}
|
||||
|
||||
const client = getMsalClient()
|
||||
|
||||
// MSAL handles token refresh silently via cache
|
||||
const tokenResponse = await client.acquireTokenSilent({
|
||||
scopes: OUTLOOK_SCOPES,
|
||||
account: {
|
||||
homeAccountId: account.email,
|
||||
},
|
||||
}).catch(async () => {
|
||||
// If silent refresh fails, need to re-authenticate
|
||||
throw new AppError('Token abgelaufen. Konto erneut verbinden.', 401, 'TOKEN_EXPIRED')
|
||||
})
|
||||
|
||||
// Update tokens
|
||||
const { db, Collections } = await import('../services/database.mjs')
|
||||
await db.update(Collections.EMAIL_ACCOUNTS, accountId, {
|
||||
accessToken: tokenResponse.accessToken,
|
||||
expiresAt: tokenResponse.expiresOn ? new Date(tokenResponse.expiresOn).getTime() : 0,
|
||||
})
|
||||
|
||||
respond.success(res, {
|
||||
accessToken: tokenResponse.accessToken,
|
||||
expiresAt: tokenResponse.expiresOn,
|
||||
})
|
||||
}))
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// STATUS CHECK
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/oauth/status
|
||||
* Check which OAuth providers are configured
|
||||
*/
|
||||
router.get('/status', (req, res) => {
|
||||
respond.success(res, {
|
||||
gmail: {
|
||||
enabled: features.gmail(),
|
||||
scopes: features.gmail() ? GMAIL_SCOPES : [],
|
||||
},
|
||||
outlook: {
|
||||
enabled: features.outlook(),
|
||||
scopes: features.outlook() ? OUTLOOK_SCOPES : [],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
351
server/routes/stripe.mjs
Normal file
351
server/routes/stripe.mjs
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Stripe Routes
|
||||
* Payment and subscription management
|
||||
*/
|
||||
|
||||
import express from 'express'
|
||||
import Stripe from 'stripe'
|
||||
import { asyncHandler, ValidationError, NotFoundError } from '../middleware/errorHandler.mjs'
|
||||
import { validate, rules } from '../middleware/validate.mjs'
|
||||
import { limiters } from '../middleware/rateLimit.mjs'
|
||||
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'
|
||||
|
||||
const router = express.Router()
|
||||
const stripe = new Stripe(config.stripe.secretKey)
|
||||
|
||||
/**
|
||||
* Plan configuration
|
||||
*/
|
||||
const PLANS = {
|
||||
basic: {
|
||||
name: 'Basic',
|
||||
priceId: config.stripe.prices.basic,
|
||||
features: {
|
||||
emailAccounts: 1,
|
||||
emailsPerDay: 500,
|
||||
historicalSync: false,
|
||||
customRules: false,
|
||||
prioritySupport: false,
|
||||
},
|
||||
},
|
||||
pro: {
|
||||
name: 'Pro',
|
||||
priceId: config.stripe.prices.pro,
|
||||
features: {
|
||||
emailAccounts: 3,
|
||||
emailsPerDay: -1,
|
||||
historicalSync: true,
|
||||
customRules: true,
|
||||
prioritySupport: false,
|
||||
},
|
||||
},
|
||||
business: {
|
||||
name: 'Business',
|
||||
priceId: config.stripe.prices.business,
|
||||
features: {
|
||||
emailAccounts: 10,
|
||||
emailsPerDay: -1,
|
||||
historicalSync: true,
|
||||
customRules: true,
|
||||
prioritySupport: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/subscription/checkout
|
||||
* Create subscription checkout session
|
||||
*/
|
||||
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 planConfig = PLANS[plan]
|
||||
if (!planConfig) {
|
||||
throw new ValidationError('Ungültiger Plan', { plan: ['Ungültig'] })
|
||||
}
|
||||
|
||||
// Check for existing subscription
|
||||
const existing = await subscriptions.getByUser(userId)
|
||||
let customerId = existing?.stripeCustomerId
|
||||
|
||||
// Create checkout session
|
||||
const sessionConfig = {
|
||||
mode: 'subscription',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price: planConfig.priceId,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${config.frontendUrl}/setup?subscription=success&setup=auto`,
|
||||
cancel_url: `${config.frontendUrl}/pricing?subscription=cancelled`,
|
||||
metadata: {
|
||||
userId,
|
||||
plan,
|
||||
},
|
||||
subscription_data: {
|
||||
trial_period_days: 14,
|
||||
metadata: { userId, plan },
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
}
|
||||
|
||||
if (customerId) {
|
||||
sessionConfig.customer = customerId
|
||||
} else if (email) {
|
||||
sessionConfig.customer_email = email
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create(sessionConfig)
|
||||
|
||||
log.info(`Checkout Session erstellt für User ${userId}`, { plan })
|
||||
|
||||
respond.success(res, {
|
||||
url: session.url,
|
||||
sessionId: session.id,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /api/subscription/status
|
||||
* Get user's subscription status
|
||||
*/
|
||||
router.get('/status', asyncHandler(async (req, res) => {
|
||||
const { userId } = req.query
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
respond.success(res, {
|
||||
status: sub.status,
|
||||
plan: sub.plan,
|
||||
features: PLANS[sub.plan]?.features || PLANS.basic.features,
|
||||
currentPeriodEnd: sub.currentPeriodEnd,
|
||||
cancelAtPeriodEnd: sub.cancelAtPeriodEnd || false,
|
||||
})
|
||||
}))
|
||||
|
||||
/**
|
||||
* POST /api/subscription/portal
|
||||
* Create Stripe Customer Portal session
|
||||
*/
|
||||
router.post('/portal',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.body
|
||||
|
||||
const sub = await subscriptions.getByUser(userId)
|
||||
|
||||
if (!sub?.stripeCustomerId) {
|
||||
throw new NotFoundError('Subscription')
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: sub.stripeCustomerId,
|
||||
return_url: `${config.frontendUrl}/settings`,
|
||||
})
|
||||
|
||||
respond.success(res, { url: session.url })
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/subscription/cancel
|
||||
* Cancel subscription at period end
|
||||
*/
|
||||
router.post('/cancel',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.body
|
||||
|
||||
const sub = await subscriptions.getByUser(userId)
|
||||
|
||||
if (!sub?.stripeSubscriptionId) {
|
||||
throw new NotFoundError('Subscription')
|
||||
}
|
||||
|
||||
await stripe.subscriptions.update(sub.stripeSubscriptionId, {
|
||||
cancel_at_period_end: true,
|
||||
})
|
||||
|
||||
await subscriptions.update(sub.$id, { cancelAtPeriodEnd: true })
|
||||
|
||||
log.info(`Subscription gekündigt für User ${userId}`)
|
||||
|
||||
respond.success(res, null, 'Subscription wird zum Ende der Periode gekündigt')
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /api/subscription/reactivate
|
||||
* Reactivate cancelled subscription
|
||||
*/
|
||||
router.post('/reactivate',
|
||||
validate({
|
||||
body: {
|
||||
userId: [rules.required('userId')],
|
||||
},
|
||||
}),
|
||||
asyncHandler(async (req, res) => {
|
||||
const { userId } = req.body
|
||||
|
||||
const sub = await subscriptions.getByUser(userId)
|
||||
|
||||
if (!sub?.stripeSubscriptionId) {
|
||||
throw new NotFoundError('Subscription')
|
||||
}
|
||||
|
||||
await stripe.subscriptions.update(sub.stripeSubscriptionId, {
|
||||
cancel_at_period_end: false,
|
||||
})
|
||||
|
||||
await subscriptions.update(sub.$id, { cancelAtPeriodEnd: false })
|
||||
|
||||
log.info(`Subscription reaktiviert für User ${userId}`)
|
||||
|
||||
respond.success(res, null, 'Subscription wurde reaktiviert')
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /stripe/webhook & POST /api/subscription/webhook
|
||||
* Stripe webhook handler
|
||||
*/
|
||||
router.post('/webhook', express.raw({ type: 'application/json' }), asyncHandler(async (req, res) => {
|
||||
const sig = req.headers['stripe-signature']
|
||||
|
||||
let event
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
req.body,
|
||||
sig,
|
||||
config.stripe.webhookSecret
|
||||
)
|
||||
} catch (err) {
|
||||
log.error('Webhook Signatur Fehler', { error: err.message })
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`)
|
||||
}
|
||||
|
||||
log.info(`Stripe Webhook: ${event.type}`)
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object
|
||||
const { userId, plan } = session.metadata || {}
|
||||
|
||||
if (userId && session.subscription) {
|
||||
const subscription = await stripe.subscriptions.retrieve(session.subscription)
|
||||
|
||||
await subscriptions.upsertByUser(userId, {
|
||||
stripeCustomerId: session.customer,
|
||||
stripeSubscriptionId: session.subscription,
|
||||
plan: plan || 'basic',
|
||||
status: subscription.status,
|
||||
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
})
|
||||
|
||||
log.success(`Subscription erstellt für User ${userId}`, { plan })
|
||||
}
|
||||
|
||||
// Handle one-time payment (legacy)
|
||||
if (session.metadata?.submissionId) {
|
||||
await submissions.updateStatus(session.metadata.submissionId, 'paid')
|
||||
log.success(`Zahlung abgeschlossen: ${session.metadata.submissionId}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'customer.subscription.updated': {
|
||||
const subscription = event.data.object
|
||||
const sub = await subscriptions.getByStripeId(subscription.id)
|
||||
|
||||
if (sub) {
|
||||
await subscriptions.update(sub.$id, {
|
||||
status: subscription.status,
|
||||
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
})
|
||||
|
||||
log.info(`Subscription aktualisiert: ${subscription.id}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object
|
||||
const sub = await subscriptions.getByStripeId(subscription.id)
|
||||
|
||||
if (sub) {
|
||||
await subscriptions.update(sub.$id, {
|
||||
status: 'cancelled',
|
||||
})
|
||||
|
||||
log.info(`Subscription gelöscht: ${subscription.id}`)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'invoice.payment_failed': {
|
||||
const invoice = event.data.object
|
||||
log.warn(`Zahlung fehlgeschlagen: ${invoice.id}`, {
|
||||
customer: invoice.customer,
|
||||
})
|
||||
// TODO: Send notification email
|
||||
break
|
||||
}
|
||||
|
||||
case 'invoice.payment_succeeded': {
|
||||
const invoice = event.data.object
|
||||
log.success(`Zahlung erfolgreich: ${invoice.id}`)
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
log.debug(`Unbehandelter Webhook: ${event.type}`)
|
||||
}
|
||||
|
||||
res.json({ received: true })
|
||||
} catch (err) {
|
||||
log.error('Webhook Handler Fehler', { error: err.message, event: event.type })
|
||||
res.status(500).json({ error: 'Webhook handler failed' })
|
||||
}
|
||||
}))
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user