522 lines
14 KiB
JavaScript
522 lines
14 KiB
JavaScript
/**
|
|
* Main API Routes
|
|
* General API endpoints
|
|
*/
|
|
|
|
import express from 'express'
|
|
import { Client, Users, Query as AppwriteQuery } from 'node-appwrite'
|
|
import { asyncHandler, NotFoundError, ValidationError, AuthorizationError } from '../middleware/errorHandler.mjs'
|
|
import { validate, schemas, rules } from '../middleware/validate.mjs'
|
|
import { respond } from '../utils/response.mjs'
|
|
import {
|
|
products,
|
|
questions,
|
|
submissions,
|
|
orders,
|
|
onboardingState,
|
|
emailAccounts,
|
|
emailStats,
|
|
emailDigests,
|
|
userPreferences,
|
|
subscriptions,
|
|
emailUsage,
|
|
referrals,
|
|
db,
|
|
Collections,
|
|
Query,
|
|
deleteAllDocumentsForUser,
|
|
} from '../services/database.mjs'
|
|
import Stripe from 'stripe'
|
|
import { config, isAdmin } from '../config/index.mjs'
|
|
import { log } from '../middleware/logger.mjs'
|
|
import { parseImapAccountAccess } from './email.mjs'
|
|
import { ImapService } from '../services/imap.mjs'
|
|
import { isAppwriteCollectionMissing } from '../utils/appwriteErrors.mjs'
|
|
import { requireAuth } from '../middleware/auth.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 },
|
|
},
|
|
})
|
|
})
|
|
|
|
/**
|
|
* GET /api/onboarding/status
|
|
* Get current onboarding state
|
|
*/
|
|
router.get('/onboarding/status',
|
|
requireAuth,
|
|
asyncHandler(async (req, res) => {
|
|
const userId = req.appwriteUser.id
|
|
const state = await onboardingState.getByUser(userId)
|
|
respond.success(res, state)
|
|
})
|
|
)
|
|
|
|
/**
|
|
* POST /api/onboarding/step
|
|
* Update onboarding step progress
|
|
*/
|
|
router.post('/onboarding/step',
|
|
requireAuth,
|
|
validate({
|
|
body: {
|
|
step: [rules.required('step')],
|
|
completedSteps: [rules.isArray('completedSteps')],
|
|
},
|
|
}),
|
|
asyncHandler(async (req, res) => {
|
|
const userId = req.appwriteUser.id
|
|
const { step, completedSteps = [] } = req.body
|
|
await onboardingState.updateStep(userId, step, completedSteps)
|
|
respond.success(res, { step, completedSteps })
|
|
})
|
|
)
|
|
|
|
/**
|
|
* POST /api/onboarding/skip
|
|
* Skip onboarding
|
|
*/
|
|
router.post('/onboarding/skip',
|
|
requireAuth,
|
|
asyncHandler(async (req, res) => {
|
|
const userId = req.appwriteUser.id
|
|
await onboardingState.skip(userId)
|
|
respond.success(res, { skipped: true })
|
|
})
|
|
)
|
|
|
|
/**
|
|
* POST /api/onboarding/resume
|
|
* Resume onboarding
|
|
*/
|
|
router.post('/onboarding/resume',
|
|
requireAuth,
|
|
asyncHandler(async (req, res) => {
|
|
const userId = req.appwriteUser.id
|
|
await onboardingState.resume(userId)
|
|
const state = await onboardingState.getByUser(userId)
|
|
respond.success(res, state)
|
|
})
|
|
)
|
|
|
|
/**
|
|
* DELETE /api/account/delete
|
|
* Delete all user data and account
|
|
*/
|
|
router.delete('/account/delete',
|
|
requireAuth,
|
|
asyncHandler(async (req, res) => {
|
|
const userId = req.appwriteUser.id
|
|
|
|
log.info(`Account deletion requested for user ${userId}`)
|
|
|
|
// Delete all user data
|
|
try {
|
|
// Delete email accounts
|
|
const accounts = await emailAccounts.getByUser(userId)
|
|
for (const account of accounts) {
|
|
try {
|
|
await db.delete(Collections.EMAIL_ACCOUNTS, account.$id)
|
|
} catch (err) {
|
|
log.warn(`Failed to delete account ${account.$id}`, { error: err.message })
|
|
}
|
|
}
|
|
|
|
// Delete stats
|
|
const stats = await emailStats.getByUser(userId)
|
|
if (stats) {
|
|
try {
|
|
await db.delete(Collections.EMAIL_STATS, stats.$id)
|
|
} catch (err) {
|
|
log.warn(`Failed to delete stats`, { error: err.message })
|
|
}
|
|
}
|
|
|
|
// Delete digests
|
|
const digests = await emailDigests.getByUser(userId)
|
|
for (const digest of digests) {
|
|
try {
|
|
await db.delete(Collections.EMAIL_DIGESTS, digest.$id)
|
|
} catch (err) {
|
|
log.warn(`Failed to delete digest ${digest.$id}`, { error: err.message })
|
|
}
|
|
}
|
|
|
|
// Delete preferences
|
|
const prefs = await userPreferences.getByUser(userId)
|
|
if (prefs) {
|
|
try {
|
|
await db.delete(Collections.USER_PREFERENCES, prefs.$id)
|
|
} catch (err) {
|
|
log.warn(`Failed to delete preferences`, { error: err.message })
|
|
}
|
|
}
|
|
|
|
// Delete subscription
|
|
const subscription = await subscriptions.getByUser(userId, req.appwriteUser?.email)
|
|
if (subscription && subscription.$id) {
|
|
try {
|
|
await db.delete(Collections.SUBSCRIPTIONS, subscription.$id)
|
|
} catch (err) {
|
|
log.warn(`Failed to delete subscription`, { error: err.message })
|
|
}
|
|
}
|
|
|
|
// Delete email usage
|
|
const usageRecords = await db.list(Collections.EMAIL_USAGE, [Query.equal('userId', userId)])
|
|
for (const usage of usageRecords) {
|
|
try {
|
|
await db.delete(Collections.EMAIL_USAGE, usage.$id)
|
|
} catch (err) {
|
|
log.warn(`Failed to delete usage record`, { error: err.message })
|
|
}
|
|
}
|
|
|
|
// Delete onboarding state
|
|
const onboarding = await onboardingState.getByUser(userId)
|
|
if (onboarding && onboarding.$id) {
|
|
try {
|
|
await db.delete(Collections.ONBOARDING_STATE, onboarding.$id)
|
|
} catch (err) {
|
|
log.warn(`Failed to delete onboarding state`, { error: err.message })
|
|
}
|
|
}
|
|
|
|
log.success(`Account deletion completed for user ${userId}`)
|
|
respond.success(res, { success: true, message: 'All data deleted successfully' })
|
|
} catch (err) {
|
|
log.error('Account deletion failed', { error: err.message, userId })
|
|
throw new ValidationError('Failed to delete account data')
|
|
}
|
|
})
|
|
)
|
|
|
|
/**
|
|
* GET /api/referrals/code
|
|
* Get or create referral code for user
|
|
* (Also registered on app in index.mjs before router mount.)
|
|
*/
|
|
export async function handleGetReferralCode(req, res) {
|
|
const userId = req.appwriteUser.id
|
|
try {
|
|
const result = await referrals.getOrCreateCode(userId)
|
|
if (!result) {
|
|
return respond.success(res, { referralCode: null, referralCount: 0 })
|
|
}
|
|
return respond.success(res, {
|
|
referralCode: result.referralCode,
|
|
referralCount: result.referralCount || 0,
|
|
})
|
|
} catch (err) {
|
|
if (isAppwriteCollectionMissing(err)) {
|
|
return respond.success(res, {
|
|
referralCode: null,
|
|
referralCount: 0,
|
|
})
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
router.get('/referrals/code', requireAuth, asyncHandler(handleGetReferralCode))
|
|
|
|
/**
|
|
* POST /api/referrals/track
|
|
* Track a referral (when new user signs up with referral code)
|
|
*/
|
|
router.post('/referrals/track',
|
|
requireAuth,
|
|
validate({
|
|
body: {
|
|
referralCode: [rules.required('referralCode')],
|
|
},
|
|
}),
|
|
asyncHandler(async (req, res) => {
|
|
const userId = req.appwriteUser.id
|
|
const { referralCode } = req.body
|
|
|
|
// Find referrer by code
|
|
const referrer = await referrals.getByCode(referralCode)
|
|
if (!referrer) {
|
|
throw new NotFoundError('Referral code')
|
|
}
|
|
|
|
// Don't allow self-referral
|
|
if (referrer.userId === userId) {
|
|
throw new ValidationError('Cannot refer yourself')
|
|
}
|
|
|
|
// Update referrer's count
|
|
await referrals.incrementCount(referrer.userId)
|
|
|
|
// Store referral relationship
|
|
await referrals.getOrCreateCode(userId)
|
|
const userReferral = await referrals.getOrCreateCode(userId)
|
|
await db.update(Collections.REFERRALS, userReferral.$id, {
|
|
referredBy: referrer.userId,
|
|
})
|
|
|
|
log.info(`Referral tracked: ${userId} referred by ${referrer.userId} (code: ${referralCode})`)
|
|
|
|
respond.success(res, { success: true })
|
|
})
|
|
)
|
|
|
|
async function resolveUserIdByEmail(email) {
|
|
const normalized = String(email).trim().toLowerCase()
|
|
const c = new Client()
|
|
.setEndpoint(config.appwrite.endpoint)
|
|
.setProject(config.appwrite.projectId)
|
|
.setKey(config.appwrite.apiKey)
|
|
const users = new Users(c)
|
|
try {
|
|
const res = await users.list([AppwriteQuery.equal('email', normalized)])
|
|
const uid = res.users?.[0]?.$id
|
|
if (uid) return uid
|
|
} catch (e) {
|
|
log.warn('resolveUserIdByEmail: Users.list failed', { email: normalized, error: e.message })
|
|
}
|
|
const acc = await db.findOne(Collections.EMAIL_ACCOUNTS, [Query.equal('email', normalized)])
|
|
return acc?.userId || null
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/reset-user-sort-data
|
|
* Admin only: clear sort-related data for a user (by email).
|
|
*/
|
|
router.post(
|
|
'/admin/reset-user-sort-data',
|
|
requireAuth,
|
|
validate({
|
|
body: {
|
|
email: [rules.required('email'), rules.email()],
|
|
},
|
|
}),
|
|
asyncHandler(async (req, res) => {
|
|
if (!isAdmin(req.appwriteUser?.email)) {
|
|
throw new AuthorizationError('Admin access required')
|
|
}
|
|
const targetEmail = String(req.body.email).trim().toLowerCase()
|
|
const targetUserId = await resolveUserIdByEmail(targetEmail)
|
|
if (!targetUserId) {
|
|
throw new NotFoundError('User for this email')
|
|
}
|
|
|
|
const statsDoc = await emailStats.getByUser(targetUserId)
|
|
let statsDeleted = 0
|
|
if (statsDoc?.$id) {
|
|
await db.delete(Collections.EMAIL_STATS, statsDoc.$id)
|
|
statsDeleted = 1
|
|
}
|
|
|
|
const digestsDeleted = await deleteAllDocumentsForUser(Collections.EMAIL_DIGESTS, targetUserId)
|
|
const usageDeleted = await deleteAllDocumentsForUser(Collections.EMAIL_USAGE, targetUserId)
|
|
|
|
await onboardingState.resetToInitial(targetUserId)
|
|
|
|
let imapCleared = 0
|
|
const imapAccounts = await db.list(Collections.EMAIL_ACCOUNTS, [
|
|
Query.equal('userId', targetUserId),
|
|
Query.equal('provider', 'imap'),
|
|
])
|
|
for (const acc of imapAccounts) {
|
|
if (!acc.accessToken) continue
|
|
try {
|
|
const cfg = parseImapAccountAccess(acc)
|
|
if (!cfg.password) continue
|
|
const imap = new ImapService({
|
|
host: cfg.host,
|
|
port: cfg.port,
|
|
secure: cfg.secure,
|
|
user: acc.email,
|
|
password: cfg.password,
|
|
})
|
|
const n = await imap.removeAllSortedFlags()
|
|
imapCleared += n
|
|
} catch (e) {
|
|
log.warn('reset-user-sort-data: IMAP flags failed for account', {
|
|
accountId: acc.$id,
|
|
error: e.message,
|
|
})
|
|
}
|
|
}
|
|
|
|
log.info('Admin reset-user-sort-data', {
|
|
admin: req.appwriteUser.email,
|
|
targetEmail,
|
|
targetUserId,
|
|
statsDeleted,
|
|
digestsDeleted,
|
|
usageDeleted,
|
|
imapCleared,
|
|
})
|
|
|
|
respond.success(res, {
|
|
reset: true,
|
|
deleted: {
|
|
stats: statsDeleted,
|
|
digests: digestsDeleted,
|
|
usage: usageDeleted,
|
|
},
|
|
imapCleared,
|
|
})
|
|
})
|
|
)
|
|
|
|
export default router
|