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
- Project Setup
- Folder Structure
- Types & Interfaces
- Constants
- Routes — Channels Management
- Routes — OAuth Flows
- Service Layer
- 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 --inittsconfig.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.ts3. 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
| Method | Path | Auth | Description |
|---|---|---|---|
GET | / | Required | List channels (filter by state, categories, sortBy) |
GET | /status | Required | Get existing, all, and yet-to-connect channel lists |
GET | /lookups | Required | Channel + category lookups for dropdowns |
GET | /lookups/social | Required | Social channel lookups |
GET | /:id | Required | Get a single channel by ID |
POST | / | Required | Create/connect a new channel |
POST | /:id/crawl | Required | Trigger website crawl |
PUT | /:id/disconnect | Required | Disconnect a channel |
PUT | /:id/rename | Required | Rename a channel |
DELETE | /:id | Required | Delete a channel |
/api/web/v1/channel — OAuth Flows
| Method | Path | Auth | Description |
|---|---|---|---|
GET | /channel/close | None | HTML page to close OAuth popup |
GET | /facebook/connect | Required | Get Facebook OAuth URL |
GET | /facebook/callback | Anonymous | Handle Facebook OAuth code exchange |
GET | /facebook/page/select | Anonymous | List Facebook pages |
POST | /facebook/page/select | Anonymous | Save selected Facebook page |
GET | /instagram/connect | Required | Get Instagram OAuth URL |
GET | /instagram/callback | Anonymous | Handle Instagram OAuth |
GET | /instagram/page/select | Anonymous | List Instagram-linked pages |
POST | /instagram/page/select | Anonymous | Save selected Instagram account |
GET | /linkedin/connect | Required | Get LinkedIn OAuth URL |
GET | /linkedin/callback | Anonymous | Handle LinkedIn OAuth |
GET | /linkedin/page/select | Anonymous | List LinkedIn organization pages |
POST | /linkedin/page/select | Anonymous | Save selected LinkedIn page |
GET | /googleSearchConsole/connect | Required | Get GSC OAuth URL |
GET | /googleSearchConsole/callback | Anonymous | Handle GSC OAuth |
GET | /googleSearchConsole/page/select | Anonymous | List GSC verified sites |
POST | /googleSearchConsole/page/select | Anonymous | Save selected GSC site |
GET | /bingWebMasterTools/connect | Required | Get Bing OAuth URL |
GET | /bingWebMasterTools/callback | Anonymous | Handle Bing OAuth |
GET | /bingWebMasterTools/page/select | Anonymous | List Bing verified sites |
POST | /bingWebMasterTools/page/select | Anonymous | Save selected Bing site |
GET | /googleAds/connect | Required | Get Google Ads OAuth URL |
GET | /googleAds/callback | Anonymous | Handle Google Ads OAuth |
GET | /googleAds/page/select | Anonymous | List Google Ads customer accounts |
POST | /googleAds/page/select | Anonymous | Save selected Google Ads account |
GET | /googleAnalytics/connect | Required | Get Google Analytics OAuth URL |
GET | /googleAnalytics/callback | Anonymous | Handle GA4 OAuth |
GET | /googleAnalytics/page/select | Anonymous | List GA4 properties |
POST | /googleAnalytics/page/select | Anonymous | Save selected GA4 property |
GET | /googleBusinessProfile/connect | Required | Get GBP OAuth URL |
GET | /googleBusinessProfile/callback | Anonymous | Handle GBP OAuth |
GET | /googleBusinessProfile/page/select | Anonymous | List GBP locations |
POST | /googleBusinessProfile/page/select | Anonymous | Save selected GBP location |
GET | /zohobooks/connect | Required | Get Zoho Books OAuth URL |
GET | /zohobooks/callback | Anonymous | Handle Zoho Books OAuth (includes accounts-server param) |