Files
Compass/backend/api/src/app.ts
2026-04-01 18:56:41 +02:00

829 lines
27 KiB
TypeScript

import path from 'node:path'
import {hrtime} from 'node:process'
import {contact} from 'api/contact'
import {createVote} from 'api/create-vote'
import {deleteMessage} from 'api/delete-message'
import {editMessage} from 'api/edit-message'
import {getChannelMemberships} from 'api/get-channel-memberships'
import {getLastSeenChannelTime, setChannelLastSeenTime} from 'api/get-channel-seen-time'
import {getHiddenProfiles} from 'api/get-hidden-profiles'
import {getLastMessages} from 'api/get-last-messages'
import {getMessagesCountEndpoint} from 'api/get-messages-count'
import {getOptionsEndpoint} from 'api/get-options'
import {getPinnedCompatibilityQuestions} from 'api/get-pinned-compatibility-questions'
import {getChannelMessagesEndpoint} from 'api/get-private-messages'
import {getUser} from 'api/get-user'
import {hideProfile} from 'api/hide-profile'
import {reactToMessage} from 'api/react-to-message'
import {saveSubscription} from 'api/save-subscription'
import {saveSubscriptionMobile} from 'api/save-subscription-mobile'
import {sendSearchNotifications} from 'api/send-search-notifications'
import {localSendTestEmail} from 'api/test'
import {unhideProfile} from 'api/unhide-profile'
import {updateCompatibilityQuestionPin} from 'api/update-compatibility-question-pin'
import {updateConnectionInterests} from 'api/update-connection-interests'
import {updateOptions} from 'api/update-options'
import {vote} from 'api/vote'
import {API, type APIPath} from 'common/api/schema'
import {APIError, APIErrors, pathWithPrefix} from 'common/api/utils'
import {sendDiscordMessage} from 'common/discord/core'
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
import {IS_LOCAL} from 'common/hosting/constants'
import {filterDefined} from 'common/util/array'
import cors from 'cors'
import * as crypto from 'crypto'
import express, {type ErrorRequestHandler, type RequestHandler} from 'express'
import {OpenAPIV3} from 'openapi-types'
import {withMonitoringContext} from 'shared/monitoring/context'
import {log} from 'shared/monitoring/log'
import {metrics} from 'shared/monitoring/metrics'
import swaggerUi from 'swagger-ui-express'
import {z, ZodFirstPartyTypeKind, ZodTypeAny} from 'zod'
import {git} from './../metadata.json'
import {version as pkgVersion} from './../package.json'
import {banUser} from './ban-user'
import {blockUser, unblockUser} from './block-user'
import {cancelEvent} from './cancel-event'
import {cancelRsvp} from './cancel-rsvp'
import {getCompatibleProfilesHandler} from './compatible-profiles'
import {createBookmarkedSearch} from './create-bookmarked-search'
import {createComment} from './create-comment'
import {createCompatibilityQuestion} from './create-compatibility-question'
import {createEvent} from './create-event'
import {createPrivateUserMessage} from './create-private-user-message'
import {createPrivateUserMessageChannel} from './create-private-user-message-channel'
import {createProfile} from './create-profile'
import {createUser} from './create-user'
import {createUserAndProfile} from './create-user-and-profile'
import {deleteBookmarkedSearch} from './delete-bookmarked-search'
import {deleteCompatibilityAnswer} from './delete-compatibility-answer'
import {deleteMe} from './delete-me'
import {getCompatibilityQuestions} from './get-compatibililty-questions'
import {getConnectionInterestsEndpoint} from './get-connection-interests'
import {getCurrentPrivateUser} from './get-current-private-user'
import {getEvents} from './get-events'
import {getLikesAndShips} from './get-likes-and-ships'
import {getMe} from './get-me'
import {getNotifications} from './get-notifications'
import {getProfileAnswers} from './get-profile-answers'
import {getProfiles} from './get-profiles'
import {getSupabaseToken} from './get-supabase-token'
import {getUserAndProfileHandler} from './get-user-and-profile'
import {getUserDataExport} from './get-user-data-export'
import {hasFreeLike} from './has-free-like'
import {health} from './health'
import {type APIHandler, typedEndpoint} from './helpers/endpoint'
import {hideComment} from './hide-comment'
import {leavePrivateUserMessageChannel} from './leave-private-user-message-channel'
import {likeProfile} from './like-profile'
import {llmExtractProfileEndpoint} from './llm-extract-profile'
import {markAllNotifsRead} from './mark-all-notifications-read'
import {removePinnedPhoto} from './remove-pinned-photo'
import {report} from './report'
import {rsvpEvent} from './rsvp-event'
import {searchLocationEndpoint} from './search-location'
import {searchNearCity} from './search-near-city'
import {searchUsers} from './search-users'
import {setCompatibilityAnswer} from './set-compatibility-answer'
import {setLastOnlineTime} from './set-last-online-time'
import {shipProfiles} from './ship-profiles'
import {starProfile} from './star-profile'
import {stats} from './stats'
import {updateEvent} from './update-event'
import {updateMe} from './update-me'
import {updateNotifSettings} from './update-notif-setting'
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
import {updateProfileEndpoint} from './update-profile'
import {updateUserLocale} from './update-user-locale'
import {validateUsernameEndpoint} from './validate-username'
// const corsOptions: CorsOptions = {
// origin: ['*'], // Only allow requests from this domain
// methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
// allowedHeaders: ['Content-Type', 'Authorization'],
// credentials: true, // if you use cookies or auth headers
// };
const allowCorsUnrestricted: RequestHandler = cors({})
function cacheController(policy?: string): RequestHandler {
return (_req, res, next) => {
if (policy) res.appendHeader('Cache-Control', policy)
next()
}
}
const requestMonitoring: RequestHandler = (req, _res, next) => {
const traceContext = req.get('X-Cloud-Trace-Context')
const traceId = traceContext ? traceContext.split('/')[0] : crypto.randomUUID()
const context = {endpoint: req.path, traceId}
withMonitoringContext(context, () => {
const startTs = hrtime.bigint()
log(`${req.method} ${req.url}`)
metrics.inc('http/request_count', {endpoint: req.path})
next()
// There's a bug worth flagging in that middleware. The timing/cleanup code after next() won't work as you expect:
// next() is synchronous — it just hands off to the next middleware. The response hasn't been sent by the time endTs is captured. To measure actual latency you'd want to hook into res.on('finish', ...)
const endTs = hrtime.bigint()
const latencyMs = Number(endTs - startTs) / 1e6
metrics.push('http/request_latency', latencyMs, {endpoint: req.path})
})
}
const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
if (error instanceof APIError) {
log.info(error)
if (!res.headersSent) {
res.status(error.code).json(error.toJSON())
}
} else {
log.error(error)
if (!res.headersSent) {
const apiError = APIErrors.internalServerError(error.message || 'Internal server error', {
originalError: error.toString(),
context: 'Unhandled exception in request processing',
})
res.status(500).json(apiError.toJSON())
}
}
}
export const app = express()
app.use(requestMonitoring)
const schemaCache = new WeakMap<ZodTypeAny, any>()
export function zodToOpenApiSchema(zodObj: ZodTypeAny): any {
if (schemaCache.has(zodObj)) {
return schemaCache.get(zodObj)
}
const def: any = (zodObj as any)._def
const typeName = def.typeName as ZodFirstPartyTypeKind
// Placeholder so recursive references can point here
const placeholder: any = {}
schemaCache.set(zodObj, placeholder)
let schema: any
switch (typeName) {
case 'ZodString':
schema = {type: 'string'}
break
case 'ZodNumber':
schema = {type: 'number'}
break
case 'ZodBoolean':
schema = {type: 'boolean'}
break
case 'ZodEnum':
schema = {type: 'string', enum: def.values}
break
case 'ZodArray':
schema = {type: 'array', items: zodToOpenApiSchema(def.type)}
break
case 'ZodObject': {
const shape = def.shape()
const properties: Record<string, any> = {}
const required: string[] = []
for (const key in shape) {
const child = shape[key]
properties[key] = zodToOpenApiSchema(child)
if (!child.isOptional()) required.push(key)
}
schema = {
type: 'object',
properties,
...(required.length ? {required} : {}),
}
break
}
case 'ZodRecord':
schema = {
type: 'object',
additionalProperties: zodToOpenApiSchema(def.valueType),
}
break
case 'ZodIntersection': {
const left = zodToOpenApiSchema(def.left)
const right = zodToOpenApiSchema(def.right)
schema = {allOf: [left, right]}
break
}
case 'ZodLazy':
schema = {type: 'object', description: 'Lazy schema - details omitted'}
break
case 'ZodUnion':
schema = {
oneOf: def.options.map((opt: ZodTypeAny) => zodToOpenApiSchema(opt)),
}
break
default:
schema = {type: 'string'} // fallback for unhandled
}
Object.assign(placeholder, schema)
return schema
}
function inferTag(route: string) {
let tag = 'General'
if (route.includes('user') || route.includes('profile')) tag = 'Profiles'
if (route.includes('auth') || route.includes('login') || route === 'me') tag = 'Authentication'
if (route.includes('search') || route.includes('location')) tag = 'Search'
if (route.includes('message') || route.includes('channel')) tag = 'Messaging'
if (route.includes('compatibility') || route.includes('question')) tag = 'Compatibility'
if (
route.includes('like') ||
route.includes('ship') ||
route.includes('star') ||
route.includes('block')
)
tag = 'Relations'
if (route.includes('event') || route.includes('rsvp')) tag = 'Events'
if (route.includes('notification') || route.includes('notif')) tag = 'Notifications'
if (route.includes('comment')) tag = 'Comments'
if (route.includes('report') || route.includes('ban')) tag = 'Moderation'
if (route.includes('option') || route.includes('locale')) tag = 'Utilities'
return tag
}
function generateSwaggerPaths(api: typeof API) {
const paths: Record<string, any> = {}
for (const [route, config] of Object.entries(api)) {
const pathKey = '/' + route.replace(/_/g, '-') // optional: convert underscores to dashes
const method = config.method.toLowerCase()
const summary = (config as any).summary ?? route
const tag = (config as any).tag ?? inferTag(route)
const operation: any = {
summary,
description: (config as any).description ?? '',
tags: [tag],
responses: {
200: {
description: 'Success',
content: {
'application/json': {
schema: {type: 'object'},
example: (config as any).exampleResponse ?? {},
},
},
},
400: {
description: 'Bad Request - Invalid input or malformed request',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
message: {type: 'string'},
details: {type: 'object'},
},
},
},
},
},
401: {
description: 'Unauthorized - Authentication required',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
message: {type: 'string'},
},
},
},
},
},
403: {
description: 'Forbidden - Insufficient permissions',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
message: {type: 'string'},
},
},
},
},
},
404: {
description: 'Not Found - Resource does not exist',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
message: {type: 'string'},
},
},
},
},
},
500: {
description: 'Internal Server Error - Something went wrong on our end',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
message: {type: 'string'},
},
},
},
},
},
},
}
// Include props in request body for POST/PUT
if (config.props && ['post', 'put', 'patch'].includes(method)) {
operation.requestBody = {
required: true,
content: {
'application/json': {
schema: zodToOpenApiSchema(config.props),
example: (config as any).exampleRequest ?? {},
},
},
}
}
// Include props as query parameters for GET/DELETE
if (config.props && ['get', 'delete'].includes(method)) {
const shape = (config.props as z.ZodObject<any>)._def.shape()
operation.parameters = Object.entries(shape).map(([key, zodType]) => {
const typeMap: Record<string, string> = {
ZodString: 'string',
ZodNumber: 'number',
ZodBoolean: 'boolean',
ZodArray: 'array',
}
const t = zodType as z.ZodTypeAny // assert type to ZodTypeAny
const typeName = t._def.typeName
return {
name: key,
in: 'query',
description: (config as any).paramDescriptions?.[key] ?? '',
required: !(t.isOptional ?? false),
schema: {type: typeMap[typeName] ?? 'string'},
}
})
}
paths[pathKey] = {
[method]: operation,
}
if (config.authed) {
operation.security = [{BearerAuth: []}]
}
}
return paths
}
const apiKey = process.env.NEXT_PUBLIC_FIREBASE_API_KEY ?? 'API_KEY'
const swaggerDocument: OpenAPIV3.Document = {
openapi: '3.0.0',
info: {
title: 'Compass API',
description: `
Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. Our API provides programmatic access to core platform features including user profiles, messaging, compatibility scoring, and community features.
## Access Tiers
### Tier 1 — Public Access (no authentication required)
Some endpoints are publicly accessible without authentication, such as events and server health. These are marked as **public** in the endpoint documentation.
### Tier 2 — User Access (Firebase authentication required)
Most endpoints require a valid Firebase JWT token. This gives you access to your own user data, profile, messages, and all interactive features.
To obtain a token:
**In your browser console while logged in (CTRL+SHIFT+C, then select the Console tab):**
\`\`\`js
const db = await new Promise((res, rej) => {
const req = indexedDB.open('firebaseLocalStorageDb')
req.onsuccess = () => res(req.result)
req.onerror = rej
})
const req = db.transaction('firebaseLocalStorage', 'readonly').objectStore('firebaseLocalStorage').getAll()
req.onsuccess = () => {
const id_token = req.result[0].value.stsTokenManager.accessToken
console.log('YOUR_FIREBASE_JWT_TOKEN is the string below:')
console.log(id_token)
copy(id_token)
}
\`\`\`
**For testing (REST):**
\`\`\`bash
curl 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${apiKey}' \\
-H 'Content-Type: application/json' \\
--data '{"email":"you@example.com","password":"yourpassword","returnSecureToken":true}'
# Use the returned idToken as your Bearer token
\`\`\`
Tokens expire after **1 hour**. Refresh by calling \`getIdToken(true)\`.
Pass the token in the Authorization header for all authenticated requests:
\`\`\`
Authorization: Bearer YOUR_FIREBASE_JWT_TOKEN
\`\`\`
In the API docs, authenticate through the green button at the bottom right of this section.
**Don't have an account?** [Register on Compass](${DEPLOYED_WEB_URL}/register) to get started.
## Rate Limiting
API requests are subject to rate limiting to ensure fair usage and platform stability. Exceeding limits will result in a \`429 Too Many Requests\` response. Rate limits are applied per authenticated user. Unauthenticated requests are limited by IP.
## Versioning
This documentation reflects API version ${pkgVersion}. Endpoints marked as **deprecated** will include a \`Sunset\` header indicating when they will be removed, and a \`Link\` header pointing to the replacement endpoint. Breaking changes are avoided where possible.
## Error Handling
All API responses follow a consistent error format:
\`\`\`json
{
"message": "Human-readable error description",
"details": { /* Optional additional context */ }
}
\`\`\`
Common HTTP status codes:
- \`200\` Success
- \`400\` Bad Request — invalid or missing input
- \`401\` Unauthorized — missing or expired token
- \`403\` Forbidden — valid token but insufficient permissions
- \`404\` Not Found — resource does not exist
- \`429\` Too Many Requests — rate limit exceeded
- \`500\` Internal Server Error
## Open Source
Compass is open source. Contributions, bug reports, and feature requests are welcome on [GitHub](https://github.com/CompassConnections/Compass).
## Git Information
Commit: ${git.revision} (${git.commitDate})`,
version: pkgVersion,
contact: {
name: 'Compass Team',
email: 'hello@compassmeet.com',
url: 'https://compassmeet.com',
},
},
paths: generateSwaggerPaths(API),
components: {
securitySchemes: {
BearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'Firebase JWT token obtained through authentication',
},
ApiKeyAuth: {
type: 'apiKey',
in: 'header',
name: 'x-api-key',
description: 'API key for internal/non-user endpoints',
},
},
},
tags: filterDefined([
{
name: 'General',
description: 'General endpoints and health checks',
},
{
name: 'Authentication',
description: 'User authentication and account management endpoints',
},
{
name: 'Users',
description: 'User accounts',
},
{
name: 'Profiles',
description: 'User profile creation, retrieval, updating, and deletion',
},
{
name: 'Search',
description: 'User discovery and search functionality',
},
{
name: 'Messages',
description: 'Direct messaging between users',
},
{
name: 'Compatibility',
description: 'Compatibility questions, answers, and scoring',
},
{
name: 'Relations',
description: 'User relationships (likes, ships, blocks, comments)',
},
{
name: 'Notifications',
description: 'User notifications and preferences',
},
{
name: 'Events',
description: 'Community events and RSVP management',
},
{
name: 'Votes',
description: 'Voting system for user content and polls',
},
{
name: 'Moderation',
description: 'Report system and user moderation',
},
{
name: 'Admin',
description: 'Administrative functions including user bans and moderation',
},
{
name: 'Contact',
description: 'Contact form and support requests',
},
{
name: 'Utilities',
description: 'Helper functions and utilities',
},
{
name: 'Internal',
description: 'Internal API endpoints for system operations',
},
IS_LOCAL && {
name: 'Local',
description: 'Local development and testing endpoints',
},
]),
} as OpenAPIV3.Document
// Triggers Missing parameter name at index 3: *; visit https://git.new/pathToRegexpError for info
// May not be necessary
// app.options('*', allowCorsUnrestricted)
const handlers: {[k in APIPath]: APIHandler<k>} = {
'ban-user': banUser,
'compatible-profiles': getCompatibleProfilesHandler,
contact: contact,
'create-bookmarked-search': createBookmarkedSearch,
'create-comment': createComment,
'create-compatibility-question': createCompatibilityQuestion,
'create-private-user-message': createPrivateUserMessage,
'create-private-user-message-channel': createPrivateUserMessageChannel,
'create-profile': createProfile,
'create-user': createUser,
'create-user-and-profile': createUserAndProfile,
'create-vote': createVote,
'delete-bookmarked-search': deleteBookmarkedSearch,
'delete-compatibility-answer': deleteCompatibilityAnswer,
'delete-message': deleteMessage,
'edit-message': editMessage,
'get-channel-memberships': getChannelMemberships,
'get-channel-messages': getChannelMessagesEndpoint,
'get-channel-seen-time': getLastSeenChannelTime,
'get-last-messages': getLastMessages,
'get-compatibility-questions': getCompatibilityQuestions,
'get-likes-and-ships': getLikesAndShips,
'get-messages-count': getMessagesCountEndpoint,
'get-notifications': getNotifications,
'get-options': getOptionsEndpoint,
'get-profile-answers': getProfileAnswers,
'get-profiles': getProfiles,
'get-supabase-token': getSupabaseToken,
'has-free-like': hasFreeLike,
'hide-comment': hideComment,
'hide-profile': hideProfile,
'unhide-profile': unhideProfile,
'get-hidden-profiles': getHiddenProfiles,
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
'like-profile': likeProfile,
'mark-all-notifs-read': markAllNotifsRead,
'me/delete': deleteMe,
'me/data': getUserDataExport,
'me/private': getCurrentPrivateUser,
'me/update': updateMe,
'react-to-message': reactToMessage,
'remove-pinned-photo': removePinnedPhoto,
'save-subscription': saveSubscription,
'save-subscription-mobile': saveSubscriptionMobile,
'search-location': searchLocationEndpoint,
'search-near-city': searchNearCity,
'search-users': searchUsers,
'set-channel-seen-time': setChannelLastSeenTime,
'set-compatibility-answer': setCompatibilityAnswer,
'set-last-online-time': setLastOnlineTime,
'ship-profiles': shipProfiles,
'star-profile': starProfile,
'update-notif-settings': updateNotifSettings,
'update-options': updateOptions,
'update-user-locale': updateUserLocale,
'update-private-user-message-channel': updatePrivateUserMessageChannel,
'update-profile': updateProfileEndpoint,
'update-compatibility-question-pin': updateCompatibilityQuestionPin,
'get-pinned-compatibility-questions': getPinnedCompatibilityQuestions,
'get-connection-interests': getConnectionInterestsEndpoint,
'update-connection-interest': updateConnectionInterests,
'user/by-id/:id': getUser,
'user/by-id/:id/block': blockUser,
'user/by-id/:id/unblock': unblockUser,
vote: vote,
'validate-username': validateUsernameEndpoint,
'llm-extract-profile': llmExtractProfileEndpoint,
// 'user/:username': getUser,
// 'user/:username/lite': getDisplayUser,
// 'user/by-id/:id/lite': getDisplayUser,
'cancel-event': cancelEvent,
'cancel-rsvp': cancelRsvp,
'create-event': createEvent,
'get-events': getEvents,
'rsvp-event': rsvpEvent,
'update-event': updateEvent,
health: health,
stats: stats,
me: getMe,
'get-user-and-profile': getUserAndProfileHandler,
report: report,
}
Object.entries(handlers).forEach(([path, handler]) => {
const api = API[path as APIPath]
const cache = cacheController((api as any).cache)
const url = pathWithPrefix(('/' + path) as APIPath)
const apiRoute = [
url,
express.json({limit: '1mb'}),
allowCorsUnrestricted,
cache,
typedEndpoint(path as any, handler as any),
apiErrorHandler,
] as const
if (api.method === 'POST') {
app.post(...apiRoute)
} else if (api.method === 'GET') {
app.get(...apiRoute)
// } else if (api.method === 'PUT') {
// app.put(...apiRoute)
} else {
throw new Error('Unsupported API method')
}
})
// Internal Endpoints
app.post(pathWithPrefix('/internal/send-search-notifications'), async (req, res) => {
const apiKey = req.header('x-api-key')
if (apiKey !== process.env.COMPASS_API_KEY) {
return res.status(401).json({error: 'Unauthorized'})
}
try {
const result = await sendSearchNotifications()
return res.status(200).json(result)
} catch (err) {
console.error('Failed to send notifications:', err)
await sendDiscordMessage(
'Failed to send [daily notifications](https://console.cloud.google.com/cloudscheduler?project=compass-130ba) for bookmarked searches...',
'health',
)
return res.status(500).json({error: 'Internal server error'})
}
})
const responses = {
200: {
description: 'Request successful',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
status: {type: 'string', example: 'success'},
},
},
},
},
},
401: {
description: 'Unauthorized (e.g., invalid or missing API key)',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
error: {type: 'string', example: 'Unauthorized'},
},
},
},
},
},
500: {
description: 'Internal server error during request processing',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
error: {type: 'string', example: 'Internal server error'},
},
},
},
},
},
}
swaggerDocument.paths['/internal/send-search-notifications'] = {
post: {
summary: 'Trigger daily search notifications',
description:
'Internal endpoint used by Compass schedulers to send daily notifications for bookmarked searches. Requires a valid `x-api-key` header.',
tags: ['Internal'],
security: [
{
ApiKeyAuth: [],
},
],
requestBody: {
required: false,
},
responses: responses,
},
} as any
// Local Endpoints
if (IS_LOCAL) {
app.post(pathWithPrefix('/local/send-test-email'), async (req, res) => {
if (!IS_LOCAL) {
return res.status(401).json({error: 'Unauthorized'})
}
try {
const result = await localSendTestEmail()
return res.status(200).json(result)
} catch (err) {
return res.status(500).json({error: err})
}
})
swaggerDocument.paths['/local/send-test-email'] = {
post: {
summary: 'Send a test email',
description: 'Local endpoint to send a test email.',
tags: ['Local'],
requestBody: {
required: false,
},
responses: responses,
},
} as any
}
const rootPath = pathWithPrefix('/')
app.get(
rootPath,
swaggerUi.setup(swaggerDocument, {
customSiteTitle: 'Compass API Docs',
customCssUrl: '/swagger.css',
customJs: `
const meta = document.createElement('meta');
meta.name = 'viewport';
meta.content = 'width=device-width, initial-scale=1';
document.head.appendChild(meta);
`,
}),
)
app.use(rootPath, swaggerUi.serve)
app.use(express.static(path.join(__dirname, 'public')))
app.use(allowCorsUnrestricted, (req, res) => {
if (req.method === 'OPTIONS') {
res.status(200).send()
} else {
res
.status(404)
.set('Content-Type', 'application/json')
.json({
message: `This is the Compass API, but the requested route '${req.path}' does not exist. Please check your URL for any misspellings, the docs at https://api.compassmeet.com, or simply refer to app.ts on GitHub`,
})
}
})