400 lines
18 KiB
TypeScript
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 };
|