/opt/canhelp/apps/api/src
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()