mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-03-26 02:21:06 -04:00
821 lines
26 KiB
TypeScript
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`,
|
|
})
|
|
}
|
|
})
|