Phase 6: Social Integration
OAuth connections, social publisher, and event promotion.
Deliverables
- Social connection OAuth flow
- Token encryption and storage
- Social post CRUD and scheduling
- Facebook publishing
- Instagram publishing
- Twitter publishing
- Event promotion auto-creation
- Reminder scheduling
1. Social Connection Service
// libs/crm/social/src/services/social-connection.service.ts
@Injectable()
export class SocialConnectionService {
constructor(
private readonly prisma: PrismaService,
private readonly encryptionService: EncryptionService,
) {}
async initiateOAuth(
tenantId: string,
clubId: string,
platform: SocialPlatform,
redirectUri: string,
): Promise<{ authUrl: string; state: string }> {
const state = this.generateState(tenantId, clubId, platform);
const authUrl = this.buildOAuthUrl(platform, redirectUri, state);
return { authUrl, state };
}
async completeOAuth(
platform: SocialPlatform,
code: string,
state: string,
userId: string,
): Promise<SocialConnection> {
const { tenantId, clubId } = this.parseState(state);
// Exchange code for tokens
const tokens = await this.exchangeCodeForTokens(platform, code);
// Get account info
const accountInfo = await this.getAccountInfo(platform, tokens.accessToken);
// Encrypt tokens
const encryptedAccess = this.encryptionService.encrypt(tokens.accessToken);
const encryptedRefresh = tokens.refreshToken
? this.encryptionService.encrypt(tokens.refreshToken)
: null;
return this.prisma.socialConnection.upsert({
where: {
tenantId_platform_accountId: {
tenantId,
platform,
accountId: accountInfo.id,
},
},
update: {
accessToken: encryptedAccess,
refreshToken: encryptedRefresh,
tokenExpiry: tokens.expiresAt,
accountName: accountInfo.name,
permissions: accountInfo.permissions,
isActive: true,
lastError: null,
},
create: {
tenantId,
clubId,
platform,
accountId: accountInfo.id,
accountName: accountInfo.name,
accountType: accountInfo.type,
accessToken: encryptedAccess,
refreshToken: encryptedRefresh,
tokenExpiry: tokens.expiresAt,
permissions: accountInfo.permissions,
connectedBy: userId,
},
});
}
private buildOAuthUrl(
platform: SocialPlatform,
redirectUri: string,
state: string,
): string {
switch (platform) {
case 'FACEBOOK':
return `https://www.facebook.com/v18.0/dialog/oauth?` +
`client_id=${process.env.FACEBOOK_APP_ID}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&state=${state}` +
`&scope=pages_manage_posts,pages_read_engagement`;
case 'INSTAGRAM':
return `https://api.instagram.com/oauth/authorize?` +
`client_id=${process.env.INSTAGRAM_APP_ID}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&state=${state}` +
`&scope=instagram_basic,instagram_content_publish`;
case 'TWITTER':
return `https://twitter.com/i/oauth2/authorize?` +
`client_id=${process.env.TWITTER_CLIENT_ID}` +
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
`&state=${state}` +
`&scope=tweet.read%20tweet.write%20users.read` +
`&code_challenge=challenge&code_challenge_method=plain`;
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
private async exchangeCodeForTokens(platform: SocialPlatform, code: string) {
switch (platform) {
case 'FACEBOOK':
return this.exchangeFacebookToken(code);
case 'TWITTER':
return this.exchangeTwitterToken(code);
default:
throw new Error(`Token exchange not implemented: ${platform}`);
}
}
async refreshTokenIfNeeded(connectionId: string): Promise<void> {
const connection = await this.prisma.socialConnection.findUnique({
where: { id: connectionId },
});
if (!connection) return;
// Refresh if expires within 7 days
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
if (connection.tokenExpiry && connection.tokenExpiry < sevenDaysFromNow) {
await this.refreshToken(connection);
}
}
}
2. Social Publisher Service
// libs/crm/social/src/services/social-publisher.service.ts
@Injectable()
export class SocialPublisherService {
private readonly logger = new Logger(SocialPublisherService.name);
constructor(
private readonly prisma: PrismaService,
private readonly encryptionService: EncryptionService,
private readonly connectionService: SocialConnectionService,
@InjectQueue('social-posts') private readonly postQueue: Queue,
) {}
async createPost(dto: CreateSocialPostDto): Promise<SocialPost> {
return this.prisma.socialPost.create({
data: {
tenantId: dto.tenantId,
connectionId: dto.connectionId,
campaignId: dto.campaignId,
content: dto.content,
mediaUrls: dto.mediaUrls ?? [],
linkUrl: dto.linkUrl,
hashtags: dto.hashtags ?? [],
status: 'DRAFT',
createdBy: dto.createdBy,
},
});
}
async schedulePost(postId: string, scheduledAt: Date): Promise<SocialPost> {
const post = await this.prisma.socialPost.update({
where: { id: postId },
data: { status: 'SCHEDULED', scheduledAt },
});
const delay = scheduledAt.getTime() - Date.now();
await this.postQueue.add(
'publish',
{ postId },
{ delay: Math.max(0, delay), jobId: `post-${postId}` },
);
return post;
}
async publishPost(postId: string): Promise<SocialPost> {
const post = await this.prisma.socialPost.findUnique({
where: { id: postId },
include: { connection: true },
});
if (!post) throw new NotFoundException('Post not found');
// Refresh token if needed
await this.connectionService.refreshTokenIfNeeded(post.connectionId);
await this.prisma.socialPost.update({
where: { id: postId },
data: { status: 'PUBLISHING' },
});
try {
const connection = post.connection;
const accessToken = this.encryptionService.decrypt(connection.accessToken);
const result = await this.publishToPlatform(
connection.platform,
accessToken,
connection.accountId,
{
content: post.content,
mediaUrls: post.mediaUrls,
linkUrl: post.linkUrl,
},
);
return this.prisma.socialPost.update({
where: { id: postId },
data: {
status: 'PUBLISHED',
publishedAt: new Date(),
platformPostId: result.postId,
platformUrl: result.url,
},
});
} catch (error) {
this.logger.error(`Failed to publish post ${postId}: ${error.message}`);
return this.prisma.socialPost.update({
where: { id: postId },
data: {
status: 'FAILED',
error: error.message,
},
});
}
}
private async publishToPlatform(
platform: SocialPlatform,
accessToken: string,
accountId: string,
content: { content: string; mediaUrls: string[]; linkUrl?: string },
): Promise<{ postId: string; url: string }> {
switch (platform) {
case 'FACEBOOK':
return this.publishToFacebook(accessToken, accountId, content);
case 'INSTAGRAM':
return this.publishToInstagram(accessToken, accountId, content);
case 'TWITTER':
return this.publishToTwitter(accessToken, content);
default:
throw new Error(`Unsupported platform: ${platform}`);
}
}
private async publishToFacebook(
accessToken: string,
pageId: string,
content: { content: string; mediaUrls: string[]; linkUrl?: string },
): Promise<{ postId: string; url: string }> {
const response = await fetch(
`https://graph.facebook.com/v18.0/${pageId}/feed`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: content.content,
link: content.linkUrl,
access_token: accessToken,
}),
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error?.message || 'Facebook API error');
}
const data = await response.json();
return {
postId: data.id,
url: `https://facebook.com/${data.id}`,
};
}
private async publishToTwitter(
accessToken: string,
content: { content: string; mediaUrls: string[] },
): Promise<{ postId: string; url: string }> {
const response = await fetch('https://api.twitter.com/2/tweets', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ text: content.content }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Twitter API error');
}
const data = await response.json();
return {
postId: data.data.id,
url: `https://twitter.com/i/status/${data.data.id}`,
};
}
}
3. Event Promotion Service
// libs/crm/social/src/services/event-promotion.service.ts
@Injectable()
export class EventPromotionService {
constructor(
private readonly prisma: PrismaService,
private readonly campaignService: CampaignService,
private readonly socialPublisher: SocialPublisherService,
@InjectQueue('promotion-reminders') private readonly reminderQueue: Queue,
) {}
async createPromotion(dto: CreateEventPromotionDto): Promise<EventPromotion> {
return this.prisma.eventPromotion.create({
data: {
tenantId: dto.tenantId,
clubId: dto.clubId,
sourceType: dto.sourceType,
sourceId: dto.sourceId,
autoPromote: dto.autoPromote ?? true,
channels: dto.channels,
announceAt: dto.announceAt,
reminderDays: dto.reminderDays ?? [7, 3, 1],
segmentId: dto.segmentId,
emailTemplateId: dto.emailTemplateId,
smsTemplateId: dto.smsTemplateId,
socialContent: dto.socialContent,
createdBy: dto.createdBy,
},
});
}
async handleCompetitionPublished(event: {
tenantId: string;
clubId: string;
competitionId: string;
competitionName: string;
startDate: Date;
}): Promise<void> {
// Check for existing promotion
let promotion = await this.prisma.eventPromotion.findUnique({
where: {
tenantId_sourceType_sourceId: {
tenantId: event.tenantId,
sourceType: 'COMPETITION',
sourceId: event.competitionId,
},
},
});
// Create default if auto-promote enabled for club
if (!promotion) {
const clubConfig = await this.getClubAutoPromoteConfig(event.clubId);
if (clubConfig?.autoPromoteCompetitions) {
promotion = await this.createPromotion({
tenantId: event.tenantId,
clubId: event.clubId,
sourceType: 'COMPETITION',
sourceId: event.competitionId,
autoPromote: true,
channels: clubConfig.defaultChannels,
announceAt: new Date(),
reminderDays: [7, 3, 1],
createdBy: 'system',
});
}
}
if (promotion?.autoPromote) {
await this.executePromotion(promotion, event);
}
}
private async executePromotion(
promotion: EventPromotion,
eventData: { competitionName: string; startDate: Date },
): Promise<void> {
// Create campaign for email/SMS channels
const nonSocialChannels = promotion.channels.filter(c => c !== 'SOCIAL');
if (nonSocialChannels.length > 0) {
const campaign = await this.campaignService.create({
tenantId: promotion.tenantId,
clubId: promotion.clubId,
name: `${eventData.competitionName} Promotion`,
type: 'ONE_TIME',
segmentId: promotion.segmentId,
channels: nonSocialChannels,
emailTemplateId: promotion.emailTemplateId,
smsTemplateId: promotion.smsTemplateId,
createdBy: 'system',
});
await this.campaignService.schedule(
campaign.id,
promotion.announceAt ?? new Date(),
);
}
// Create social posts
if (promotion.channels.includes('SOCIAL')) {
await this.createSocialPosts(promotion, eventData);
}
// Schedule reminders
await this.scheduleReminders(promotion, eventData);
await this.prisma.eventPromotion.update({
where: { id: promotion.id },
data: { lastPromotedAt: new Date() },
});
}
private async createSocialPosts(
promotion: EventPromotion,
eventData: { competitionName: string; startDate: Date },
): Promise<void> {
const connections = await this.prisma.socialConnection.findMany({
where: {
tenantId: promotion.tenantId,
clubId: promotion.clubId,
isActive: true,
},
});
const content = promotion.socialContent || this.generateDefaultContent(eventData);
for (const connection of connections) {
const post = await this.socialPublisher.createPost({
tenantId: promotion.tenantId,
connectionId: connection.id,
content,
createdBy: 'system',
});
await this.socialPublisher.schedulePost(
post.id,
promotion.announceAt ?? new Date(),
);
}
}
private async scheduleReminders(
promotion: EventPromotion,
eventData: { startDate: Date },
): Promise<void> {
for (const days of promotion.reminderDays) {
const reminderDate = new Date(eventData.startDate);
reminderDate.setDate(reminderDate.getDate() - days);
if (reminderDate > new Date()) {
await this.reminderQueue.add(
'send-reminder',
{ promotionId: promotion.id, daysUntil: days },
{ delay: reminderDate.getTime() - Date.now() },
);
}
}
}
private generateDefaultContent(eventData: {
competitionName: string;
startDate: Date;
}): string {
const dateStr = eventData.startDate.toLocaleDateString('en-ZA', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
return `Join us for ${eventData.competitionName}!\n\n` +
`📅 ${dateStr}\n` +
`⛳ Limited spots available\n\n` +
`Register now!`;
}
}
4. Encryption Service
// libs/crm/social/src/services/encryption.service.ts
import { Injectable } from '@nestjs/common';
import * as crypto from 'crypto';
@Injectable()
export class EncryptionService {
private readonly algorithm = 'aes-256-gcm';
private readonly key: Buffer;
constructor() {
const keyHex = process.env.ENCRYPTION_KEY;
if (!keyHex || keyHex.length !== 64) {
throw new Error('ENCRYPTION_KEY must be 32 bytes (64 hex chars)');
}
this.key = Buffer.from(keyHex, 'hex');
}
encrypt(plaintext: string): string {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
}
decrypt(ciphertext: string): string {
const [ivHex, authTagHex, encrypted] = ciphertext.split(':');
const iv = Buffer.from(ivHex, 'hex');
const authTag = Buffer.from(authTagHex, 'hex');
const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
}
Next Steps
After completing Phase 6, proceed to Phase 7: Backfill.