/opt/canhelp/apps/api/src
NameSizeModeActions
lib/-0755rm
middleware/-0755rm
routes/-0755rm
auth.ts156000644editdlrm
db.ts1740644editdlrm
index.ts239420644editdlrm
redis.ts2490644editdlrm
seed.ts830180644editdlrm
socket.ts57540644editdlrm
sync-categories-from-prod.ts27920644editdlrm
translate.ts220300644editdlrm
Edit: /opt/canhelp/apps/api/src/index.ts (23942B)
import { config as loadEnv } from 'dotenv' import { fileURLToPath } from 'url' import path from 'path' import type { Server as HttpServer } from 'http' // Load .env from repo root regardless of working directory or how the server is started loadEnv({ path: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../.env') }) import { serve } from '@hono/node-server' import { Hono } from 'hono' import { cors } from 'hono/cors' import { logger } from 'hono/logger' import { serveStatic } from '@hono/node-server/serve-static' import { auth } from './auth.js' import { isAppleAuthConfigured } from './lib/apple-client-secret.js' import { setupSocket } from './socket.js' import { type AuthVariables } from './middleware/auth.js' import { verifyTurnstile } from './lib/turnstile.js' import { db } from './db.js' import { tasks, users, notifications, plans, settings as settingsTable } from '@canhelp/db' import { and, eq, lt, gte, lte, isNotNull, isNull, or } from 'drizzle-orm' import { notifyTaskDeadlineReminder, notifyTaskArchived, notifyPlanRenewalReminder, notifyPlanInsufficientFunds, notifyPlanCharged } from './lib/notif.js' import { emailTaskDeadlineReminder, emailTaskArchived, emailPlanDowngradeApplied, emailPlanRenewalReminder, emailPlanInsufficientFunds, emailPlanCharged, } from './lib/email.js' import { applyBalanceChange } from './lib/balance.js' import { logPlanHistory } from './lib/plan-history.js' import fs from 'node:fs' // Route imports import healthRouter from './routes/health.js' import usersRouter from './routes/users.js' import tasksRouter from './routes/tasks.js' import offersRouter from './routes/offers.js' import chatRouter from './routes/chat.js' import reviewsRouter from './routes/reviews.js' import notificationsRouter from './routes/notifications.js' import favoritesRouter from './routes/favorites.js' import categoriesRouter from './routes/categories.js' import locationsRouter from './routes/locations.js' import uploadsRouter from './routes/uploads.js' import reportsRouter from './routes/reports.js' import adminRouter, { _backupDir, _pgDump } from './routes/admin.js' import i18nRouter from './routes/i18n.js' import availabilityRouter from './routes/availability.js' import portfolioRouter from './routes/portfolio.js' import specialistCardsRouter from './routes/specialist-cards.js' import skillSuggestionsRouter from './routes/skill-suggestions.js' import paymentsRouter from './routes/payments.js' import plansRouter from './routes/plans.js' import googleRouter from './routes/google.js' import statusesRouter from './routes/statuses.js' import fomoRouter from './routes/fomo.js' import autoResponseRouter from './routes/auto-response.js' import autoMatchRouter from './routes/auto-match.js' import dashboardRouter from './routes/dashboard.js' import supportRouter from './routes/support.js' import statsRouter from './routes/stats.js' import priceListRouter from './routes/price-list.js' const _dir = path.dirname(fileURLToPath(import.meta.url)) const UPLOAD_ROOT = process.env.UPLOAD_DIR ? path.resolve(process.env.UPLOAD_DIR) : path.resolve(_dir, '../uploads') const app = new Hono<{ Variables: AuthVariables }>().basePath('/api') // ─── Global middleware ───────────────────────────────────────────────────── app.use( cors({ origin: process.env.WEB_URL || 'http://localhost:3000', credentials: true, allowHeaders: ['Content-Type', 'Authorization'], allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], }), ) app.use(logger()) // ─── Turnstile verification for registration ────────────────────────────── function shouldBypassSignupCaptcha(c: Parameters[1] extends (arg: infer A) => any ? A : never): boolean { const origin = c.req.header('Origin') ?? '' const userAgent = (c.req.header('User-Agent') ?? '').toLowerCase() // Keep CAPTCHA for browser-based signup flows. if (origin) return false // Native apps usually call API without Origin and with platform UA. // This enables already-published mobile builds (e.g. TestFlight) to register // without embedding a Turnstile widget. const looksLikeNativeClient = userAgent.includes('dart') || userAgent.includes('flutter') || userAgent.includes('cfnetwork') || userAgent.includes('okhttp') return looksLikeNativeClient } app.post('/auth/sign-up/email', async (c) => { const body = await c.req.json().catch(() => ({})) const { captchaToken, ...rest } = body const skipCaptcha = shouldBypassSignupCaptcha(c) const ok = skipCaptcha || await verifyTurnstile(captchaToken, c.req.header('CF-Connecting-IP')) if (!ok) { return c.json({ message: 'CAPTCHA verification failed. Please try again.', code: 'CAPTCHA_FAILED' }, 400) } const newRequest = new Request(c.req.url, { method: 'POST', headers: c.req.raw.headers, body: JSON.stringify(rest), }) return auth.handler(newRequest) }) app.post('/auth/sign-in/social', async (c) => { const body = await c.req.json().catch(() => ({})) const provider = typeof body?.provider === 'string' ? body.provider.toLowerCase() : '' if (provider === 'google') { if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) { return c.json( { message: 'Google login is temporarily unavailable. Please try email login.', code: 'GOOGLE_AUTH_NOT_CONFIGURED', }, 503, ) } } if (provider === 'apple') { if (!isAppleAuthConfigured()) { return c.json( { message: 'Apple login is temporarily unavailable. Please try email login.', code: 'APPLE_AUTH_NOT_CONFIGURED', }, 503, ) } } const newRequest = new Request(c.req.url, { method: 'POST', headers: c.req.raw.headers, body: JSON.stringify(body), }) return auth.handler(newRequest) }) // ─── Better Auth handler (intercept all /auth/* paths) ─────────────────── app.use('/auth/*', (c) => auth.handler(c.req.raw)) // ─── Static uploads ─────────────────────────────────────────────────────── app.use('/uploads/*', serveStatic({ root: UPLOAD_ROOT, rewriteRequestPath: (p) => p.replace('/api/uploads', '') })) // ─── Routes ──────────────────────────────────────────────────────────────── app.route('/health', healthRouter) app.route('/users', usersRouter) app.route('/tasks', tasksRouter) app.route('/offers', offersRouter) app.route('/chat', chatRouter) app.route('/reviews', reviewsRouter) app.route('/notifications', notificationsRouter) app.route('/favorites', favoritesRouter) app.route('/categories', categoriesRouter) app.route('/locations', locationsRouter) app.route('/uploads', uploadsRouter) app.route('/reports', reportsRouter) app.route('/admin', adminRouter) app.route('/i18n', i18nRouter) app.route('/availability', availabilityRouter) app.route('/portfolio', portfolioRouter) app.route('/specialist-cards', specialistCardsRouter) app.route('/price-list', priceListRouter) app.route('/skill-suggestions', skillSuggestionsRouter) app.route('/payments', paymentsRouter) app.route('/plans', plansRouter) app.route('/google', googleRouter) app.route('/statuses', statusesRouter) app.route('/fomo', fomoRouter) app.route('/auto-response', autoResponseRouter) app.route('/auto-match', autoMatchRouter) app.route('/dashboard', dashboardRouter) app.route('/support', supportRouter) app.route('/stats', statsRouter) // ─── 404 fallback ───────────────────────────────────────────────────────── app.notFound((c) => c.json({ error: 'Not found' }, 404)) app.onError((err, c) => { console.error('[API Error]', err) return c.json({ error: 'Internal server error' }, 500) }) // ─── Start server ───────────────────────────────────────────────────────── const port = Number(process.env.API_PORT) || 4000 const server = serve({ fetch: app.fetch, port }, () => { console.log(`🚀 CanHelp API running on http://localhost:${port}/api`) console.log(`📚 Auth endpoints: http://localhost:${port}/api/auth`) }) setupSocket(server as unknown as HttpServer) // ─── Deadline cron job (runs every hour) ────────────────────────────────── async function runDeadlineJob() { try { const now = new Date() const in23h = new Date(now.getTime() + 23 * 60 * 60 * 1000) const in25h = new Date(now.getTime() + 25 * 60 * 60 * 1000) // 1. Send reminder for tasks expiring in ~24h (once per task) const upcoming = await db .select() .from(tasks) .where( and( eq(tasks.status, 'open'), isNotNull(tasks.expiresAt), gte(tasks.expiresAt, in23h), lte(tasks.expiresAt, in25h), ), ) for (const task of upcoming) { // Skip if reminder already sent const [existing] = await db .select({ id: notifications.id }) .from(notifications) .where( and( eq(notifications.referenceId, task.id), eq(notifications.type, 'task_deadline_reminder' as any), ), ) if (existing) continue const [owner] = await db .select({ email: users.email, locale: users.locale, name: users.name }) .from(users) .where(eq(users.id, task.customerId)) if (!owner) continue await notifyTaskDeadlineReminder(task.customerId, task.title, task.id) if (owner.email) { const locale = (['en', 'ru'].includes(owner.locale ?? '') ? owner.locale : 'el') as 'el' | 'en' | 'ru' emailTaskDeadlineReminder({ to: owner.email, ownerName: owner.name || owner.email, taskTitle: task.title, taskId: task.id, locale, }).catch((err: unknown) => console.error('[DeadlineJob] email error', err)) } } // 2. Auto-archive expired open tasks → draft (use expiresAt) const expiredTasks = await db .select({ id: tasks.id, title: tasks.title, customerId: tasks.customerId }) .from(tasks) .where( and( eq(tasks.status, 'open'), isNotNull(tasks.expiresAt), lt(tasks.expiresAt, now), ), ) if (expiredTasks.length > 0) { // Bulk update to draft await db .update(tasks) .set({ status: 'draft', updatedAt: now }) .where( and( eq(tasks.status, 'open'), isNotNull(tasks.expiresAt), lt(tasks.expiresAt, now), ), ) // Notify each owner for (const task of expiredTasks) { await notifyTaskArchived(task.customerId, task.title, task.id).catch( (err: unknown) => console.error('[DeadlineJob] notify archive error', err), ) const [owner] = await db .select({ email: users.email, locale: users.locale, name: users.name }) .from(users) .where(eq(users.id, task.customerId)) if (owner?.email) { const locale = (['en', 'ru'].includes(owner.locale ?? '') ? owner.locale : 'el') as 'el' | 'en' | 'ru' emailTaskArchived({ to: owner.email, ownerName: owner.name || owner.email, taskTitle: task.title, taskId: task.id, locale, }).catch((err: unknown) => console.error('[DeadlineJob] email archive error', err)) } } console.log(`[DeadlineJob] archived ${expiredTasks.length} expired task(s)`) } } catch (err) { console.error('[DeadlineJob] error', err) } } const _deadlineInterval = setInterval(runDeadlineJob, 60 * 60 * 1000) _deadlineInterval.unref() runDeadlineJob() // ─── Plan renewal cron job (runs every hour) ────────────────────────────── async function runPlanRenewalJob() { try { const now = new Date() // 1. Apply scheduled downgrades (pendingPlanId) when current plan expires const pendingUsers = await db .select({ id: users.id, email: users.email, name: users.name, planId: users.planId, pendingPlanId: users.pendingPlanId, locale: users.locale, }) .from(users) .where(and( isNotNull(users.pendingPlanId), or(isNull(users.planExpiresAt), lte(users.planExpiresAt, now)), )) for (const u of pendingUsers) { const [targetPlan] = await db.select().from(plans).where(eq(plans.id, u.pendingPlanId!)) if (!targetPlan) continue const newExpiry = targetPlan.durationDays && targetPlan.durationDays > 0 ? new Date(Date.now() + targetPlan.durationDays * 86400000) : null await db.update(users).set({ planId: u.pendingPlanId, planExpiresAt: newExpiry, pendingPlanId: null, renewalReminderSentAt: null, updatedAt: now, }).where(eq(users.id, u.id)) const locale = (['en', 'ru'].includes(u.locale ?? '') ? u.locale : 'el') as 'el' | 'en' | 'ru' if (u.email) { emailPlanDowngradeApplied({ to: u.email, name: u.name ?? '', planName: targetPlan.name, locale, }).catch((err: unknown) => console.error('[PlanJob] downgrade email error', err)) } // Log plan history const [prevPlan] = u.planId ? await db.select({ name: plans.name }).from(plans).where(eq(plans.id, u.planId)).limit(1) : [null] logPlanHistory({ userId: u.id, event: 'downgraded', planId: targetPlan.id, planName: targetPlan.name, previousPlanId: u.planId, previousPlanName: prevPlan?.name ?? null, expiresAt: newExpiry, }) console.log(`[PlanJob] applied pending plan ${targetPlan.name} for user ${u.id}`) } // 2. Check for users whose plan expires in 3 days — warn or schedule auto-downgrade const in3Days = new Date(now.getTime() + 3 * 24 * 60 * 60 * 1000) // Preload free plans for both roles (used for downgrade fallback) const freePlans = await db.select().from(plans).where(eq(plans.tier, 'free')) const freePlanForRole = (role: string) => freePlans.find(p => p.role === role) const aboutToExpire = await db .select({ id: users.id, email: users.email, name: users.name, balance: users.balance, planId: users.planId, planExpiresAt: users.planExpiresAt, locale: users.locale, renewalReminderSentAt: users.renewalReminderSentAt, }) .from(users) .where(and( isNotNull(users.planId), isNotNull(users.planExpiresAt), isNull(users.pendingPlanId), isNull(users.renewalReminderSentAt), // not yet reminded lte(users.planExpiresAt, in3Days), gte(users.planExpiresAt, now), )) for (const u of aboutToExpire) { const [currentPlan] = await db.select().from(plans).where(eq(plans.id, u.planId!)) if (!currentPlan || currentPlan.tier === 'free') continue const balance = parseFloat(u.balance ?? '0') const planPrice = parseFloat(currentPlan.price ?? '0') // Skip reminder for free-promo plans (price = 0) — they will auto-downgrade on expiry if (planPrice <= 0) { await db.update(users).set({ renewalReminderSentAt: now, updatedAt: now }).where(eq(users.id, u.id)) continue } const expiresStr = u.planExpiresAt!.toLocaleDateString('el-GR') const locale = (['en', 'ru'].includes(u.locale ?? '') ? u.locale : 'el') as 'el' | 'en' | 'ru' // Mark reminder sent (deduplication) await db.update(users).set({ renewalReminderSentAt: now, updatedAt: now }).where(eq(users.id, u.id)) if (balance < planPrice) { // Schedule auto-downgrade to free const freePlan = freePlanForRole(currentPlan.role) if (freePlan) { await db.update(users).set({ pendingPlanId: freePlan.id, updatedAt: now }).where(eq(users.id, u.id)) } notifyPlanInsufficientFunds(u.id, currentPlan.name, planPrice.toFixed(2), balance.toFixed(2)).catch(() => {}) if (u.email) { emailPlanInsufficientFunds({ to: u.email, name: u.name ?? '', currentPlanName: currentPlan.name, requiredAmount: planPrice.toFixed(2), currentBalance: balance.toFixed(2), expiresAt: expiresStr, locale, }).catch((err: unknown) => console.error('[PlanJob] insufficient funds email error', err)) } console.log(`[PlanJob] scheduled free downgrade for user ${u.id} (balance ${balance} < ${planPrice})`) } else { notifyPlanRenewalReminder(u.id, currentPlan.name, planPrice.toFixed(2), expiresStr).catch(() => {}) if (u.email) { emailPlanRenewalReminder({ to: u.email, name: u.name ?? '', planName: currentPlan.name, amount: planPrice.toFixed(2), expiresAt: expiresStr, locale, }).catch((err: unknown) => console.error('[PlanJob] renewal reminder email error', err)) } console.log(`[PlanJob] renewal reminder sent for user ${u.id}`) } } // 3. Charge users whose plan expired and they have sufficient balance (no pendingPlanId = wasn't scheduled for downgrade) const expiredUsers = await db .select({ id: users.id, email: users.email, name: users.name, balance: users.balance, planId: users.planId, planExpiresAt: users.planExpiresAt, locale: users.locale, }) .from(users) .where(and( isNotNull(users.planId), isNotNull(users.planExpiresAt), isNull(users.pendingPlanId), lte(users.planExpiresAt, now), )) for (const u of expiredUsers) { const [currentPlan] = await db.select().from(plans).where(eq(plans.id, u.planId!)) if (!currentPlan || currentPlan.tier === 'free') continue const balance = parseFloat(u.balance ?? '0') const planPrice = parseFloat(currentPlan.price ?? '0') // Free-promo plan (price = 0) — downgrade to free without charging if (planPrice <= 0) { const freePlan = freePlanForRole(currentPlan.role) if (freePlan) { await db.update(users).set({ planId: freePlan.id, planExpiresAt: null, updatedAt: now }).where(eq(users.id, u.id)) logPlanHistory({ userId: u.id, event: 'expired', planId: freePlan.id, planName: freePlan.name, previousPlanId: currentPlan.id, previousPlanName: currentPlan.name, expiresAt: null, note: 'promo ended', }) console.log(`[PlanJob] promo plan ${currentPlan.name} expired for user ${u.id} — downgraded to free`) } continue } const locale = (['en', 'ru'].includes(u.locale ?? '') ? u.locale : 'el') as 'el' | 'en' | 'ru' if (balance >= planPrice) { try { // Deduct balance await applyBalanceChange(db, { userId: u.id, amount: planPrice, direction: 'debit', kind: 'plan_purchase', description: `Plan renewal: ${currentPlan.name}`, }) // Extend plan expiry from current expiry (or now if already past) const base = u.planExpiresAt && u.planExpiresAt > now ? u.planExpiresAt : now const durationDays = currentPlan.durationDays && currentPlan.durationDays > 0 ? currentPlan.durationDays : 30 const newExpiry = new Date(base.getTime() + durationDays * 86400000) await db.update(users).set({ planExpiresAt: newExpiry, renewalReminderSentAt: null, updatedAt: now, }).where(eq(users.id, u.id)) const newExpiresStr = newExpiry.toLocaleDateString('el-GR') notifyPlanCharged(u.id, currentPlan.name, planPrice.toFixed(2)).catch(() => {}) if (u.email) { emailPlanCharged({ to: u.email, name: u.name ?? '', planName: currentPlan.name, amount: planPrice.toFixed(2), newExpiresAt: newExpiresStr, locale, }).catch((err: unknown) => console.error('[PlanJob] charged email error', err)) } logPlanHistory({ userId: u.id, event: 'renewed', planId: currentPlan.id, planName: currentPlan.name, previousPlanId: currentPlan.id, previousPlanName: currentPlan.name, expiresAt: newExpiry, }) console.log(`[PlanJob] charged ${planPrice}€ and renewed plan ${currentPlan.name} for user ${u.id} until ${newExpiry.toISOString()}`) } catch (chargeErr) { console.error(`[PlanJob] failed to charge user ${u.id}:`, chargeErr) // Downgrade on charge failure const freePlan = freePlanForRole(currentPlan.role) if (freePlan) { await db.update(users).set({ pendingPlanId: freePlan.id, updatedAt: now }).where(eq(users.id, u.id)) } } } else { // Insufficient balance at renewal time — downgrade const freePlan = freePlanForRole(currentPlan.role) if (freePlan) { await db.update(users).set({ pendingPlanId: freePlan.id, updatedAt: now }).where(eq(users.id, u.id)) } console.log(`[PlanJob] expired plan, insufficient balance for user ${u.id} — scheduling downgrade`) } } } catch (err) { console.error('[PlanJob] error', err) } } const _planInterval = setInterval(runPlanRenewalJob, 60 * 60 * 1000) _planInterval.unref() runPlanRenewalJob() // ─── Daily auto-backup cron job (runs every hour, backs up once per day) ───── async function runBackupJob() { try { const [autoSetting] = await db .select() .from(settingsTable) .where(eq(settingsTable.key, 'backup.autoEnabled')) if (autoSetting?.value !== 'true') return const todayFile = path.join(_backupDir, 'backup_today.sql.gz') const yesterdayFile = path.join(_backupDir, 'backup_yesterday.sql.gz') // Skip if today's backup already exists and was created today if (fs.existsSync(todayFile)) { const stat = fs.statSync(todayFile) const today = new Date() if (stat.mtime.toDateString() === today.toDateString()) return } // Rotate: today → yesterday if (fs.existsSync(todayFile)) { if (fs.existsSync(yesterdayFile)) fs.unlinkSync(yesterdayFile) fs.renameSync(todayFile, yesterdayFile) } // Create new backup const buf = await _pgDump() fs.writeFileSync(todayFile, buf) // Record last backup timestamp await db .insert(settingsTable) .values({ key: 'backup.lastBackup', value: new Date().toISOString(), updatedAt: new Date() }) .onConflictDoUpdate({ target: settingsTable.key, set: { value: new Date().toISOString(), updatedAt: new Date() } }) console.log('[BackupJob] daily backup created successfully') } catch (err) { console.error('[BackupJob] error', err) } } const _backupInterval = setInterval(runBackupJob, 60 * 60 * 1000) _backupInterval.unref() runBackupJob()