Skip to Content
ChannelsChannels API — Fastify + TypeScript Implementation Guide

Channels API — Fastify + TypeScript Implementation Guide

This document describes how to recreate the Channels API (originally in ASP.NET Core) using Fastify and TypeScript.

The original API is split across two controllers:

  • ChannelsController → CRUD and management (/api/web/v1/channels)
  • ChannelController → OAuth flows for each provider (/api/web/v1/channel)

Table of Contents

  1. Project Setup
  2. Folder Structure
  3. Types & Interfaces
  4. Constants
  5. Routes — Channels Management
  6. Routes — OAuth Flows
  7. Service Layer
  8. Plugin Registration

1. Project Setup

npm init -y npm install fastify @fastify/jwt @fastify/sensible fastify-plugin dotenv mongodb npm install -D typescript @types/node ts-node nodemon npx tsc --init

tsconfig.json — key settings:

{ "compilerOptions": { "target": "ES2020", "module": "commonjs", "rootDir": "src", "outDir": "dist", "strict": true, "esModuleInterop": true } }

src/server.ts:

import Fastify from 'fastify'; import { channelsRoutes } from './routes/channels.routes'; import { channelOAuthRoutes } from './routes/channel-oauth.routes'; const app = Fastify({ logger: true }); app.register(channelsRoutes, { prefix: '/api/web/v1/channels' }); app.register(channelOAuthRoutes, { prefix: '/api/web/v1/channel' }); app.listen({ port: 3000 }, (err) => { if (err) process.exit(1); });

2. Folder Structure

src/ ├── constants/ │ ├── channels.constants.ts │ ├── channel-categories.constants.ts │ └── authentication-type.constants.ts ├── types/ │ └── channel.types.ts ├── services/ │ ├── channel.service.ts │ ├── audit-log.service.ts │ └── bus.service.ts ├── routes/ │ ├── channels.routes.ts ← CRUD + management │ └── channel-oauth.routes.ts ← OAuth flows per provider ├── middlewares/ │ └── auth.middleware.ts └── server.ts

3. Types & Interfaces

src/types/channel.types.ts:

