Version: 1.0.0
GBP Path: src/server/services/gbp/
Social Path: src/server/services/social/
Auth: Google OAuth (GBP) + Composio (Social)
1. GBP OAuth Flow#
Authorization URL Generation#
// src/server/services/gbp/auth.ts
import { google } from "googleapis";
import { encrypt } from "@/lib/crypto";
const oauth2Client = new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
`${process.env.APP_URL}/api/webhooks/google/oauth-callback`
);
export async function getAuthUrl(practiceId: string): Promise<string> {
const state = Buffer.from(JSON.stringify({ practiceId })).toString("base64");
return oauth2Client.generateAuthUrl({
access_type: "offline",
scope: [
"https://www.googleapis.com/auth/business.manage",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
],
prompt: "consent",
state,
include_granted_scopes: true,
});
}
OAuth Callback Handler#
// src/app/api/webhooks/google/oauth-callback/route.ts
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
if (!code || !state) {
return new Response("Invalid callback", { status: 400 });
}
const { practiceId } = JSON.parse(Buffer.from(state, "base64").toString());
// Exchange code for tokens
const { tokens } = await oauth2Client.getToken(code);
// Get user info
oauth2Client.setCredentials(tokens);
const oauth2 = google.oauth2({ version: "v2", auth: oauth2Client });
const { data: userInfo } = await oauth2.userinfo.get();
// Store encrypted tokens
await db.gbpAccount.create({
data: {
practiceId,
accountEmail: userInfo.email!,
accessToken: encrypt(tokens.access_token!),
refreshToken: encrypt(tokens.refresh_token!),
tokenExpiresAt: new Date(Date.now() + (tokens.expiry_date || 3600 * 1000)),
scope: (tokens.scope || "").split(" ").filter(Boolean),
},
});
// Trigger sync workflow
await inngest.send({
name: "gbp/sync-locations",
data: { practice_id: practiceId },
});
// Redirect to dashboard
return NextResponse.redirect(`${process.env.APP_URL}/dashboard/gbp?connected=true`);
}
2. GBP Token Management#
Automatic Refresh#
// src/server/services/gbp/auth.ts
export async function getClient(accountId: string) {
const account = await db.gbpAccount.findUnique({ where: { id: accountId } });
if (!account) throw new Error("GBP account not found");
const accessToken = decrypt(account.accessToken);
const refreshToken = decrypt(account.refreshToken);
// Refresh if expiring within 5 minutes
if (account.tokenExpiresAt.getTime() - Date.now() < 5 * 60 * 1000) {
oauth2Client.setCredentials({ refresh_token: refreshToken });
const { tokens } = await oauth2Client.refreshAccessToken();
await db.gbpAccount.update({
where: { id: accountId },
data: {
accessToken: encrypt(tokens.access_token!),
...(tokens.refresh_token ? { refreshToken: encrypt(tokens.refresh_token) } : {}),
tokenExpiresAt: new Date(Date.now() + (tokens.expiry_date || 3600 * 1000)),
},
});
oauth2Client.setCredentials({ access_token: tokens.access_token });
} else {
oauth2Client.setCredentials({ access_token: accessToken });
}
return google.mybusinessbusinessinformation({ version: "v1", auth: oauth2Client });
}
Token Refresh Cron Job#
// src/server/bullmq/processors/token-refresh.ts
export async function tokenRefreshProcessor(job: Job) {
const accounts = await db.gbpAccount.findMany({
where: {
isActive: true,
tokenExpiresAt: { lt: new Date(Date.now() + 24 * 60 * 60 * 1000) },
},
});
const results = await Promise.allSettled(
accounts.map(async (account) => {
try {
await getClient(account.id); // This triggers refresh
return { accountId: account.id, success: true };
} catch (error) {
// Notify if refresh fails
await email.send({
to: "admin@rankflow.ai",
subject: `GBP Token Refresh Failed: ${account.accountEmail}`,
body: `Practice: ${account.practiceId}\nError: ${(error as Error).message}`,
});
return { accountId: account.id, success: false, error: (error as Error).message };
}
})
);
return { refreshed: results.filter(r => r.status === "fulfilled").length };
}
3. GBP Operations#
Post Creation#
// src/server/services/gbp/client.ts
export async function createPost(
accountId: string,
locationId: string,
content: string,
options: {
mediaUrls?: string[];
ctaType?: "BOOK" | "CALL" | "LEARN_MORE" | "SIGN_UP" | "ORDER";
topicType?: "STANDARD" | "OFFER" | "EVENT";
scheduledFor?: Date;
}
): Promise<{ postId: string; status: string }> {
const client = await getClient(accountId);
const postBody: any = {
languageCode: "en-IN",
topicType: options.topicType || "STANDARD",
body: content,
};
if (options.ctaType) {
postBody.callToAction = {
actionType: options.ctaType,
url: options.ctaType === "BOOK" ? "https://booking.url" : undefined,
};
}
if (options.mediaUrls?.length) {
postBody.media = options.mediaUrls.map(url => ({
mediaFormat: url.endsWith(".mp4") ? "VIDEO" : "PHOTO",
sourceUrl: url,
}));
}
const response = await client.accounts.locations.localPosts.create({
parent: `accounts/${accountId}/locations/${locationId}`,
requestBody: postBody,
});
return {
postId: response.data.name!,
status: options.scheduledFor ? "SCHEDULED" : "PUBLISHED",
};
}
Review Reply#
export async function replyToReview(
accountId: string,
locationId: string,
reviewId: string,
replyText: string
): Promise<void> {
const client = await getClient(accountId);
await client.accounts.locations.reviews.updateReply({
name: `accounts/${accountId}/locations/${locationId}/reviews/${reviewId}`,
requestBody: {
comment: replyText,
updateTime: new Date().toISOString(),
},
});
}
Insights Fetch#
export async function getInsights(
accountId: string,
locationId: string,
dateFrom: Date,
dateTo: Date
): Promise<GbpInsightData> {
const client = await getClient(accountId);
const response = await client.accounts.locations.getDailyMetricsTimeSeries({
name: `accounts/${accountId}/locations/${locationId}`,
dailyMetricsTimeSeries: {
startDate: { year: dateFrom.getFullYear(), month: dateFrom.getMonth() + 1, day: dateFrom.getDate() },
endDate: { year: dateTo.getFullYear(), month: dateTo.getMonth() + 1, day: dateTo.getDate() },
},
});
return parseInsightsResponse(response.data);
}
4. GBP Rate Limiting#
Quota Limits#
| Operation |
Daily Limit |
Per-Minute Limit |
| Business information |
10,000 |
100 |
| Posts |
500 |
30 |
| Reviews |
1,000 |
60 |
| Photos |
1,000 |
60 |
| Insights |
500 |
30 |
| Q&A |
500 |
30 |
Implementation#
// src/server/services/gbp/ratelimit.ts
import { redis } from "@/lib/redis";
const QUOTA_LIMITS: Record<string, { daily: number; perMinute: number }> = {
"business-information": { daily: 10000, perMinute: 100 },
posts: { daily: 500, perMinute: 30 },
reviews: { daily: 1000, perMinute: 60 },
photos: { daily: 1000, perMinute: 60 },
insights: { daily: 500, perMinute: 30 },
qa: { daily: 500, perMinute: 30 },
};
export async function checkGbpQuota(
operation: keyof typeof QUOTA_LIMITS,
accountId: string
): Promise<{ allowed: boolean; remaining: number }> {
const limit = QUOTA_LIMITS[operation];
const today = new Date().toISOString().slice(0, 10);
const dailyKey = `gbp:quota:${operation}:${accountId}:${today}`;
const minuteKey = `gbp:ratelimit:${operation}:${accountId}`;
const [dailyCount, minuteCount] = await Promise.all([
redis.incr(dailyKey),
redis.incr(minuteKey),
]);
if (dailyCount === 1) await redis.expire(dailyKey, 86400);
if (minuteCount === 1) await redis.pexpire(minuteKey, 60000);
const allowed = dailyCount <= limit.daily && minuteCount <= limit.perMinute;
const remaining = Math.max(0, limit.daily - dailyCount);
return { allowed, remaining };
}
5. Review Monitoring & Replies#
Daily Review Poll#
// src/server/bullmq/processors/review-monitor.ts
export async function reviewMonitorProcessor(job: Job) {
const { practiceId } = job.data;
const gbpAccounts = await db.gbpAccount.findMany({
where: { practiceId, isActive: true },
include: { locations: true },
});
for (const account of gbpAccounts) {
for (const location of account.locations) {
// Fetch new reviews
const reviews = await fetchNewReviews(account.id, location.gbpLocationId);
for (const review of reviews) {
// Store review
await db.review.create({
data: {
locationId: location.locationId,
gbpLocationId: location.id,
gbpReviewId: review.reviewId,
reviewerName: reviewerName,
rating: review.starRating,
comment: review.comment,
reviewDate: new Date(review.createTime),
status: "NEW",
},
});
// Generate AI reply
const reply = await ai.generate({
task: "review_reply",
prompt: `Review: ${review.comment}\nRating: ${review.starRating}/5\nBusiness: ${location.name}`,
});
// Sentiment-based routing
if (review.starRating >= 4) {
// Positive: auto-reply
await replyToReview(account.id, location.gbpLocationId, review.reviewId, reply.text);
await db.review.update({
where: { gbpReviewId: review.reviewId },
data: {
replyText: reply.text,
replyGeneratedByAI: true,
replyPublished: true,
repliedAt: new Date(),
status: "REPLIED",
},
});
} else {
// Negative: queue for approval
await db.review.update({
where: { gbpReviewId: review.reviewId },
data: {
replyText: reply.text,
replyGeneratedByAI: true,
replyPublished: false,
status: "NEW", // Awaits approval
},
});
// Notify client
await email.send({
to: practice.owner.email,
subject: "New negative review requires your attention",
body: `A ${review.starRating}-star review was received. Please approve the AI-generated reply in your dashboard.`,
});
}
}
}
}
}
Review Reply Approval Flow#
| Rating |
Action |
Approval Required |
| 5 stars |
Auto-reply with AI |
❌ |
| 4 stars |
Auto-reply with AI |
❌ |
| 3 stars |
AI reply, queue for approval |
✅ (24h auto-approve) |
| 1-2 stars |
AI reply, queue for approval |
✅ (manual only) |
Connection Flow#
// src/server/services/social/composio.ts
import { Composio } from "composio-core";
const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });
export async function initiateConnection(
platform: string,
redirectUri: string,
metadata: Record<string, any>
) {
const connection = await composio.connectedAccounts.initiate({
appName: platform,
redirectUri,
metadata,
});
return {
authUrl: connection.redirectUrl,
connectionId: connection.connectedAccountId,
};
}
export async function handleCallback(connectionId: string) {
const connection = await composio.connectedAccounts.get({
connectedAccountId: connectionId,
});
return {
status: connection.status,
appName: connection.appName,
entityId: connection.entityId,
};
}
| Platform |
Auth Method |
Posting API |
Rate Limit |
| Instagram |
Composio (Meta OAuth) |
Instagram Graph API |
25 publishes/user/day |
| Facebook |
Composio (Meta OAuth) |
Facebook Graph API |
Per-page limits |
| LinkedIn |
Composio (OAuth 2.0) |
LinkedIn API |
150 posts/day |
| Twitter/X |
Composio (OAuth 2.0) |
Twitter API v2 |
300 tweets/day |
Social Account Storage#
model SocialAccount {
id String @id @default(cuid())
practiceId String
platform PlatformType
accountName String
accountId String?
profileUrl String?
accessToken String @db.Text
refreshToken String? @db.Text
tokenExpiresAt DateTime?
composioConnectionId String?
followerCount Int?
isActive Boolean @default(true)
lastSyncedAt DateTime?
practice Practice @relation(fields: [practiceId], references: [id], onDelete: Cascade)
posts SocialPost[]
@@unique([practiceId, platform])
@@map("social_accounts")
}
7. Social Posting Pipeline#
Post Creation#
// src/server/services/social/platforms.ts
export async function publishPost(
account: SocialAccount,
content: string,
mediaUrls?: string[]
): Promise<{ postId: string; url: string }> {
switch (account.platform) {
case "FACEBOOK":
return publishFacebookPost(account.composioConnectionId!, content, mediaUrls);
case "INSTAGRAM":
return publishInstagramPost(account.composioConnectionId!, content, mediaUrls?.[0]);
case "LINKEDIN":
return publishLinkedInPost(account.composioConnectionId!, content);
case "TWITTER":
return publishTwitterPost(account.composioConnectionId!, content, mediaUrls);
default:
throw new Error(`Unsupported platform: ${account.platform}`);
}
}
Facebook Post#
async function publishFacebookPost(
connectionId: string,
message: string,
mediaUrls?: string[]
) {
const action = await composio.getAction({ actionName: "FACEBOOK_PUBLISH_POST" });
return await action.execute({
connectedAccountId: connectionId,
input: {
message,
...(mediaUrls ? { photos: mediaUrls } : {}),
},
});
}
Instagram Post#
async function publishInstagramPost(
connectionId: string,
caption: string,
imageUrl?: string
) {
const action = await composio.getAction({ actionName: "INSTAGRAM_PUBLISH_POST" });
return await action.execute({
connectedAccountId: connectionId,
input: {
caption,
image_url: imageUrl,
},
});
}
8. Zernio Scheduling#
Batch Scheduling#
// src/server/services/social/zernio.ts
export async function schedulePosts(posts: Array<{
platform: string;
connectionId: string;
content: string;
mediaUrls?: string[];
scheduledFor: string;
}>) {
const response = await fetch("https://api.zernio.com/v1/schedule/batch", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.ZERNIO_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
posts,
webhook_url: `${process.env.APP_URL}/api/webhooks/zernio`,
}),
});
if (!response.ok) {
throw new Error(`Zernio scheduling failed: ${await response.text()}`);
}
return response.json();
}
Webhook Handler#
// src/app/api/webhooks/zernio/route.ts
export async function POST(request: Request) {
const body = await request.json();
// Update post status
await db.socialPost.update({
where: { id: body.postId },
data: {
status: body.status === "published" ? "PUBLISHED" : "FAILED",
externalPostId: body.platformPostId,
publishedAt: body.status === "published" ? new Date() : null,
failedReason: body.error || null,
},
});
return new Response("OK");
}
9. Content Approval Gate#
Approval States#
| State |
Badge |
Auto-Publish |
Actions |
DRAFT |
Gray |
— |
Edit, Submit |
PENDING_APPROVAL |
Yellow |
24h countdown |
Approve, Reject, Edit |
APPROVED |
Green |
Immediate |
— |
PUBLISHED |
Blue |
— |
View live |
REJECTED |
Red |
Never |
Regenerate |
FAILED |
Red |
Never |
Retry |
Medical Compliance Gate#
// All content for medical clients goes through approval
export async function publishWithApproval(
content: ContentPiece,
practice: Practice
) {
if (practice.type === "CLINIC" || practice.type === "HOSPITAL") {
// Medical clients: always require approval
await db.contentPiece.update({
where: { id: content.id },
data: { status: "PENDING_REVIEW" },
});
// Schedule auto-publish in 24h
await queues.emailSend.add("content-reminder", {
contentId: content.id,
practiceId: practice.id,
}, { delay: 24 * 60 * 60 * 1000 });
return { status: "PENDING_APPROVAL", autoPublishAt: new Date(Date.now() + 24 * 60 * 60 * 1000) };
}
// Non-medical: auto-publish
return await publishImmediately(content);
}
10. Risk Mitigation#
GBP Suspension Prevention#
| Risk |
Mitigation |
| Bulk posting identical content |
AI generates unique content per client per post |
| Excessive posting frequency |
Max 2-3 posts/week per location |
| Automated review replies flagged |
Vary reply templates, human approval for negative |
| Multiple accounts from same IP |
Use proxy rotation for API calls |
| Sudden activity spike |
Gradual ramp for new clients |
Emergency Stop#
// src/server/services/gbp/emergency-stop.ts
export async function emergencyStop(practiceId?: string) {
if (practiceId) {
// Stop one client
await db.gbpAccount.updateMany({
where: { practiceId },
data: { isActive: false },
});
} else {
// Stop ALL GBP activity (system-wide)
await db.gbpAccount.updateMany({
where: {},
data: { isActive: false },
});
}
// Cancel pending posts
await queues.gbpPostPublish.pause();
// Alert admin
await email.send({
to: "admin@rankflow.ai",
subject: "🚨 GBP Emergency Stop Activated",
body: `All GBP activity has been paused. Reason: ${reason}`,
});
}
// If Composio fails, queue for manual posting
export async function handleSocialFailure(
post: SocialPost,
error: Error
) {
await db.socialPost.update({
where: { id: post.id },
data: {
status: "FAILED",
failedReason: error.message,
},
});
// Notify client with copy-paste content
await email.send({
to: post.practice.owner.email,
subject: "Your social post needs manual posting",
body: `Platform: ${post.platform}\nContent: ${post.content}\nPlease post manually and mark as done in your dashboard.`,
});
}
End of GBP & Social Media Pipeline Documentation