lilith-platform.live/codebase/@features/api/src/app/server.ts
2026-05-18 18:52:34 -07:00

400 lines
18 KiB
TypeScript

import { Hono } from 'hono';
import { callMigrations } from '@/entities/call';
import { correctionMigrations } from '@/entities/correction';
import { prospectQualificationMigrations } from '@/entities/prospect-qualification';
import { promptRevisionMigrations } from '@/entities/prompt-revision';
import { contactMigrations, contactClientLinkMigrations } from '@/entities/contact';
import { bookingMigrations } from '@/entities/booking';
import { calendarMigrations } from '@/entities/calendar';
import { calendarEventMigrations } from '@/entities/calendar-event';
import { clientBookingMigrations } from '@/entities/client-booking';
import { incomeSessionMigrations } from '@/entities/income-session';
import { cityVisitMigrations } from '@/entities/city-visit';
import { clientMigrations } from '@/entities/client';
import { classificationEventMigrations } from '@/entities/classification-event';
import { contactRelationshipMigrations } from '@/entities/contact-relationship';
import { credentialMigrations } from '@/entities/credential';
import { photoMigrations } from '@/entities/photo';
import { platformMigrations } from '@/entities/platform';
import { projectMigrations } from '@/entities/project';
import { projectClientMigrations } from '@/entities/project-client';
import { contactSubmissionMigrations } from '@/entities/contact-submission';
import { outreachBatchMigrations } from '@/entities/outreach-batch';
import { outreachBatchItemMigrations } from '@/entities/outreach-batch-item';
import { outreachSettingsMigrations } from '@/entities/outreach-settings';
import { macSyncStatusMigrations } from '@/entities/mac-sync-status';
import { contentPostMigrations } from '@/entities/content-post';
import { destinationMigrations } from '@/entities/destination';
import { destinationPerformanceMigrations } from '@/entities/destination-performance';
import { destinationVisitsMigrations } from '@/entities/destination-visits';
import { providerGradesMigrations } from '@/entities/provider-grades';
import { financialRecordMigrations } from '@/entities/financial-record';
import { flightMigrations } from '@/entities/flight';
import { hotelStayMigrations } from '@/entities/hotel-stay';
import { galleryItemMigrations } from '@/entities/gallery-item';
import { inspirationMigrations } from '@/entities/inspiration';
import { inviteTokenMigrations } from '@/entities/invite-token';
import { journalEntryMigrations } from '@/entities/journal-entry';
import { locationInferenceMigrations } from '@/entities/location-inference';
import { loreSectionMigrations } from '@/entities/lore-section';
import { providerProfileMigrations } from '@/entities/provider-profile';
import { rateCardMigrations } from '@/entities/rate-card';
import { paymentMethodMigrations } from '@/entities/payment-method';
import { reminderMigrations } from '@/entities/reminder';
import { reputationEventMigrations } from '@/entities/reputation-event';
import { rosterContentMigrations } from '@/entities/roster-content';
import { screeningCheckMigrations } from '@/entities/screening-check';
import { shopListingMigrations } from '@/entities/shop-listing';
import { shortLinkMigrations } from '@/entities/short-link';
import { aboutMigrations } from '@/entities/about';
import { activityMenuMigrations } from '@/entities/activity-menu';
import { siteTextMigrations } from '@/entities/site-text';
import { specialtyCategoryMigrations } from '@/entities/specialty-category';
import { specialtyMigrations } from '@/entities/specialty';
import { taskMigrations } from '@/entities/task';
import { tourEventMigrations } from '@/entities/tour-event';
import { citySnapshotMigrations } from '@/entities/city-snapshot';
import { tourInterestMigrations } from '@/entities/tour-interest';
import { touringSubscriptionMigrations } from '@/entities/touring-subscription';
import { policyMigrations } from '@/entities/policy';
import { heroStripMigrations } from '@/entities/hero-strip';
import { waitlistSubscriptionMigrations } from '@/entities/waitlist-subscription';
import { tourStopMigrations } from '@/entities/tour-stop';
import { verifiedProfileMigrations } from '@/entities/verified-profile';
import { vipBillingMigrations } from '@/entities/vip-billing';
import { vipClientMigrations, vipSchemaV2Migration } from '@/entities/vip-client';
import { engineDraftMigrations } from '@/entities/engine-draft';
import { engineDraftsRouter } from '@/surfaces/admin/engine-drafts';
import { engineQualifyRouter } from '@/surfaces/assistant/qualify';
import { engineTourStopsRouter } from '@/surfaces/engine/tour-stops';
import { engineOpportunityLocationsRouter } from '@/surfaces/engine/opportunity-locations';
import { engineTourOptimizerRouter } from '@/surfaces/engine/tour-optimizer';
import { vipConversationMigrations } from '@/entities/vip-conversation';
import { vipGiftMigrations } from '@/entities/vip-gift';
import { vipMeetingMigrations } from '@/entities/vip-meeting';
import { vipReservationMigrations } from '@/entities/vip-reservation';
import { vipInvitationMigrations } from '@/entities/vip-invitation';
import { vipPriorityRequestMigrations } from '@/entities/vip-priority-request';
import { vipPushSubscriptionMigrations } from '@/entities/vip-push-subscription';
import { vipReferralMigrations } from '@/entities/vip-referral';
import { vipMemoryMigrations } from '@/entities/vip-memory';
import { aiConversationMigrations } from '@/entities/ai-conversation';
import { prospectExperimentMigrations } from '@/entities/prospect-experiment';
import { aiEngineStateMigrations } from '@/entities/ai-engine-state';
import { vipTokenMigrations } from '@/entities/vip-token';
import { vipQuoteMigrations } from '@/entities/vip-quote';
import { otpAttemptMigrations } from '@/entities/otp-attempt';
import { adminSurface } from '@/surfaces/admin';
import { createAdminI18nRouter } from '@/surfaces/admin/i18n';
import { authSurface } from '@/surfaces/auth';
import { assistantSurface } from '@/surfaces/assistant';
import { createMSurface } from '@/surfaces/m';
import { createMySurface } from '@/surfaces/my';
import { createPublicSurface } from '@/surfaces/public';
import { shortLinkRedirectRouter } from '@/surfaces/public/short-link';
import { createVipSurface } from '@/surfaces/vip';
import { createI18nSurface } from '@/surfaces/i18n';
import { createWwwSurface } from '@/surfaces/www';
import { openDb, openIcloudDb, runMigrations, getDb, getIcloudDb } from '@/shared/db';
import { logger } from '@/shared/logger';
import { createMailerFromEnv, MailerError } from '@/shared/mail';
import { startProcessors } from '@/processors';
import { runCalendarProjection } from '@/processors/calendar-projection';
import { loadConfig } from './config';
import { serviceTokenAuth } from './middleware/auth';
import { corsMiddleware } from './middleware/cors';
import { errorHandler } from './middleware/errors';
import { rateLimitMiddleware } from './middleware/rate-limit';
import { ssoRequired } from './middleware/sso';
const config = loadConfig();
const db = openDb(config.QUINN_DB_URL);
if (config.QUINN_MACSYNC_DB_URL) openIcloudDb(config.QUINN_MACSYNC_DB_URL);
await runMigrations(db, [
// contacts table must run before clientMigrations (person_id FK → contacts(id) added below)
...contactMigrations,
// Core entities
...clientMigrations,
...contactClientLinkMigrations, // adds person_id FK to clients → contacts (clients must exist first)
...bookingMigrations,
...clientBookingMigrations,
...reputationEventMigrations,
...journalEntryMigrations,
...contentPostMigrations,
...contactSubmissionMigrations,
...touringSubscriptionMigrations,
...waitlistSubscriptionMigrations,
...inspirationMigrations,
...reminderMigrations,
...taskMigrations,
...tourStopMigrations,
...cityVisitMigrations,
...tourEventMigrations,
...citySnapshotMigrations,
...tourInterestMigrations,
...inviteTokenMigrations,
...calendarEventMigrations,
...calendarMigrations,
...incomeSessionMigrations,
...financialRecordMigrations,
...flightMigrations,
...hotelStayMigrations,
...screeningCheckMigrations,
// Personal data entities
...projectMigrations,
...photoMigrations,
...platformMigrations,
...credentialMigrations,
// Contact and classification entities
...contactRelationshipMigrations,
...projectClientMigrations,
...locationInferenceMigrations,
...classificationEventMigrations,
// Outreach entities
...outreachBatchMigrations,
...outreachBatchItemMigrations,
...outreachSettingsMigrations,
...macSyncStatusMigrations,
// Content / CMS entities
...destinationMigrations,
...destinationPerformanceMigrations,
...destinationVisitsMigrations,
...providerGradesMigrations,
...galleryItemMigrations,
...loreSectionMigrations,
...providerProfileMigrations,
...rateCardMigrations,
...paymentMethodMigrations,
...rosterContentMigrations,
...shopListingMigrations,
...shortLinkMigrations,
...aboutMigrations,
...activityMenuMigrations,
...policyMigrations,
...heroStripMigrations,
...siteTextMigrations,
...specialtyCategoryMigrations,
...specialtyMigrations,
...verifiedProfileMigrations,
// VIP entities — order matters: clients first, then dependent tables, v2 migration last
...vipClientMigrations,
...engineDraftMigrations, // creates vip_clients
...vipTokenMigrations, // creates vip_tokens (FK → vip_clients)
...vipPushSubscriptionMigrations, // creates vip_push_subscriptions (FK → vip_clients)
...vipConversationMigrations, // creates vip_conversations (FK → vip_clients)
...vipGiftMigrations, // creates vip_gifts (FK → vip_clients on fresh install)
...vipMeetingMigrations, // creates vip_meetings (FK → vip_clients on fresh install)
...vipReservationMigrations, // creates vip_reservations (FK → vip_clients, nullable)
...vipInvitationMigrations, // creates vip_invitations (FK → vip_clients)
...vipPriorityRequestMigrations, // creates vip_priority_requests (FK → vip_clients)
...vipReferralMigrations, // creates vip_referrals (FK → vip_clients on fresh install)
...vipBillingMigrations, // creates vip_billing (FK → vip_clients on fresh install)
...vipMemoryMigrations, // creates vip_memories (FK → vip_clients)
...vipQuoteMigrations, // creates vip_quotes (FK → vip_clients)
...otpAttemptMigrations, // creates otp_attempts (cocotte.club SMS-OTP login)
...prospectExperimentMigrations, // prospect_experiments (messaging experiment tracking)
...aiConversationMigrations, // ai.conversations (chat history across AI agents)
...aiEngineStateMigrations, // ai.engine_state + ai.engine_block_list (engine control)
...vipSchemaV2Migration, // migrates data from old vip_invites schema (no-op on fresh install)
// Calls infrastructure (personas, calls, call_events, voicemails)
...callMigrations,
// Prospect qualification — drives funnel KPIs in /my/prospector.
...prospectQualificationMigrations,
// Prospector dashboard — prompt_revisions creates the table + ALTERs engine_drafts
// to add prompt_revision_id (FK), so it must run after engineDraftMigrations (registered earlier).
// corrections references engine_drafts.id, so it must run after as well.
...promptRevisionMigrations,
...correctionMigrations,
]);
const mailer = createMailerFromEnv();
// Start background processors after migrations complete
void startProcessors(
{
quinn: getDb(),
icloud: getIcloudDb(),
},
{ url: config.CONTENT_MODERATOR_URL },
config.MAC_SYNC_SERVICE_TOKEN
? {
macSyncBaseUrl: config.MAC_SYNC_BASE_URL,
macSyncServiceToken: config.MAC_SYNC_SERVICE_TOKEN,
}
: undefined,
{ providerEmail: config.CONTACT_PROVIDER_EMAIL },
).catch((err: unknown) => {
logger.error('failed to start processors', {
error: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
process.exit(1);
});
const app = new Hono()
.onError(errorHandler)
.get('/health', (c) => c.json({ ok: true }))
.get('/health/deep', async (c) => {
const checks: Record<string, string> = {};
try {
await getDb()`SELECT 1`;
checks['db'] = 'ok';
} catch (err) {
checks['db'] = `error: ${String(err)}`;
}
try {
await getIcloudDb()`SELECT 1`;
checks['macsync_db'] = 'ok';
} catch (err) {
checks['macsync_db'] = `error: ${String(err)}`;
}
try {
await mailer.verify();
checks['mail'] = 'ok';
} catch (err) {
checks['mail'] = err instanceof MailerError ? `error: ${err.code}` : `error: ${String(err)}`;
}
const ok = Object.values(checks).every((v) => v === 'ok');
return c.json({ ok, checks }, ok ? 200 : 503);
})
.use('/my/*', corsMiddleware('same-origin'))
.use('/my/*', rateLimitMiddleware('my'))
.use('/my/*', ssoRequired(config.SSO_VALIDATE_URL, config.SERVICE_TOKEN, {
skipHosts: config.DEV_AUTH_SKIP_HOSTS.split(',').map((h) => h.trim().toLowerCase()).filter(Boolean),
}))
.route('/my', createMySurface({
clients: { syncApiUrl: config.SYNC_API_URL, syncApiKey: config.SYNC_API_KEY },
bookings: { mailer, providerEmail: config.CONTACT_PROVIDER_EMAIL },
calls: {
twilioAccountSid: config.TWILIO_ACCOUNT_SID,
twilioAuthToken: config.TWILIO_AUTH_TOKEN,
twilioApiKey: config.TWILIO_API_KEY,
signingSecret: config.SERVICE_TOKEN,
},
}))
.route('/auth', authSurface)
.use('/admin/*', corsMiddleware('same-origin'))
.use('/admin/*', rateLimitMiddleware('admin'))
.use('/admin/*', ssoRequired(config.SSO_VALIDATE_URL, config.SERVICE_TOKEN, { skipHosts: config.DEV_AUTH_SKIP_HOSTS.split(',').map((h) => h.trim().toLowerCase()).filter(Boolean) }))
.route('/admin', adminSurface)
.route('/admin/i18n', createAdminI18nRouter({ localesDir: config.QUINN_I18N_LOCALES_DIR }))
.use('/assistant/*', serviceTokenAuth(config.SERVICE_TOKEN))
.route('/assistant', assistantSurface)
.use('/internal/*', serviceTokenAuth(config.SERVICE_TOKEN))
.post('/internal/sync/calendars', async (c) => {
const result = await runCalendarProjection(getDb(), getIcloudDb());
return c.json(result);
})
.use('/m/*', corsMiddleware('same-origin'))
.use('/m/*', rateLimitMiddleware('m'))
.use('/m/*', serviceTokenAuth(config.SERVICE_TOKEN))
.route('/m', createMSurface({
contacts: { providerSlug: config.CONTACT_PROVIDER_SLUG },
qualification: { providerSlug: config.CONTACT_PROVIDER_SLUG },
}))
.use('/i18n/*', corsMiddleware('public-read'))
.use('/i18n/*', rateLimitMiddleware('www'))
.route('/i18n', createI18nSurface({
localesDir: config.QUINN_I18N_LOCALES_DIR,
fallbackLocale: config.QUINN_I18N_FALLBACK_LOCALE,
modelBossUrl: config.MODEL_BOSS_URL ?? '',
translationModel: config.QUINN_I18N_ML_MODEL,
timeoutMs: config.QUINN_I18N_ML_TIMEOUT_MS,
persistOnSuccess: config.QUINN_I18N_ML_PERSIST,
autoBackfill: config.QUINN_I18N_ML_AUTO_BACKFILL,
}))
.use('/www/*', corsMiddleware('public-read'))
.use('/www/*', rateLimitMiddleware('www'))
.route('/www', createWwwSurface({
dataApiUrl: config.PROVIDER_DATA_API_URL,
dataApiToken: config.PROVIDER_DATA_API_TOKEN || undefined,
}))
.use('/s/*', corsMiddleware('public-read'))
.route('/s', shortLinkRedirectRouter)
.use('/vip/*', corsMiddleware([
config.VIP_ORIGIN,
'http://vip.quinn.apricot.lan',
'http://localhost:5178',
]))
.use('/vip/invites/*', serviceTokenAuth(config.SERVICE_TOKEN))
.use('/vip/billing-admin/*', serviceTokenAuth(config.SERVICE_TOKEN))
.use('/vip/summary/*', serviceTokenAuth(config.SERVICE_TOKEN))
.use('/vip/roster/*', serviceTokenAuth(config.SERVICE_TOKEN))
.use('/vip/roster', serviceTokenAuth(config.SERVICE_TOKEN))
.use('/vip/admin/*', ssoRequired(config.SSO_VALIDATE_URL, config.SERVICE_TOKEN, {
skipHosts: config.DEV_AUTH_SKIP_HOSTS.split(',').map((h) => h.trim().toLowerCase()).filter(Boolean),
}))
.route('/vip', createVipSurface({ vipOrigin: config.VIP_ORIGIN }))
.route('/clients', createVipSurface({ vipOrigin: config.VIP_ORIGIN }))
.use('/engine/drafts', serviceTokenAuth(config.SERVICE_TOKEN))
.use('/engine/drafts/*', serviceTokenAuth(config.SERVICE_TOKEN))
.route('/engine/drafts', engineDraftsRouter)
.use('/engine/qualify', serviceTokenAuth(config.SERVICE_TOKEN))
.use('/engine/qualify/*', serviceTokenAuth(config.SERVICE_TOKEN))
.route('/engine/qualify', engineQualifyRouter)
.use('/engine/tour-stops', serviceTokenAuth(config.SERVICE_TOKEN))
.use('/engine/tour-stops/*', serviceTokenAuth(config.SERVICE_TOKEN))
.route('/engine/tour-stops', engineTourStopsRouter)
.use('/engine/opportunity-locations', serviceTokenAuth(config.SERVICE_TOKEN))
.use('/engine/opportunity-locations/*', serviceTokenAuth(config.SERVICE_TOKEN))
.route('/engine/opportunity-locations', engineOpportunityLocationsRouter)
.use('/engine/tour-optimizer', serviceTokenAuth(config.SERVICE_TOKEN))
.use('/engine/tour-optimizer/*', serviceTokenAuth(config.SERVICE_TOKEN))
.route('/engine/tour-optimizer', engineTourOptimizerRouter)
.use('/public/*', corsMiddleware('public-read'))
.use('/public/*', rateLimitMiddleware('public'))
.route(
'/public',
createPublicSurface({
analytics: {
collectorUrl: config.ANALYTICS_COLLECTOR_URL,
writeKey: config.ANALYTICS_WRITE_KEY,
},
bookings: {
mailer,
providerEmail: config.CONTACT_PROVIDER_EMAIL,
},
calls: {
twilioAuthToken: config.TWILIO_AUTH_TOKEN,
webhookBaseUrl: config.QUINN_API_BASE_URL,
},
contact: {
mailer,
providerEmail: config.CONTACT_PROVIDER_EMAIL,
hcaptchaSecret: config.CONTACT_HCAPTCHA_SECRET,
providerSlug: config.CONTACT_PROVIDER_SLUG,
},
touring: {
mailer,
providerEmail: config.CONTACT_PROVIDER_EMAIL,
providerSlug: config.CONTACT_PROVIDER_SLUG,
},
waitlist: {
mailer,
providerEmail: config.CONTACT_PROVIDER_EMAIL,
providerSlug: config.CONTACT_PROVIDER_SLUG,
},
roster: {
myBaseUrl: config.QUINN_MY_BASE_URL,
serviceToken: config.QUINN_MY_SERVICE_TOKEN,
mailer,
providerEmail: config.ROSTER_TO_EMAIL ?? config.CONTACT_PROVIDER_EMAIL,
},
}),
);
logger.info('quinn.api listening', { port: config.PORT });
if (typeof Bun === 'undefined') {
const { serve } = await import('@hono/node-server');
serve({ fetch: app.fetch, port: config.PORT });
}
export default { port: config.PORT, fetch: app.fetch, idleTimeout: 240 };