Files
Compass/backend/api/src/app.ts

821 lines
26 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 {getOptions} from 'api/get-options'
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 {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 {markAllNotifsRead} from './mark-all-notifications-read'
import {removePinnedPhoto} from './remove-pinned-photo'
import {report} from './report'
import {rsvpEvent} from './rsvp-event'
import {searchLocation} 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()
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 app or browser console while logged in (JavaScript/TypeScript):**
\`\`\`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': getOptions,
'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': searchLocation,
'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,
'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,
// '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(),
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`,
})
}
})