export type AuthenticationType = 'None' | 'OAuth' | 'Basic' | 'ApiKey'; export interface TokenInfo { accessToken: string; refreshToken?: string; accessTokenExpiryDate?: Date; scope?: string; secret?: string; } export interface SubChannel { id: string; title: string; parentId?: string; tokenInfo?: TokenInfo; } export interface UserInfo { id: string; name?: string; } export interface ApiKeyInfo { key: string; } export interface BasicAuthInfo { userName: string; password: string; } export interface ChannelConnectionHistory { operation: string; // 'Connect' | 'Disconnect' executedById: string; executedByName?: string; executedOn: Date; message: string; } export interface ChannelModel { id?: string; title: string; url?: string; type: string; authenticationType: AuthenticationType; isConnected?: boolean; lastConnectedOn?: Date; apiKeyInfo?: ApiKeyInfo; basicInfo?: BasicAuthInfo; tokenInfo?: TokenInfo; subChannelInfo?: SubChannel; userInfo?: UserInfo; categories?: string[]; connectionHistory?: ChannelConnectionHistory[]; tenantId?: string; createdByUserId?: string; } // --- DTOs --- export interface AddEditChannelDTO { title: string; url?: string; type: string; apiKey?: string; userName?: string; password?: string; } export interface RenameChannelDTO { channelName: string; } export interface ChannelFilterModel { state: string; // 'all' | 'connected' | 'disconnected' categories: string[]; } export interface LookUpKeyValue { label: string; value: string; } export interface ChannelLookupListDTO { channels: LookUpKeyValue[]; categories: LookUpKeyValue[]; } export interface ChannelBasicInfoDTO { id: string; title?: string; type: string; } export interface SocialChannelsLookupListDTO { channels: ChannelBasicInfoDTO[]; } export interface ChannelsStatusViewModel { existing: ChannelModel[]; allChannels: ChannelModel[]; yetToConnect: ChannelModel[]; }

4. Constants

src/constants/channels.constants.ts:

export const Channels = { Facebook: 'Facebook', Instagram: 'Instagram', LinkedIn: 'LinkedIn', Website: 'Website', LandingPage: 'LandingPage', GoogleSearchConsole: 'Google Search Console', BingWebMasterTools: 'Bing WebMaster Tools', GoogleAds: 'Google Ads', GoogleAnalytics: 'Google Analytics', GoogleBusinessProfile: 'Google Business Profile', ZohoBooks: 'ZohoBooks', MetaAds: 'Meta Ads', LinkedInAds: 'LinkedIn Ads', WordPress: 'WordPress', } as const; export type ChannelType = typeof Channels[keyof typeof Channels]; export const CHANNELS_WITH_URL: ChannelType[] = [ Channels.Website, Channels.LandingPage, Channels.WordPress, ]; export const CHANNEL_AUTH_TYPE_MAP: Record<string, string> = { [Channels.Facebook]: 'OAuth', [Channels.Instagram]: 'OAuth', [Channels.LinkedIn]: 'OAuth', [Channels.Website]: 'None', [Channels.LandingPage]: 'None', [Channels.GoogleSearchConsole]: 'OAuth', [Channels.BingWebMasterTools]: 'OAuth', [Channels.GoogleAds]: 'OAuth', [Channels.GoogleAnalytics]: 'OAuth', [Channels.GoogleBusinessProfile]: 'OAuth', [Channels.ZohoBooks]: 'OAuth', [Channels.MetaAds]: 'OAuth', [Channels.LinkedInAds]: 'OAuth', [Channels.WordPress]: 'Basic', }; export function getAuthenticationType(channelType: string): string | undefined { return CHANNEL_AUTH_TYPE_MAP[channelType]; } export function isValidChannel(type: string): boolean { return Object.values(Channels).includes(type as ChannelType); }

src/constants/channel-categories.constants.ts:

export const ChannelCategories = { SocialMedia: 'Social Media', AIO: 'AIO', Blog: 'Blog', SEO: 'SEO', PerformanceMarketing: 'Performance Marketing', Maps: 'Maps', Communication: 'Communication', Accounting: 'Accounting', EmailMarketing: 'Email Marketing', } as const; export const CATEGORY_ALL = Object.values(ChannelCategories).map(c => ({ label: c, value: c }));

5. Routes — Channels Management (/api/web/v1/channels)

src/routes/channels.routes.ts:

import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { ChannelService } from '../services/channel.service'; import { AuditLogService } from '../services/audit-log.service'; import { BusService } from '../services/bus.service'; import { AddEditChannelDTO, RenameChannelDTO } from '../types/channel.types'; import { isValidChannel, getAuthenticationType, CHANNELS_WITH_URL } from '../constants/channels.constants'; import { CATEGORY_ALL } from '../constants/channel-categories.constants'; export async function channelsRoutes(app: FastifyInstance) { // ───────────────────────────────────────────── // GET /api/web/v1/channels // Query: state, categories[], sortBy // Returns: list of channels for the tenant // ───────────────────────────────────────────── app.get('/', async (request: FastifyRequest, reply: FastifyReply) => { const { state = 'all', sortBy = 'latest', categories } = request.query as { state?: string; sortBy?: string; categories?: string | string[]; }; const tenantId = request.user.tenantId; const categoriesArr = Array.isArray(categories) ? categories : categories ? [categories] : []; const channels = await ChannelService.searchChannels({ state, categories: categoriesArr }, sortBy, tenantId); return reply.send(channels); }); // ───────────────────────────────────────────── // GET /api/web/v1/channels/status // Returns: existing, allChannels, yetToConnect // ───────────────────────────────────────────── app.get('/status', async (request: FastifyRequest, reply: FastifyReply) => { const { state = 'all', sortBy = 'latest', categories } = request.query as { state?: string; sortBy?: string; categories?: string | string[]; }; const tenantId = request.user.tenantId; const categoriesArr = Array.isArray(categories) ? categories : categories ? [categories] : []; const existing = await ChannelService.searchChannels({ state, categories: categoriesArr }, sortBy, tenantId); const allChannels = ChannelService.getAllChannels(categoriesArr); const yetToConnect = ChannelService.getYetToConnectChannels(existing, categoriesArr); return reply.send({ existing, allChannels, yetToConnect }); }); // ───────────────────────────────────────────── // GET /api/web/v1/channels/lookups?type=... // Returns: channels list + categories for dropdowns // ───────────────────────────────────────────── app.get('/lookups', async (request: FastifyRequest, reply: FastifyReply) => { const { type } = request.query as { type: string }; const tenantId = request.user.tenantId; const channels = await ChannelService.getChannelsByType(tenantId, type); const lookupList = channels.map(c => ({ label: c.title, value: c.id })); return reply.send({ channels: lookupList, categories: CATEGORY_ALL }); }); // ───────────────────────────────────────────── // GET /api/web/v1/channels/lookups/social // Returns: connected social channels for dropdowns // ───────────────────────────────────────────── app.get('/lookups/social', async (request: FastifyRequest, reply: FastifyReply) => { const tenantId = request.user.tenantId; const channels = await ChannelService.getSocialChannels(tenantId); const lookupList = channels.map(c => ({ id: c.id, title: c.subChannelInfo?.title, type: c.type, })); return reply.send({ channels: lookupList }); }); // ───────────────────────────────────────────── // GET /api/web/v1/channels/:id // Returns: single channel by ID // ───────────────────────────────────────────── app.get('/:id', async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.params as { id: string }; const channel = await ChannelService.getChannel(id); if (!channel) { return reply.status(404).send({ message: 'Channel not found' }); } return reply.send(channel); }); // ───────────────────────────────────────────── // POST /api/web/v1/channels // Body: AddEditChannelDTO // Creates and optionally connects a channel. // Auth types: None (auto-connect), Basic (test connection), ApiKey, OAuth (deferred to /channel routes) // ───────────────────────────────────────────── app.post('/', async (request: FastifyRequest, reply: FastifyReply) => { const body = request.body as AddEditChannelDTO; const userId = request.user.id; const tenantId = request.user.tenantId; // Validation if (!body.title) { return reply.status(400).send({ message: 'Channel title is required' }); } if (!isValidChannel(body.type)) { return reply.status(400).send({ message: 'Invalid Channel' }); } if (CHANNELS_WITH_URL.includes(body.type as any) && !body.url) { return reply.status(400).send({ message: 'Url is required' }); } const authType = getAuthenticationType(body.type); if (!authType) { return reply.status(400).send({ message: 'Authentication type not configured for this channel' }); } if (authType === 'ApiKey' && !body.apiKey) { return reply.status(400).send({ message: 'Api Key is required' }); } if (authType === 'Basic') { if (!body.userName) return reply.status(400).send({ message: 'User Name is required' }); if (!body.password) return reply.status(400).send({ message: 'Password is required' }); } const user = await UserService.getUser(userId); const newChannelData = { title: body.title, url: body.url, type: body.type, authenticationType: authType, apiKeyInfo: authType === 'ApiKey' ? { key: body.apiKey! } : undefined, basicInfo: authType === 'Basic' ? { userName: body.userName!, password: body.password! } : undefined, categories: ChannelCategoryService.getCategories(body.type), userInfo: { id: user.id, name: user.name }, connectionHistory: [] as any[], }; // Auto-connect channels with no auth if (authType === 'None') { newChannelData.isConnected = true; newChannelData.lastConnectedOn = new Date(); newChannelData.connectionHistory.push({ operation: 'Connect', executedById: userId, executedOn: new Date(), message: 'Connected', }); } // Test WordPress basic auth connection if (authType === 'Basic' && body.type === 'WordPress') { const isConnected = await WordpressService.testConnection(body.url!, body.userName!, body.password!); if (!isConnected) { return reply.status(400).send({ message: 'Connection failed. Please check the provided credentials.' }); } newChannelData.isConnected = true; newChannelData.lastConnectedOn = new Date(); newChannelData.connectionHistory.push({ operation: 'Connect', executedById: userId, executedOn: new Date(), message: 'Connected', }); } const newChannel = await ChannelService.createChannel(newChannelData, userId, tenantId); // Audit log await AuditLogService.create({ category: 'EntityCRUD', entity: 'Channel', operation: 'Create', entityId: newChannel.id, message: `Connected the channel ${newChannel.title}` }, userId, tenantId); // Bus event await BusService.send('entities-default-activities', { entity: 'Channel', entityType: newChannel.type, entityId: newChannel.id, event: 'Connected' }); // Website channel — create or link website record if (body.type === 'Website' || body.type === 'LandingPage') { const exists = await WebsiteService.isWebsiteExisting(body.url!); if (exists) { const website = await WebsiteService.getWebsiteByDomain(body.url!); await ChannelService.updateWebsiteChannelInfo(newChannel.id, website.id, website.name, userId, tenantId); } else { const newWebsite = await WebsiteService.createWebsite({ name: body.title, type: body.type, domain: body.url! }); await ChannelService.updateWebsiteChannelInfo(newChannel.id, newWebsite.id, newWebsite.name, userId, tenantId); await BusService.send('website-crawl-v2', { tenantId, id: newWebsite.id, maxPages: 1000 }); } } return reply.status(201).send(newChannel); }); // ───────────────────────────────────────────── // POST /api/web/v1/channels/:id/crawl // Triggers a website crawl for the channel // ───────────────────────────────────────────── app.post('/:id/crawl', async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.params as { id: string }; const userId = request.user.id; const tenantId = request.user.tenantId; const channel = await ChannelService.getChannel(id); if (!channel) { return reply.status(404).send({ message: 'Channel not found' }); } const website = await WebsiteService.getWebsiteByDomain(channel.url!); await BusService.send('do-not-know', { url: website.domain }); return reply.send(true); }); // ───────────────────────────────────────────── // PUT /api/web/v1/channels/:id/disconnect // Disconnects the channel, logs audit + fires bus event // ───────────────────────────────────────────── app.put('/:id/disconnect', async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.params as { id: string }; const userId = request.user.id; const tenantId = request.user.tenantId; const channel = await ChannelService.getChannel(id); if (!channel) { return reply.status(404).send({ message: 'Channel not found' }); } const disconnected = await ChannelService.disconnectChannel(id, userId, tenantId, 'Disconnected By User'); await AuditLogService.create({ category: 'EntityCRUD', entity: 'Channel', operation: 'Disconnect', entityId: id, message: `Disconnected the channel ${disconnected.title}` }, userId, tenantId); await BusService.send('entities-default-activities', { entity: 'Channel', entityType: disconnected.type, entityId: id, event: 'Disconnected' }); return reply.send(disconnected); }); // ───────────────────────────────────────────── // DELETE /api/web/v1/channels/:id // Deletes the channel (blocks if pending social posts) // ───────────────────────────────────────────── app.delete('/:id', async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.params as { id: string }; const userId = request.user.id; const tenantId = request.user.tenantId; const channel = await ChannelService.getChannel(id); if (!channel) { return reply.status(404).send({ message: 'Channel not found' }); } const hasPendingPost = await SocialPostService.isPendingPostForChannel(id, tenantId); if (hasPendingPost) { return reply.status(400).send({ message: 'Cannot delete channel with pending posts' }); } const deleted = await ChannelService.deleteChannel(id, userId, tenantId); await AuditLogService.create({ category: 'EntityCRUD', entity: 'Channel', operation: 'Delete', entityId: id, message: `Deleted the channel ${deleted.title}` }, userId, tenantId); await BusService.send('entities-default-activities', { entity: 'Channel', entityType: deleted.type, entityId: id, event: 'Disconnected' }); return reply.send(deleted); }); // ───────────────────────────────────────────── // PUT /api/web/v1/channels/:id/rename // Body: { channelName: string } // ───────────────────────────────────────────── app.put('/:id/rename', async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.params as { id: string }; const body = request.body as RenameChannelDTO; const userId = request.user.id; const tenantId = request.user.tenantId; const renamed = await ChannelService.renameChannel(id, body, userId, tenantId); return reply.send(renamed); }); }

6. Routes — OAuth Flows (/api/web/v1/channel)

Each provider follows the same 3-step OAuth pattern:

1. GET /{provider}/connect → returns the OAuth redirect URL to the client 2. GET /{provider}/callback → handles the code exchange, saves tokens (public/anonymous) 3. GET /{provider}/page/select → renders a page/account selection step (public/anonymous) 4. POST /{provider}/page/select → saves the selected sub-channel (public/anonymous)

Callback and selection routes are anonymous (no auth header required) because the user arrives from the OAuth provider redirect.

src/routes/channel-oauth.routes.ts:

import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { ChannelService } from '../services/channel.service'; export async function channelOAuthRoutes(app: FastifyInstance) { // ── Helper: closes the OAuth popup window ───────────────────────── // GET /api/web/v1/channel/channel/close app.get('/channel/close', async (_req, reply) => { reply.type('text/html').send(`<html><body onload='closeWin()'><script>function closeWin(){window.close();}</script></body></html>`); }); // ════════════════════════════════════════════ // FACEBOOK // ════════════════════════════════════════════ // GET /api/web/v1/channel/facebook/connect?id={channelId} // Returns the Facebook OAuth authorization URL app.get('/facebook/connect', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.query as { id: string }; const url = FacebookService.getAuthUrl(process.env.BASE_URL + 'channel/facebook/callback', id); return reply.send(url); }); // GET /api/web/v1/channel/facebook/callback [Anonymous] app.get('/facebook/callback', async (request: FastifyRequest, reply: FastifyReply) => { const { code, state: channelId } = request.query as { code: string; state: string }; const authInfo = await FacebookService.exchangeCode(code, process.env.BASE_URL + 'channel/facebook/callback'); const channel = await ChannelService.getChannel(channelId); if (!authInfo || !channel) return reply.status(400).send(); channel.isConnected = true; channel.tokenInfo = { accessToken: authInfo.accessToken, accessTokenExpiryDate: authInfo.expireOn, scope: authInfo.scope }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); await ChannelService.addConnectionHistory(channel.id!, 'Connect', channel.createdByUserId!, 'Connected'); // If no page/sub-channel selected yet, redirect to page selection if (!channel.subChannelInfo) { return reply.redirect(`/channel/facebook/page/select?state=${channelId}`); } return reply.redirect('/channel/channel/close'); }); // GET /api/web/v1/channel/facebook/page/select?state={channelId} [Anonymous] // Returns list of Facebook pages for selection app.get('/facebook/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId } = request.query as { state: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const pages = await FacebookService.getPages(channel.tokenInfo!.accessToken); return reply.send({ state: channelId, pages: pages.map(p => ({ id: p.id, title: p.name })) }); }); // POST /api/web/v1/channel/facebook/page/select [Anonymous] // Body: { state: channelId, pageId: string } app.post('/facebook/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId, pageId } = request.body as { state: string; pageId: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const pages = await FacebookService.getPages(channel.tokenInfo!.accessToken); const page = pages.find(p => p.id === pageId); if (!page) return reply.status(400).send(); const pageToken = await FacebookService.getPageToken(page.id, channel.tokenInfo!.accessToken); channel.subChannelInfo = { id: page.id, title: page.name, tokenInfo: { accessToken: pageToken.accessToken } }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); return reply.redirect('/channel/channel/close'); }); // ════════════════════════════════════════════ // INSTAGRAM // ════════════════════════════════════════════ // GET /api/web/v1/channel/instagram/connect?id={channelId} app.get('/instagram/connect', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.query as { id: string }; const url = InstagramService.getAuthUrl(process.env.BASE_URL + 'channel/instagram/callback', id); return reply.send(url); }); // GET /api/web/v1/channel/instagram/callback [Anonymous] app.get('/instagram/callback', async (request: FastifyRequest, reply: FastifyReply) => { const { code, state: channelId } = request.query as { code: string; state: string }; const authInfo = await InstagramService.exchangeCode(code, process.env.BASE_URL + 'channel/instagram/callback'); const channel = await ChannelService.getChannel(channelId); if (!authInfo || !channel) return reply.status(400).send(); channel.isConnected = true; channel.tokenInfo = { accessToken: authInfo.accessToken, accessTokenExpiryDate: authInfo.expireOn, refreshToken: authInfo.refreshToken }; channel.userInfo = { id: authInfo.userId }; await ChannelService.updateConnectionState(channel.id!, true, channel.tokenInfo, channel.userInfo, channel.createdByUserId!); await ChannelService.addConnectionHistory(channel.id!, 'Connect', channel.createdByUserId!, 'Connected'); return reply.redirect('/channel/channel/close'); }); // GET /api/web/v1/channel/instagram/page/select [Anonymous] app.get('/instagram/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId } = request.query as { state: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const pages = await FacebookService.getPages(channel.tokenInfo!.accessToken); return reply.send({ state: channelId, pages: pages.map(p => ({ id: p.id, title: p.name })) }); }); // POST /api/web/v1/channel/instagram/page/select [Anonymous] app.post('/instagram/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId, pageId } = request.body as { state: string; pageId: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const pages = await FacebookService.getPages(channel.tokenInfo!.accessToken); const page = pages.find(p => p.id === pageId); if (!page) return reply.status(400).send(); const instagramAccountId = await InstagramService.getInstagramAccountFromPage(page.id, channel.tokenInfo!.accessToken); channel.subChannelInfo = { id: instagramAccountId, title: page.name }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); return reply.redirect('/channel/channel/close'); }); // ════════════════════════════════════════════ // LINKEDIN // ════════════════════════════════════════════ // GET /api/web/v1/channel/linkedin/connect?id={channelId} app.get('/linkedin/connect', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.query as { id: string }; const url = LinkedInService.getAuthUrl(process.env.BASE_URL + 'channel/linkedin/callback', id); return reply.send(url); }); // GET /api/web/v1/channel/linkedin/callback [Anonymous] app.get('/linkedin/callback', async (request: FastifyRequest, reply: FastifyReply) => { const { code, state: channelId } = request.query as { code: string; state: string }; const authInfo = await LinkedInService.exchangeCode(code, process.env.BASE_URL + 'channel/linkedin/callback'); const channel = await ChannelService.getChannel(channelId); if (!authInfo || !channel) return reply.status(400).send(); channel.isConnected = true; channel.tokenInfo = { accessToken: authInfo.accessToken, accessTokenExpiryDate: authInfo.expireOn, refreshToken: authInfo.refreshToken, scope: authInfo.scope }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); await ChannelService.addConnectionHistory(channel.id!, 'Connect', channel.createdByUserId!, 'Connected'); if (!channel.subChannelInfo) { return reply.redirect(`/channel/linkedin/page/select?state=${channelId}`); } return reply.redirect('/channel/channel/close'); }); // GET /api/web/v1/channel/linkedin/page/select [Anonymous] app.get('/linkedin/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId } = request.query as { state: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const pages = await LinkedInService.getPages(channel.tokenInfo!.accessToken); return reply.send({ state: channelId, pages: pages.map(p => ({ id: p.organization, title: p.organizationInfo.name })) }); }); // POST /api/web/v1/channel/linkedin/page/select [Anonymous] app.post('/linkedin/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId, pageId } = request.body as { state: string; pageId: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const pages = await LinkedInService.getPages(channel.tokenInfo!.accessToken); const page = pages.find(p => p.organization === pageId); if (!page) return reply.status(400).send(); channel.subChannelInfo = { id: page.organization, title: page.organizationInfo.name }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); return reply.redirect('/channel/channel/close'); }); // ════════════════════════════════════════════ // GOOGLE SEARCH CONSOLE // ════════════════════════════════════════════ // GET /api/web/v1/channel/googleSearchConsole/connect?id={channelId} app.get('/googleSearchConsole/connect', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.query as { id: string }; const url = GoogleSearchConsoleService.getAuthUrl(process.env.BASE_URL + 'channel/googleSearchConsole/callback', id); return reply.send(url); }); // GET /api/web/v1/channel/googleSearchConsole/callback [Anonymous] app.get('/googleSearchConsole/callback', async (request: FastifyRequest, reply: FastifyReply) => { const { code, state: channelId } = request.query as { code: string; state: string }; const authInfo = await GoogleSearchConsoleService.exchangeCode(code, process.env.BASE_URL + 'channel/googleSearchConsole/callback'); const channel = await ChannelService.getChannel(channelId); if (!authInfo || !channel) return reply.status(400).send(); channel.isConnected = true; channel.tokenInfo = { accessToken: authInfo.accessToken, refreshToken: authInfo.refreshToken, accessTokenExpiryDate: authInfo.expireOn, scope: authInfo.scope }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); await ChannelService.addConnectionHistory(channel.id!, 'Connect', channel.createdByUserId!, 'Connected'); if (!channel.subChannelInfo) { return reply.redirect(`/channel/googleSearchConsole/page/select?state=${channelId}`); } return reply.redirect('/channel/channel/close'); }); // GET /api/web/v1/channel/googleSearchConsole/page/select [Anonymous] app.get('/googleSearchConsole/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId } = request.query as { state: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const sites = await GoogleSearchConsoleService.getAllSites(channel.tokenInfo!.accessToken); return reply.send({ state: channelId, sites: sites.map(s => ({ id: s.siteUrl, title: s.siteUrl })) }); }); // POST /api/web/v1/channel/googleSearchConsole/page/select [Anonymous] app.post('/googleSearchConsole/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId, siteId } = request.body as { state: string; siteId: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const sites = await GoogleSearchConsoleService.getAllSites(channel.tokenInfo!.accessToken); const site = sites.find(s => s.siteUrl === siteId); if (!site) return reply.status(400).send(); channel.subChannelInfo = { id: site.siteUrl, title: site.siteUrl, tokenInfo: { ...channel.tokenInfo! }, }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); // Trigger GSC data fetch jobs await BusService.send('gsc-keywords-fetch-v2', channel.id!); await BusService.send('gsc-pages-stats-v2', channel.id!); await BusService.send('gsc-keywords-stats-v2', channel.id!); return reply.redirect('/channel/channel/close'); }); // ════════════════════════════════════════════ // BING WEBMASTER TOOLS // ════════════════════════════════════════════ // GET /api/web/v1/channel/bingWebMasterTools/connect?id={channelId} app.get('/bingWebMasterTools/connect', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.query as { id: string }; const url = BingWebMasterToolsService.getAuthUrl(process.env.BASE_URL + 'channel/bingWebMasterTools/callback', id); return reply.send(url); }); // GET /api/web/v1/channel/bingWebMasterTools/callback [Anonymous] app.get('/bingWebMasterTools/callback', async (request: FastifyRequest, reply: FastifyReply) => { const { code, state: channelId } = request.query as { code: string; state: string }; const authInfo = await BingWebMasterToolsService.exchangeCode(code, process.env.BASE_URL + 'channel/bingWebMasterTools/callback'); const channel = await ChannelService.getChannel(channelId); if (!authInfo || !channel) return reply.status(400).send(); channel.isConnected = true; channel.tokenInfo = { accessToken: authInfo.accessToken, refreshToken: authInfo.refreshToken, accessTokenExpiryDate: authInfo.expireOn, scope: authInfo.scope }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); await ChannelService.addConnectionHistory(channel.id!, 'Connect', channel.createdByUserId!, 'Connected'); if (!channel.subChannelInfo) { return reply.redirect(`/channel/bingWebMasterTools/page/select?state=${channelId}`); } return reply.redirect('/channel/channel/close'); }); // GET /api/web/v1/channel/bingWebMasterTools/page/select [Anonymous] app.get('/bingWebMasterTools/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId } = request.query as { state: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const sites = await BingWebMasterToolsService.getAllSites(channel.tokenInfo!.accessToken); return reply.send({ state: channelId, sites: sites.map(s => ({ id: s.url, title: s.url })) }); }); // POST /api/web/v1/channel/bingWebMasterTools/page/select [Anonymous] app.post('/bingWebMasterTools/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId, siteId } = request.body as { state: string; siteId: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const sites = await BingWebMasterToolsService.getAllSites(channel.tokenInfo!.accessToken); const site = sites.find(s => s.url === siteId); if (!site) return reply.status(400).send(); channel.subChannelInfo = { id: site.url, title: site.url, tokenInfo: { ...channel.tokenInfo! } }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); return reply.redirect('/channel/channel/close'); }); // ════════════════════════════════════════════ // GOOGLE ADS // ════════════════════════════════════════════ // GET /api/web/v1/channel/googleAds/connect?id={channelId} app.get('/googleAds/connect', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.query as { id: string }; const url = GoogleAdsService.getAuthUrl(process.env.BASE_URL + 'channel/googleAds/callback', id); return reply.send(url); }); // GET /api/web/v1/channel/googleAds/callback [Anonymous] app.get('/googleAds/callback', async (request: FastifyRequest, reply: FastifyReply) => { const { code, state: channelId } = request.query as { code: string; state: string }; const authInfo = await GoogleAdsService.exchangeCode(code, process.env.BASE_URL + 'channel/googleAds/callback'); const channel = await ChannelService.getChannel(channelId); if (!authInfo || !channel) return reply.status(400).send(); channel.isConnected = true; channel.tokenInfo = { accessToken: authInfo.accessToken, refreshToken: authInfo.refreshToken, accessTokenExpiryDate: authInfo.expireOn, scope: authInfo.scope }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); await ChannelService.addConnectionHistory(channel.id!, 'Connect', channel.createdByUserId!, 'Connected'); if (!channel.subChannelInfo) { return reply.redirect(`/channel/googleAds/page/select?state=${channelId}`); } return reply.redirect('/channel/channel/close'); }); // GET /api/web/v1/channel/googleAds/page/select [Anonymous] // Returns list of Google Ads customer accounts app.get('/googleAds/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId } = request.query as { state: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const customers = await GoogleAdsService.getAllCustomers(channel.tokenInfo!.accessToken); const filtered = customers.filter(c => c.descriptiveName); return reply.send({ state: channelId, customers: filtered.map(c => ({ id: c.id, title: c.descriptiveName })) }); }); // POST /api/web/v1/channel/googleAds/page/select [Anonymous] app.post('/googleAds/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId, customerId } = request.body as { state: string; customerId: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const customers = await GoogleAdsService.getAllCustomers(channel.tokenInfo!.accessToken); const customer = customers.find(c => c.id === customerId); if (!customer) return reply.status(400).send(); // Extract parent account ID for sub-accounts const parentMatch = customer.resourceName?.match(/customers\/(\d+)\/customerClients/); channel.subChannelInfo = { id: customer.id, title: customer.descriptiveName, parentId: parentMatch?.[1], tokenInfo: { accessToken: channel.tokenInfo!.accessToken }, }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); // Trigger Google Ads keyword metrics job await BusService.send('google-ads-keywords-metrics-v2', channel.tenantId!); return reply.redirect('/channel/channel/close'); }); // ════════════════════════════════════════════ // GOOGLE ANALYTICS // ════════════════════════════════════════════ // GET /api/web/v1/channel/googleAnalytics/connect?id={channelId} app.get('/googleAnalytics/connect', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.query as { id: string }; const url = GoogleAnalyticsService.getAuthUrl(process.env.BASE_URL + 'channel/googleAnalytics/callback', id); return reply.send(url); }); // GET /api/web/v1/channel/googleAnalytics/callback [Anonymous] app.get('/googleAnalytics/callback', async (request: FastifyRequest, reply: FastifyReply) => { const { code, state: channelId } = request.query as { code: string; state: string }; const authInfo = await GoogleAnalyticsService.exchangeCode(code, process.env.BASE_URL + 'channel/googleAnalytics/callback'); const channel = await ChannelService.getChannel(channelId); if (!authInfo || !channel) return reply.status(400).send(); channel.isConnected = true; channel.tokenInfo = { accessToken: authInfo.accessToken, refreshToken: authInfo.refreshToken, accessTokenExpiryDate: authInfo.expireOn, scope: authInfo.scope }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); await ChannelService.addConnectionHistory(channel.id!, 'Connect', channel.createdByUserId!, 'Connected'); if (!channel.subChannelInfo) { return reply.redirect(`/channel/googleAnalytics/page/select?state=${channelId}`); } return reply.redirect('/channel/channel/close'); }); // GET /api/web/v1/channel/googleAnalytics/page/select [Anonymous] app.get('/googleAnalytics/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId } = request.query as { state: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const accounts = await GoogleAnalyticsService.getAllAccounts(channel.tokenInfo!.accessToken); const properties = accounts.flatMap(a => a.propertySummaries); return reply.send({ state: channelId, properties: properties.map(p => ({ id: p.property, title: p.displayName })) }); }); // POST /api/web/v1/channel/googleAnalytics/page/select [Anonymous] app.post('/googleAnalytics/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId, propertyId } = request.body as { state: string; propertyId: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const accounts = await GoogleAnalyticsService.getAllAccounts(channel.tokenInfo!.accessToken); const properties = accounts.flatMap(a => a.propertySummaries); const property = properties.find(p => p.property === propertyId); if (!property) return reply.status(400).send(); channel.subChannelInfo = { id: property.property, title: property.displayName, tokenInfo: { ...channel.tokenInfo! } }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); return reply.redirect('/channel/channel/close'); }); // ════════════════════════════════════════════ // GOOGLE BUSINESS PROFILE // ════════════════════════════════════════════ // GET /api/web/v1/channel/googleBusinessProfile/connect?id={channelId} app.get('/googleBusinessProfile/connect', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.query as { id: string }; const url = GoogleBusinessProfileService.getAuthUrl(process.env.BASE_URL + 'channel/googleBusinessProfile/callback', id); return reply.send(url); }); // GET /api/web/v1/channel/googleBusinessProfile/callback [Anonymous] app.get('/googleBusinessProfile/callback', async (request: FastifyRequest, reply: FastifyReply) => { const { code, state: channelId } = request.query as { code: string; state: string }; const authInfo = await GoogleBusinessProfileService.exchangeCode(code, process.env.BASE_URL + 'channel/googleBusinessProfile/callback'); const channel = await ChannelService.getChannel(channelId); if (!authInfo || !channel) return reply.status(400).send(); channel.isConnected = true; channel.tokenInfo = { accessToken: authInfo.accessToken, refreshToken: authInfo.refreshToken, accessTokenExpiryDate: authInfo.expireOn, scope: authInfo.scope }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); await ChannelService.addConnectionHistory(channel.id!, 'Connect', channel.createdByUserId!, 'Connected'); if (!channel.subChannelInfo) { return reply.redirect(`/channel/googleBusinessProfile/page/select?state=${channelId}`); } return reply.redirect('/channel/channel/close'); }); // GET /api/web/v1/channel/googleBusinessProfile/page/select [Anonymous] app.get('/googleBusinessProfile/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId } = request.query as { state: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const locations = await GoogleBusinessProfileService.getAllLocations(channel.tokenInfo!.accessToken); return reply.send({ state: channelId, locations: locations.map(l => ({ id: l.name, title: l.title })) }); }); // POST /api/web/v1/channel/googleBusinessProfile/page/select [Anonymous] app.post('/googleBusinessProfile/page/select', async (request: FastifyRequest, reply: FastifyReply) => { const { state: channelId, locationId } = request.body as { state: string; locationId: string }; const channel = await ChannelService.getChannel(channelId); if (!channel) return reply.status(400).send(); const locations = await GoogleBusinessProfileService.getAllLocations(channel.tokenInfo!.accessToken); const location = locations.find(l => l.name === locationId); if (!location) return reply.status(400).send(); channel.subChannelInfo = { id: location.name, title: location.title, parentId: location.accountId, tokenInfo: { ...channel.tokenInfo! }, }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); return reply.redirect('/channel/channel/close'); }); // ════════════════════════════════════════════ // ZOHO BOOKS // ════════════════════════════════════════════ // GET /api/web/v1/channel/zohobooks/connect?id={channelId} app.get('/zohobooks/connect', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { const { id } = request.query as { id: string }; const url = ZohoBooksService.getAuthUrl(process.env.BASE_URL + 'channel/zohobooks/callback', id); return reply.send(url); }); // GET /api/web/v1/channel/zohobooks/callback [Anonymous] // Note: Zoho also passes `accounts-server` query param app.get('/zohobooks/callback', async (request: FastifyRequest, reply: FastifyReply) => { const { code, state: channelId, 'accounts-server': accountsServer } = request.query as { code: string; state: string; 'accounts-server': string; }; const authInfo = await ZohoBooksService.exchangeCode(code, process.env.BASE_URL + 'channel/zohobooks/callback', accountsServer); const channel = await ChannelService.getChannel(channelId); if (!authInfo || !channel) return reply.status(400).send(); channel.isConnected = true; channel.tokenInfo = { accessToken: authInfo.accessToken, refreshToken: authInfo.refreshToken, accessTokenExpiryDate: authInfo.expireOn, scope: authInfo.scope }; await ChannelService.updateChannel(channel.id!, channel, channel.createdByUserId!, channel.tenantId!); await ChannelService.addConnectionHistory(channel.id!, 'Connect', channel.createdByUserId!, 'Connected'); return reply.redirect('/channel/channel/close'); }); }

7. Service Layer

src/services/channel.service.ts — interface contract (implement against your MongoDB driver):

import { ChannelModel, ChannelFilterModel, RenameChannelDTO, TokenInfo, UserInfo } from '../types/channel.types'; export const ChannelService = { async searchChannels(filter: ChannelFilterModel, sortBy: string, tenantId: string): Promise<ChannelModel[]> { /* ... */ }, async getChannel(id: string): Promise<ChannelModel | null> { /* ... */ }, async getChannelsByType(tenantId: string, type: string): Promise<ChannelModel[]> { /* ... */ }, async getSocialChannels(tenantId: string): Promise<ChannelModel[]> { /* ... */ }, async createChannel(data: Partial<ChannelModel>, userId: string, tenantId: string): Promise<ChannelModel> { /* ... */ }, async updateChannel(id: string, data: ChannelModel, userId: string, tenantId: string): Promise<ChannelModel> { /* ... */ }, async updateConnectionState(id: string, isConnected: boolean, tokenInfo: TokenInfo, userInfo: UserInfo, userId: string): Promise<void> { /* ... */ }, async updateWebsiteChannelInfo(id: string, websiteId: string, websiteName: string, userId: string, tenantId: string): Promise<void> { /* ... */ }, async addConnectionHistory(id: string, operation: string, userId: string, message: string): Promise<void> { /* ... */ }, async disconnectChannel(id: string, userId: string, tenantId: string, reason: string): Promise<ChannelModel> { /* ... */ }, async deleteChannel(id: string, userId: string, tenantId: string): Promise<ChannelModel> { /* ... */ }, async renameChannel(id: string, dto: RenameChannelDTO, userId: string, tenantId: string): Promise<ChannelModel> { /* ... */ }, getAllChannels(categories: string[]): ChannelModel[] { /* filter from constants */ }, getYetToConnectChannels(existing: ChannelModel[], categories: string[]): ChannelModel[] { /* subtract existing */ }, };

8. Plugin Registration

Use Fastify’s fastify-plugin to share decorators (like authenticate) across routes:

src/plugins/auth.plugin.ts:

import fp from 'fastify-plugin'; import jwt from '@fastify/jwt'; export default fp(async (app) => { app.register(jwt, { secret: process.env.JWT_SECRET! }); app.decorate('authenticate', async (request: any, reply: any) => { try { await request.jwtVerify(); } catch (err) { reply.status(401).send({ message: 'Unauthorized' }); } }); });

Register it before routes in server.ts:

app.register(authPlugin); app.register(channelsRoutes, { prefix: '/api/web/v1/channels' }); app.register(channelOAuthRoutes, { prefix: '/api/web/v1/channel' });

Route Summary

/api/web/v1/channels — Channel Management

MethodPathAuthDescription
GET/RequiredList channels (filter by state, categories, sortBy)
GET/statusRequiredGet existing, all, and yet-to-connect channel lists
GET/lookupsRequiredChannel + category lookups for dropdowns
GET/lookups/socialRequiredSocial channel lookups
GET/:idRequiredGet a single channel by ID
POST/RequiredCreate/connect a new channel
POST/:id/crawlRequiredTrigger website crawl
PUT/:id/disconnectRequiredDisconnect a channel
PUT/:id/renameRequiredRename a channel
DELETE/:idRequiredDelete a channel

/api/web/v1/channel — OAuth Flows

MethodPathAuthDescription
GET/channel/closeNoneHTML page to close OAuth popup
GET/facebook/connectRequiredGet Facebook OAuth URL
GET/facebook/callbackAnonymousHandle Facebook OAuth code exchange
GET/facebook/page/selectAnonymousList Facebook pages
POST/facebook/page/selectAnonymousSave selected Facebook page
GET/instagram/connectRequiredGet Instagram OAuth URL
GET/instagram/callbackAnonymousHandle Instagram OAuth
GET/instagram/page/selectAnonymousList Instagram-linked pages
POST/instagram/page/selectAnonymousSave selected Instagram account
GET/linkedin/connectRequiredGet LinkedIn OAuth URL
GET/linkedin/callbackAnonymousHandle LinkedIn OAuth
GET/linkedin/page/selectAnonymousList LinkedIn organization pages
POST/linkedin/page/selectAnonymousSave selected LinkedIn page
GET/googleSearchConsole/connectRequiredGet GSC OAuth URL
GET/googleSearchConsole/callbackAnonymousHandle GSC OAuth
GET/googleSearchConsole/page/selectAnonymousList GSC verified sites
POST/googleSearchConsole/page/selectAnonymousSave selected GSC site
GET/bingWebMasterTools/connectRequiredGet Bing OAuth URL
GET/bingWebMasterTools/callbackAnonymousHandle Bing OAuth
GET/bingWebMasterTools/page/selectAnonymousList Bing verified sites
POST/bingWebMasterTools/page/selectAnonymousSave selected Bing site
GET/googleAds/connectRequiredGet Google Ads OAuth URL
GET/googleAds/callbackAnonymousHandle Google Ads OAuth
GET/googleAds/page/selectAnonymousList Google Ads customer accounts
POST/googleAds/page/selectAnonymousSave selected Google Ads account
GET/googleAnalytics/connectRequiredGet Google Analytics OAuth URL
GET/googleAnalytics/callbackAnonymousHandle GA4 OAuth
GET/googleAnalytics/page/selectAnonymousList GA4 properties
POST/googleAnalytics/page/selectAnonymousSave selected GA4 property
GET/googleBusinessProfile/connectRequiredGet GBP OAuth URL
GET/googleBusinessProfile/callbackAnonymousHandle GBP OAuth
GET/googleBusinessProfile/page/selectAnonymousList GBP locations
POST/googleBusinessProfile/page/selectAnonymousSave selected GBP location
GET/zohobooks/connectRequiredGet Zoho Books OAuth URL
GET/zohobooks/callbackAnonymousHandle Zoho Books OAuth (includes accounts-server param)

© 2026 Leadmetrics — Internal use only