/opt/canhelp/apps/api/src/routes
NameSizeModeActions
admin.ts950570644editdlrm
auto-match.ts74060644editdlrm
auto-response.ts81110644editdlrm
availability.ts72940644editdlrm
categories.ts18670644editdlrm
chat.ts93770644editdlrm
dashboard.ts51420644editdlrm
favorites.ts44410644editdlrm
fomo.ts68440644editdlrm
google.ts48720644editdlrm
health.ts12140644editdlrm
i18n.ts1670860644editdlrm
locations.ts17810644editdlrm
notifications.ts28620644editdlrm
offers.ts100470644editdlrm
payments.ts211670644editdlrm
plans.ts13520644editdlrm
portfolio.ts66580644editdlrm
price-list.ts144170644editdlrm
reports.ts20070644editdlrm
reviews.ts27830644editdlrm
skill-suggestions.ts10350644editdlrm
specialist-cards.ts186410644editdlrm
stats.ts10930644editdlrm
statuses.ts37310644editdlrm
support.ts84520644editdlrm
tasks.ts300180644editdlrm
uploads.ts15260644editdlrm
users.ts371570644editdlrm
Edit: /opt/canhelp/apps/api/src/routes/users.ts (37157B)
import { Hono, type Context } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' import { eq, and, ne, count, desc, or, ilike, avg, asc, inArray, sql, gte, countDistinct } from 'drizzle-orm' import { createHash } from 'crypto' import { db } from '../db.js' import { users, reviews, specialistCards, plans, specialistAvailability, referralRewards, userStatuses, portfolioItems, offers, tasks, sitePageViews, siteContactLogs, planHistory, pushTokens } from '@canhelp/db' import { requireAuth, optionalAuth, type AuthVariables } from '../middleware/auth.js' import { generateReferralCode, applyReferral } from '../lib/referral.js' import { translateString } from '../translate.js' import { enrichUserWithHelperCapability, getHelperCapability, isAdminRole, toClientRole, } from '../lib/helper-capability.js' const app = new Hono<{ Variables: AuthVariables }>() type AppLocale = 'el' | 'en' | 'ru' | 'uk' function normalizeLocale(input?: string | null): AppLocale { if (input === 'el' || input === 'en' || input === 'ru' || input === 'uk') return input return 'el' } function pickLocalizedBio(user: any, locale?: string | null): string | null { const l = normalizeLocale(locale) const byLocale = { el: user.bioEl, en: user.bioEn, ru: user.bioRu, uk: user.bioUk, } as const return byLocale[l] ?? user.bio ?? null } // GET /users/me app.get('/me', requireAuth, async (c) => { const authUser = c.get('user') const [dbUser] = await db.select().from(users).where(eq(users.id, authUser.id)) const user = dbUser ? { ...dbUser, bio: pickLocalizedBio(dbUser, dbUser.locale) } : null if (!user) return c.json({ error: 'Not found' }, 404) let plan: typeof plans.$inferSelect | null = null if (user.planId) { const [p] = await db.select().from(plans).where(eq(plans.id, user.planId)) plan = p ?? null } const enriched = await enrichUserWithHelperCapability(user) const [{ total: offersCount }] = await db .select({ total: count() }) .from(specialistCards) .where(eq(specialistCards.specialistId, user.id)) return c.json({ ...enriched, plan, hasOffers: (offersCount ?? 0) > 0 }) }) app.post( '/me/push-token', requireAuth, zValidator( 'json', z.object({ token: z.string().min(10), platform: z.enum(['android', 'ios', 'web', 'unknown']).optional(), locale: z.enum(['el', 'en', 'ru', 'uk']).optional(), }), ), async (c) => { const authUser = c.get('user') const { token, platform, locale } = c.req.valid('json') const [existing] = await db .select({ id: pushTokens.id }) .from(pushTokens) .where(eq(pushTokens.token, token)) if (existing) { await db .update(pushTokens) .set({ userId: authUser.id, platform: platform ?? 'unknown', locale, updatedAt: new Date(), }) .where(eq(pushTokens.id, existing.id)) } else { await db.insert(pushTokens).values({ userId: authUser.id, token, platform: platform ?? 'unknown', locale, }) } return c.json({ ok: true }) }, ) app.post( '/me/push-token/remove', requireAuth, zValidator('json', z.object({ token: z.string().min(10) })), async (c) => { const authUser = c.get('user') const { token } = c.req.valid('json') await db .delete(pushTokens) .where(and(eq(pushTokens.userId, authUser.id), eq(pushTokens.token, token))) return c.json({ ok: true }) }, ) // POST /users/me/referral/apply — apply a referral code for the current user // Used after Google OAuth when refCode couldn't be passed through the OAuth flow app.post('/me/referral/apply', requireAuth, zValidator('json', z.object({ refCode: z.string().min(1).max(20) })), async (c) => { const authUser = c.get('user') const { refCode } = c.req.valid('json') // Check if reward already applied for this user const existing = await db .select({ id: referralRewards.id }) .from(referralRewards) .where(eq(referralRewards.refereeId, authUser.id)) .limit(1) if (existing.length) return c.json({ ok: true, skipped: true }) await applyReferral(authUser.id, refCode) return c.json({ ok: true }) }) // GET /users/me/referral — get referral info (code + stats) app.get('/me/referral', requireAuth, async (c) => { const authUser = c.get('user') let [user] = await db .select({ referralCode: users.referralCode }) .from(users) .where(eq(users.id, authUser.id)) if (!user) return c.json({ error: 'Not found' }, 404) // Auto-generate code for existing users that were created before referral system if (!user.referralCode) { let code: string | null = null for (let i = 0; i < 5; i++) { const candidate = generateReferralCode() const existing = await db.select({ id: users.id }).from(users).where(eq(users.referralCode, candidate)) if (existing.length === 0) { code = candidate; break } } if (code) { await db.update(users).set({ referralCode: code }).where(eq(users.id, authUser.id)) user = { referralCode: code } } } const rewarded = await db .select({ count: count() }) .from(referralRewards) .where(eq(referralRewards.referrerId, authUser.id)) const webUrl = process.env.WEB_URL ?? 'https://canhelp.gr' return c.json({ referralCode: user.referralCode, referralCount: Number(rewarded[0]?.count ?? 0), referralUrl: user.referralCode ? `${webUrl}/register?ref=${user.referralCode}` : null, }) }) // GET /users/me/plan-history — plan change history for current user app.get('/me/plan-history', requireAuth, async (c) => { const user = c.get('user') const limit = Math.min(Number(c.req.query('limit') ?? '50'), 100) const rows = await db .select() .from(planHistory) .where(eq(planHistory.userId, user.id)) .orderBy(desc(planHistory.createdAt)) .limit(limit) return c.json(rows) }) app.patch( '/me', requireAuth, zValidator( 'json', z.object({ firstName: z.string().min(1).optional(), lastName: z.string().min(1).optional(), phone: z.string().optional(), bio: z.string().max(1000).optional(), bioLocale: z.enum(['el', 'en', 'ru', 'uk']).optional(), autoTranslateBio: z.boolean().optional(), image: z.string().url().optional().nullable(), skills: z.array(z.string()).optional(), specialistCategories: z.array(z.string()).optional(), specialistLocations: z.array(z.string()).optional(), locale: z.enum(['el', 'en', 'ru', 'uk']).optional(), notifyNewTasks: z.boolean().optional(), notifMessages: z.boolean().optional(), languages: z.array(z.string().max(50)).max(20).optional(), showContactInfo: z.boolean().optional(), personalSiteSlug: z.string().max(50).regex(/^[a-z0-9_-]+$/).optional().nullable(), siteSettings: z.object({ showBio: z.boolean().optional(), showRating: z.boolean().optional(), showServices: z.boolean().optional(), showPriceList: z.boolean().optional(), showPortfolio: z.boolean().optional(), showReviews: z.boolean().optional(), showPhone: z.boolean().optional(), showStatus: z.boolean().optional(), showFullLastName: z.boolean().optional(), siteTheme: z.enum(['dark', 'light']).optional(), profileBackgroundImage: z.string().url().optional().nullable(), }).optional(), }), ), async (c) => { const authUser = c.get('user') const body = c.req.valid('json') const updates: Partial = {} if (body.firstName !== undefined) updates.firstName = body.firstName if (body.lastName !== undefined) updates.lastName = body.lastName if (body.phone !== undefined) updates.phone = body.phone if (body.bio !== undefined) { const [currentUser] = await db .select({ locale: users.locale }) .from(users) .where(eq(users.id, authUser.id)) const sourceLocale = normalizeLocale(body.bioLocale ?? body.locale ?? currentUser?.locale) updates.bio = body.bio if (sourceLocale === 'el') updates.bioEl = body.bio if (sourceLocale === 'en') updates.bioEn = body.bio if (sourceLocale === 'ru') updates.bioRu = body.bio if (sourceLocale === 'uk') updates.bioUk = body.bio } if (body.skills !== undefined) updates.skills = body.skills if (body.specialistCategories !== undefined) updates.specialistCategories = body.specialistCategories if (body.specialistLocations !== undefined) updates.specialistLocations = body.specialistLocations if (body.locale !== undefined) updates.locale = body.locale if (body.image !== undefined) updates.image = body.image if (body.notifyNewTasks !== undefined) updates.notifyNewTasks = body.notifyNewTasks if (body.notifMessages !== undefined) updates.notifMessages = body.notifMessages if (body.languages !== undefined) updates.languages = body.languages if (body.showContactInfo !== undefined) { // Check if user's plan allows showing contact info const [dbUser] = await db.select({ planId: users.planId }).from(users).where(eq(users.id, authUser.id)) if (body.showContactInfo && dbUser?.planId) { const [plan] = await db.select().from(plans).where(eq(plans.id, dbUser.planId)) if (!plan?.canShowContactInfo) { return c.json({ error: 'Your plan does not allow showing contact info', code: 'PLAN_FEATURE_RESTRICTED' }, 403) } } updates.showContactInfo = body.showContactInfo } if (body.personalSiteSlug !== undefined) { // Check if user's plan allows personal site and read current slug for legacy validation. const [dbUser] = await db .select({ planId: users.planId, personalSiteSlug: users.personalSiteSlug }) .from(users) .where(eq(users.id, authUser.id)) // Legacy compatibility: allow 1-2 char slug only if user keeps their existing slug unchanged. if (body.personalSiteSlug && body.personalSiteSlug.length < 3 && body.personalSiteSlug !== dbUser?.personalSiteSlug) { return c.json({ error: 'Personal site URL must be at least 3 characters', code: 'SLUG_TOO_SHORT' }, 400) } if (body.personalSiteSlug && dbUser?.planId) { const [plan] = await db.select().from(plans).where(eq(plans.id, dbUser.planId)) if (!plan?.hasPersonalSite) { return c.json({ error: 'Your plan does not include a personal site', code: 'PLAN_FEATURE_RESTRICTED' }, 403) } } // Check slug uniqueness if (body.personalSiteSlug) { const [taken] = await db .select({ id: users.id }) .from(users) .where(and(eq(users.personalSiteSlug, body.personalSiteSlug), ne(users.id, authUser.id))) if (taken) { return c.json({ error: 'This URL is already taken', code: 'SLUG_TAKEN' }, 409) } } updates.personalSiteSlug = body.personalSiteSlug } if (body.siteSettings !== undefined) { updates.siteSettings = body.siteSettings } if (Object.keys(updates).length === 0) { return c.json({ error: 'No fields to update' }, 400) } updates.updatedAt = new Date() const [updated] = await db .update(users) .set(updates) .where(eq(users.id, authUser.id)) .returning() if (body.bio && (body.autoTranslateBio ?? true)) { const sourceLocale = normalizeLocale(body.bioLocale ?? body.locale ?? updated.locale) const targets = (['el', 'en', 'ru', 'uk'] as const).filter((l) => l !== sourceLocale) Promise.allSettled( targets.map((to) => translateString(body.bio as string, sourceLocale, to).then(async (translated) => { if (!translated) return const field = to === 'el' ? 'bioEl' : to === 'en' ? 'bioEn' : to === 'ru' ? 'bioRu' : 'bioUk' await db .update(users) .set({ [field]: translated, updatedAt: new Date() }) .where(eq(users.id, authUser.id)) }), ), ).catch((e) => console.error('[bio-translate]', e)) } let plan: typeof plans.$inferSelect | null = null if (updated.planId) { const [p] = await db.select().from(plans).where(eq(plans.id, updated.planId)) plan = p ?? null } const enriched = await enrichUserWithHelperCapability({ ...updated, bio: pickLocalizedBio(updated, updated.locale) }) return c.json({ ...enriched, plan }) }, ) async function enableHelperMode(c: Context<{ Variables: AuthVariables }>) { const authUser = c.get('user') const [user] = await db.select().from(users).where(eq(users.id, authUser.id)) if (!user) return c.json({ error: 'Not found' }, 404) const [updated] = await db .update(users) .set({ updatedAt: new Date() }) .where(eq(users.id, authUser.id)) .returning() const enriched = await enrichUserWithHelperCapability(updated) return c.json(enriched) } // POST /users/me/offer-help app.post('/me/offer-help', requireAuth, enableHelperMode) // POST /users/me/become-specialist (legacy alias) app.post('/me/become-specialist', requireAuth, enableHelperMode) // GET /users/specialists (public) app.get('/specialists', async (c) => { const page = Math.max(1, Number(c.req.query('page') || 1)) const limit = Math.min(Number(c.req.query('limit') || 20), 100) const offset = (page - 1) * limit const q = c.req.query('q') const filterCategory = c.req.query('category') || '' const filterLocation = c.req.query('location') || '' const filterLanguage = c.req.query('language') || '' const locale = normalizeLocale(c.req.query('locale')) // Support multiple languages via repeated language[]=el&language[]=en or comma-separated const rawLanguages = c.req.queries('language') ?? (filterLanguage ? [filterLanguage] : []) const filterLanguages = rawLanguages.flatMap((l) => l.split(',').map((s) => s.trim())).filter(Boolean) // If filtering by category, location, or language, find matching specialistIds via cards let filteredIds: string[] | null = null if (filterCategory || filterLocation) { const cardConditions: any[] = [eq(specialistCards.isActive, true), eq(specialistCards.publicationStatus, 'active')] if (filterCategory) { cardConditions.push(sql`${specialistCards.categories} @> ARRAY[${filterCategory}]::text[]`) } if (filterLocation) { cardConditions.push(sql`${specialistCards.locations} @> ARRAY[${filterLocation}]::text[]`) } const matching = await db .selectDistinct({ specialistId: specialistCards.specialistId }) .from(specialistCards) .where(and(...cardConditions)) filteredIds = matching.map((r) => r.specialistId) if (filteredIds.length === 0) { return c.json({ data: [], total: 0, page, limit }) } } const helperReadyCondition = sql` ( (${users.bio} IS NOT NULL AND btrim(${users.bio}) <> '') AND EXISTS ( SELECT 1 FROM specialist_cards sc WHERE sc.specialist_id = ${users.id} AND sc.is_active = true AND sc.publication_status = 'active' AND cardinality(coalesce(sc.categories, ARRAY[]::text[])) > 0 AND cardinality(coalesce(sc.locations, ARRAY[]::text[])) > 0 ) ) ` const conditions = [eq(users.isActive, true), helperReadyCondition] if (q) { const nameCondition = or( ilike(users.name, `%${q}%`), ilike(users.firstName, `%${q}%`), ilike(users.lastName, `%${q}%`), )! // Also search in active portfolio cards: title, description, skills const cardMatches = await db .selectDistinct({ specialistId: specialistCards.specialistId }) .from(specialistCards) .where( and( eq(specialistCards.isActive, true), eq(specialistCards.publicationStatus, 'active'), or( ilike(specialistCards.title, `%${q}%`), ilike(specialistCards.description, `%${q}%`), sql`EXISTS (SELECT 1 FROM unnest(${specialistCards.skills}) AS s WHERE s ILIKE ${`%${q}%`})`, )!, ), ) const cardMatchIds = cardMatches.map((r) => r.specialistId) conditions.push( cardMatchIds.length > 0 ? or(nameCondition, inArray(users.id, cardMatchIds))! : nameCondition, ) } if (filteredIds !== null) { conditions.push(inArray(users.id, filteredIds)) } if (filterLanguages.length > 0) { // Specialist must speak AT LEAST ONE of the selected languages (OR semantics) conditions.push( or(...filterLanguages.map((lang) => sql`${users.languages} @> ARRAY[${lang}]::text[]`))!, ) } const where = and(...conditions) const [rows, [{ total }]] = await Promise.all([ db .select({ id: users.id, role: users.role, name: users.name, firstName: users.firstName, lastName: users.lastName, siteSettings: users.siteSettings, image: users.image, bio: users.bio, bioEl: users.bioEl, bioEn: users.bioEn, bioRu: users.bioRu, bioUk: users.bioUk, skills: users.skills, createdAt: users.createdAt, lastSeenAt: users.lastSeenAt, personalSiteSlug: users.personalSiteSlug, searchBoost: sql`COALESCE(${plans.searchBoost}, 0)`.as('search_boost'), }) .from(users) .leftJoin(plans, eq(users.planId, plans.id)) .where(where) .orderBy(sql`COALESCE(${plans.searchBoost}, 0) DESC`, desc(users.createdAt)) .limit(limit) .offset(offset), db.select({ total: count() }).from(users).where(where), ]) // Fetch ratings for all specialists in the page const ids = rows.map((r) => r.id) const ratings = ids.length > 0 ? await Promise.all( ids.map(async (id) => { const [result] = await db .select({ avg: avg(reviews.rating), total: count(), positive: count(sql`CASE WHEN ${reviews.rating} >= 4 THEN 1 END`) }) .from(reviews) .where(eq(reviews.targetId, id)) return { id, avg: result?.avg ?? null, total: result?.total ?? 0, positive: result?.positive ?? 0 } }), ) : [] const ratingMap = Object.fromEntries(ratings.map((r) => [r.id, r])) const data = rows.map((u) => { const r = ratingMap[u.id] const positivePercent = r && r.total > 0 ? Math.round((r.positive / r.total) * 100) : null const localizedBio = pickLocalizedBio(u, locale) const showFullLastName = (u.siteSettings?.showFullLastName ?? false) === true const profileBackgroundImage = (u.siteSettings as any)?.profileBackgroundImage ?? null const maskedLastName = showFullLastName ? u.lastName : (u.lastName ? `${u.lastName[0]}.` : u.lastName) return { ...u, lastName: maskedLastName, bio: localizedBio, rating: r?.avg ?? null, reviewCount: r?.total ?? 0, positivePercent, // Keep only the background image setting public for list cards. siteSettings: profileBackgroundImage ? { profileBackgroundImage } : undefined, } }).map(({ bioEl, bioEn, bioRu, bioUk, ...u }) => u) // Fetch active specialist cards for all specialists in the page const allCards = ids.length > 0 ? await db .select({ id: specialistCards.id, specialistId: specialistCards.specialistId, title: specialistCards.title, skills: specialistCards.skills, categories: specialistCards.categories, locations: specialistCards.locations, order: specialistCards.order, }) .from(specialistCards) .where(and( eq(specialistCards.isActive, true), eq(specialistCards.publicationStatus, 'active'), inArray(specialistCards.specialistId, ids), )) .orderBy(asc(specialistCards.order), asc(specialistCards.createdAt)) : [] // Attach card summary to each specialist const cardsBySpecialist = new Map() for (const c of allCards) { const arr = cardsBySpecialist.get(c.specialistId) ?? [] arr.push(c) cardsBySpecialist.set(c.specialistId, arr) } // Fetch today's availability for all specialists const today = new Date().toISOString().slice(0, 10) const todayAvailabilityRows = ids.length > 0 ? await db .select({ specialistId: specialistAvailability.specialistId, status: specialistAvailability.status }) .from(specialistAvailability) .where(and(inArray(specialistAvailability.specialistId, ids), eq(specialistAvailability.date, today))) : [] const availabilityMap = Object.fromEntries(todayAvailabilityRows.map((r) => [r.specialistId, r.status])) // Fetch active user statuses (canhelp_now / need_help) and verified badges const now = new Date() const [activeStatuses, userPlans] = await Promise.all([ ids.length > 0 ? db .select({ userId: userStatuses.userId, statusType: userStatuses.statusType, expiresAt: userStatuses.expiresAt, }) .from(userStatuses) .where(and(inArray(userStatuses.userId, ids), eq(userStatuses.isActive, true))) : Promise.resolve([]), ids.length > 0 ? db .select({ id: users.id, planId: users.planId, }) .from(users) .where(inArray(users.id, ids)) : Promise.resolve([]), ]) // Build status map const statusMap = new Map() for (const s of activeStatuses) { if (s.expiresAt && s.expiresAt < now) continue const arr = statusMap.get(s.userId) ?? [] arr.push(s.statusType) statusMap.set(s.userId, arr) } // Build badge map (hasVerifiedBadge from plan) const planIds = [...new Set(userPlans.map((u) => u.planId).filter(Boolean))] as string[] const planRows = planIds.length > 0 ? await db.select({ id: plans.id, hasVerifiedBadge: plans.hasVerifiedBadge, tier: plans.tier, searchBoost: plans.searchBoost, highlightedReviews: plans.highlightedReviews }).from(plans).where(inArray(plans.id, planIds)) : [] const planMap = Object.fromEntries(planRows.map((p) => [p.id, p])) const userPlanMap = Object.fromEntries(userPlans.map((u) => [u.id, u.planId])) const enrichedData = data.map((u) => { const cards = (cardsBySpecialist.get(u.id) ?? []).filter((c) => ids.includes(c.specialistId)) const allSkills = [...new Set(cards.flatMap((c) => c.skills ?? []))] const allCategories = [...new Set(cards.flatMap((c) => c.categories ?? []))] const allLocations = [...new Set(cards.flatMap((c) => c.locations ?? []))] const uPlanId = userPlanMap[u.id] const uPlan = uPlanId ? planMap[uPlanId] : null return { ...u, role: toClientRole((u as any).role ?? 'user', true), isHelperReady: true, legacyMarketRole: 'specialist', todayStatus: (availabilityMap[u.id] ?? null) as 'available' | 'busy' | null, cards: cards.map((c) => ({ id: c.id, title: c.title })), cardSkills: allSkills, cardCategories: allCategories, cardLocations: allLocations, activeStatuses: statusMap.get(u.id) ?? [], hasVerifiedBadge: !!uPlan?.hasVerifiedBadge, highlightedReviews: !!uPlan?.highlightedReviews, planTier: uPlan?.tier ?? 'free', } }) return c.json({ data: enrichedData, total, page, limit }) }) // GET /users/me/site-stats — site analytics for the current user (last 30 days) app.get('/me/site-stats', requireAuth, async (c) => { const authUser = c.get('user') const since = new Date() since.setDate(since.getDate() - 29) const sinceDate = since.toISOString().slice(0, 10) // Daily views + unique visitors const viewRows = await db .select({ date: sitePageViews.date, views: count(), unique: countDistinct(sitePageViews.visitorFingerprint), }) .from(sitePageViews) .where(and(eq(sitePageViews.siteOwnerId, authUser.id), gte(sitePageViews.date, sinceDate))) .groupBy(sitePageViews.date) .orderBy(asc(sitePageViews.date)) // Daily messages const msgRows = await db .select({ date: siteContactLogs.date, messages: count(), }) .from(siteContactLogs) .where(and(eq(siteContactLogs.siteOwnerId, authUser.id), gte(siteContactLogs.date, sinceDate))) .groupBy(siteContactLogs.date) .orderBy(asc(siteContactLogs.date)) // Build full 30-day array const msgMap = Object.fromEntries(msgRows.map((r) => [r.date, Number(r.messages)])) const days: { date: string; views: number; unique: number; messages: number }[] = [] for (let i = 0; i < 30; i++) { const d = new Date(since) d.setDate(d.getDate() + i) const dateStr = d.toISOString().slice(0, 10) const vr = viewRows.find((r) => r.date === dateStr) days.push({ date: dateStr, views: vr ? Number(vr.views) : 0, unique: vr ? Number(vr.unique) : 0, messages: msgMap[dateStr] ?? 0, }) } return c.json({ days }) }) // GET /users/site/:slug — public personal site for specialists with Ultimate plan app.get('/site/:slug', async (c) => { const slug = c.req.param('slug') const [user] = await db .select() .from(users) .where(and(eq(users.personalSiteSlug, slug), eq(users.isActive, true))) if (!user) return c.json({ error: 'Not found' }, 404) // Verify the user's plan includes personal site let planTier = 'free' let hasVerifiedBadge = false let hasPersonalSite = false if (user.planId) { const [plan] = await db .select({ tier: plans.tier, hasVerifiedBadge: plans.hasVerifiedBadge, hasPersonalSite: plans.hasPersonalSite }) .from(plans) .where(eq(plans.id, user.planId)) if (plan) { planTier = plan.tier ?? 'free' hasVerifiedBadge = !!plan.hasVerifiedBadge hasPersonalSite = !!plan.hasPersonalSite } } if (!hasPersonalSite) return c.json({ error: 'Not found' }, 404) // Track page view (fire & forget — don't block response) const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? c.req.header('x-real-ip') ?? 'unknown' const ua = c.req.header('user-agent') ?? '' const fingerprint = createHash('sha256').update(ip + ua).digest('hex').slice(0, 16) const today = new Date().toISOString().slice(0, 10) db.insert(sitePageViews).values({ siteOwnerId: user.id, visitorFingerprint: fingerprint, date: today }).execute().catch(() => {}) // Load specialist cards const cards = await db .select({ id: specialistCards.id, title: specialistCards.title, description: specialistCards.description, descriptionEl: specialistCards.descriptionEl, descriptionEn: specialistCards.descriptionEn, descriptionRu: specialistCards.descriptionRu, descriptionUk: specialistCards.descriptionUk, skills: specialistCards.skills, categories: specialistCards.categories, locations: specialistCards.locations, }) .from(specialistCards) .where(and(eq(specialistCards.specialistId, user.id), eq(specialistCards.isActive, true))) .orderBy(asc(specialistCards.order)) // Load portfolio const portfolio = await db .select({ id: portfolioItems.id, imageUrl: portfolioItems.imageUrl, title: portfolioItems.title, description: portfolioItems.description, }) .from(portfolioItems) .where(eq(portfolioItems.specialistId, user.id)) .orderBy(asc(portfolioItems.order)) // Load reviews const reviewRows = await db .select({ id: reviews.id, rating: reviews.rating, comment: reviews.comment, createdAt: reviews.createdAt, authorName: users.name, authorFirstName: users.firstName, authorImage: users.image, }) .from(reviews) .leftJoin(users, eq(reviews.authorId, users.id)) .where(eq(reviews.targetId, user.id)) .orderBy(desc(reviews.createdAt)) .limit(20) // Rating summary const [ratingAgg] = await db .select({ avg: avg(reviews.rating), total: count() }) .from(reviews) .where(eq(reviews.targetId, user.id)) // Active statuses const now = new Date() const statusRows = await db .select({ statusType: userStatuses.statusType, expiresAt: userStatuses.expiresAt }) .from(userStatuses) .where(and(eq(userStatuses.userId, user.id), eq(userStatuses.isActive, true))) const activeStatuses = statusRows .filter((s) => !s.expiresAt || s.expiresAt > now) .map((s) => s.statusType) // Count completed tasks (where specialist's offer was accepted) const [completedAgg] = await db .select({ total: count() }) .from(offers) .innerJoin(tasks, eq(offers.taskId, tasks.id)) .where(and(eq(offers.specialistId, user.id), eq(offers.status, 'accepted'), eq(tasks.status, 'completed'))) const ss = (user.siteSettings ?? {}) as Record const show = (key: string) => ss[key] !== false // undefined → show by default const showServices = show('showServices') const showPriceList = show('showPriceList') return c.json({ id: user.id, name: user.name, firstName: user.firstName, lastName: show('showFullLastName') ? user.lastName : (user.lastName ? user.lastName[0] + '.' : null), image: user.image, bio: show('showBio') ? user.bio : undefined, slug: user.personalSiteSlug, phone: show('showPhone') ? user.phone : undefined, siteSettings: ss, planTier, hasVerifiedBadge, activeStatuses: show('showStatus') ? activeStatuses : [], cards: showServices || showPriceList ? cards : [], portfolio: show('showPortfolio') ? portfolio : [], reviews: show('showReviews') ? reviewRows : [], rating: show('showRating') ? (ratingAgg?.avg ? Number(ratingAgg.avg) : null) : null, reviewCount: show('showRating') ? (ratingAgg?.total ?? 0) : 0, languages: user.languages ?? [], memberSince: user.createdAt, completedTasks: completedAgg?.total ?? 0, }) }) // POST /users/site/:slug/contact — contact form from personal site app.post('/site/:slug/contact', zValidator('json', z.object({ name: z.string().min(1).max(200), email: z.string().email().max(200).optional().or(z.literal('')), message: z.string().min(1).max(2000), })), async (c) => { const slug = c.req.param('slug') const { name, email, message } = c.req.valid('json') const [user] = await db .select({ id: users.id, email: users.email, firstName: users.firstName, personalSiteSlug: users.personalSiteSlug }) .from(users) .where(and(eq(users.personalSiteSlug, slug), eq(users.isActive, true))) if (!user) return c.json({ error: 'Not found' }, 404) // Send notification email to the specialist try { const { emailSiteContact } = await import('../lib/email.js') await emailSiteContact({ to: user.email, specialistName: user.firstName ?? '', senderName: name, senderEmail: email || undefined, message, }) } catch (err) { console.error('[Site Contact] email error:', err) } // Track contact message (fire & forget) const msgDate = new Date().toISOString().slice(0, 10) db.insert(siteContactLogs).values({ siteOwnerId: user.id, date: msgDate }).execute().catch(() => {}) return c.json({ ok: true }) }) // POST /users/site/:slug/track-message — track direct message sent from personal site (authenticated) app.post('/site/:slug/track-message', async (c) => { const slug = c.req.param('slug') const [user] = await db .select({ id: users.id }) .from(users) .where(and(eq(users.personalSiteSlug, slug), eq(users.isActive, true))) if (!user) return c.json({ ok: false }, 404) const date = new Date().toISOString().slice(0, 10) db.insert(siteContactLogs).values({ siteOwnerId: user.id, date }).execute().catch(() => {}) return c.json({ ok: true }) }) // GET /users/:id/profile (public, with optional auth for phone visibility) app.get('/:id/profile', optionalAuth, async (c) => { const [dbUser] = await db.select().from(users).where(eq(users.id, c.req.param('id'))) const viewer = c.get('user') as any const profileLocale = normalizeLocale(c.req.query('locale') ?? viewer?.locale ?? dbUser?.locale) const user = dbUser ? { ...dbUser, bio: pickLocalizedBio(dbUser, profileLocale) } : null if (!user || !user.isActive) return c.json({ error: 'Not found' }, 404) // Strip sensitive fields const { balance, referralCode, referredBy, notifyNewTasks, notifMessages, planExpiresAt, ...profile } = user // Phone visibility: only show if user opted in AND viewer's plan allows it let showPhone = false if (viewer) { if (viewer.id === user.id || viewer.role === 'admin') { showPhone = true } else if (user.showContactInfo && user.phone) { // Check viewer's plan canViewPhone const [viewerUser] = await db.select({ planId: users.planId }).from(users).where(eq(users.id, viewer.id)) if (viewerUser?.planId) { const [viewerPlan] = await db.select().from(plans).where(eq(plans.id, viewerUser.planId)) showPhone = !!viewerPlan?.canViewPhone } } } // Fetch verified badge + statuses let hasVerifiedBadge = false let planTier = 'free' if (user.planId) { const [plan] = await db .select({ hasVerifiedBadge: plans.hasVerifiedBadge, tier: plans.tier }) .from(plans) .where(eq(plans.id, user.planId)) if (plan) { hasVerifiedBadge = !!plan.hasVerifiedBadge planTier = plan.tier ?? 'free' } } const now = new Date() const statusRows = await db .select({ statusType: userStatuses.statusType, expiresAt: userStatuses.expiresAt }) .from(userStatuses) .where(and(eq(userStatuses.userId, user.id), eq(userStatuses.isActive, true))) const activeStatuses = statusRows .filter((s) => !s.expiresAt || s.expiresAt > now) .map((s) => s.statusType) const isHelperReady = await getHelperCapability(user.id) return c.json({ ...profile, role: toClientRole(profile.role, isHelperReady), isHelperReady, legacyMarketRole: isHelperReady ? 'specialist' : 'customer', email: viewer?.id === user.id || viewer?.role === 'admin' ? profile.email : undefined, phone: showPhone ? profile.phone : undefined, hasVerifiedBadge, planTier, activeStatuses, }) }) // ─── POST /users/me/site-seo ───────────────────────────────────────────── app.post( '/me/site-seo', requireAuth, zValidator('json', z.object({ seoTitle: z.string().max(200).optional(), seoDescription: z.string().max(500).optional(), locale: z.enum(['el', 'en', 'ru', 'uk']), })), async (c) => { const authUser = c.get('user') const { seoTitle, seoDescription, locale } = c.req.valid('json') const [dbUser] = await db.select({ siteSettings: users.siteSettings }).from(users).where(eq(users.id, authUser.id)) const cap = locale.charAt(0).toUpperCase() + locale.slice(1) const current = (dbUser?.siteSettings ?? {}) as Record const updated: Record = { ...current } if (seoTitle !== undefined) updated[`seoTitle${cap}`] = seoTitle if (seoDescription !== undefined) updated[`seoDesc${cap}`] = seoDescription await db.update(users).set({ siteSettings: updated }).where(eq(users.id, authUser.id)) // Background translation to other locales const targets = (['el', 'en', 'ru', 'uk'] as const).filter((l) => l !== locale) Promise.allSettled( targets.flatMap((to) => { const toCap = to.charAt(0).toUpperCase() + to.slice(1) const jobs: Promise[] = [] if (seoTitle) { jobs.push( translateString(seoTitle, locale, to).then(async (translated) => { if (!translated) return const [cur] = await db.select({ siteSettings: users.siteSettings }).from(users).where(eq(users.id, authUser.id)) const merged = { ...(cur?.siteSettings ?? {}), [`seoTitle${toCap}`]: translated } await db.update(users).set({ siteSettings: merged }).where(eq(users.id, authUser.id)) }) ) } if (seoDescription) { jobs.push( translateString(seoDescription, locale, to).then(async (translated) => { if (!translated) return const [cur] = await db.select({ siteSettings: users.siteSettings }).from(users).where(eq(users.id, authUser.id)) const merged = { ...(cur?.siteSettings ?? {}), [`seoDesc${toCap}`]: translated } await db.update(users).set({ siteSettings: merged }).where(eq(users.id, authUser.id)) }) ) } return jobs }), ).catch((e) => console.error('[seo-translate]', e)) return c.json({ ok: true }) }, ) export default app