feat(community): Phase 2.4 - Unified Sync Service

Implements a unified sync service that orchestrates comment
synchronization across all social media platforms.

UnifiedSyncService:
- Platform-agnostic sync orchestration
- Support for YouTube, Facebook, and Instagram
- Parallel platform detection and account grouping
- Progress tracking with live status updates
- Aggregated results per platform
- Error handling with partial results support

New API Endpoints:
- GET/POST /api/cron/community-sync
  - Cron endpoint for scheduled multi-platform sync
  - Query params: platforms, accountIds, analyzeWithAI, maxItems
  - HEAD for monitoring status

- GET /api/community/sync-status
  - Live sync status for dashboard
  - Platform overview with account details
  - Interaction statistics (total, today, unanswered)
  - Last sync result summary

Configuration:
- vercel.json updated to use community-sync cron
- 15-minute sync interval maintained

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-01-16 21:33:42 +00:00
parent b107d60183
commit 89882a545b
4 changed files with 916 additions and 1 deletions

View file

@ -0,0 +1,158 @@
// src/app/(payload)/api/community/sync-status/route.ts
// Sync Status API für Dashboard
import { NextRequest, NextResponse } from 'next/server'
import { getPayload } from 'payload'
import config from '@payload-config'
import { getUnifiedSyncStatus } from '@/lib/jobs/UnifiedSyncService'
import { getSyncStatus as getYouTubeSyncStatus } from '@/lib/jobs/syncAllComments'
/**
* GET /api/community/sync-status
* Gibt den aktuellen Sync-Status für alle Plattformen zurück
*/
export async function GET(request: NextRequest) {
try {
const payload = await getPayload({ config })
// Authentifizierung prüfen
const { user } = await payload.auth({ headers: request.headers })
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Sync-Status abrufen
const unifiedStatus = getUnifiedSyncStatus()
const youtubeStatus = getYouTubeSyncStatus()
// Account-Statistiken laden
const accounts = await payload.find({
collection: 'social-accounts',
where: {
isActive: { equals: true },
},
limit: 100,
depth: 2,
})
// Nach Plattform aggregieren
const platformStats: Record<
string,
{
platform: string
accountCount: number
accounts: Array<{
id: number
name: string
lastSyncedAt: string | null
followers: number
totalPosts: number
}>
}
> = {}
for (const account of accounts.docs) {
const platformDoc = account.platform as { slug?: string; name?: string } | undefined
const platformSlug = platformDoc?.slug || 'unknown'
const platformName = platformDoc?.name || platformSlug
if (!platformStats[platformSlug]) {
platformStats[platformSlug] = {
platform: platformName,
accountCount: 0,
accounts: [],
}
}
const stats = account.stats as {
lastSyncedAt?: string
followers?: number
totalPosts?: number
} | undefined
platformStats[platformSlug].accountCount++
platformStats[platformSlug].accounts.push({
id: account.id as number,
name: (account.displayName as string) || `Account ${account.id}`,
lastSyncedAt: stats?.lastSyncedAt || null,
followers: stats?.followers || 0,
totalPosts: stats?.totalPosts || 0,
})
}
// Interaktions-Statistiken
const interactionStats = await payload.find({
collection: 'community-interactions',
limit: 0, // Nur Count
})
// Neue Interaktionen heute
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const todayInteractions = await payload.find({
collection: 'community-interactions',
where: {
createdAt: { greater_than: todayStart.toISOString() },
},
limit: 0,
})
// Unbeantwortete Interaktionen
const unansweredInteractions = await payload.find({
collection: 'community-interactions',
where: {
status: { in: ['new', 'in_review'] },
},
limit: 0,
})
return NextResponse.json({
// Live Sync-Status
syncStatus: {
isRunning: unifiedStatus.isRunning || youtubeStatus.isRunning,
currentPlatform: unifiedStatus.currentPlatform,
currentAccount: unifiedStatus.currentAccount,
progress: unifiedStatus.progress,
lastRunAt: unifiedStatus.lastRunAt || youtubeStatus.lastRunAt,
},
// Plattform-Übersicht
platforms: Object.values(platformStats),
// Interaktions-Statistiken
interactions: {
total: interactionStats.totalDocs,
today: todayInteractions.totalDocs,
unanswered: unansweredInteractions.totalDocs,
},
// Letztes Sync-Ergebnis
lastSyncResult: unifiedStatus.lastResult
? {
success: unifiedStatus.lastResult.success,
startedAt: unifiedStatus.lastResult.startedAt,
completedAt: unifiedStatus.lastResult.completedAt,
duration: unifiedStatus.lastResult.duration,
platforms: unifiedStatus.lastResult.platforms,
totalNewComments: unifiedStatus.lastResult.results.reduce(
(sum, r) => sum + r.newComments,
0
),
totalUpdatedComments: unifiedStatus.lastResult.results.reduce(
(sum, r) => sum + r.updatedComments,
0
),
totalErrors: unifiedStatus.lastResult.errors.length,
}
: null,
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
console.error('[API] sync-status error:', error)
return NextResponse.json({ error: message }, { status: 500 })
}
}
export const dynamic = 'force-dynamic'

View file

@ -0,0 +1,203 @@
// src/app/(payload)/api/cron/community-sync/route.ts
// Unified Community Sync Cron Endpoint
import { NextRequest, NextResponse } from 'next/server'
import {
runUnifiedSync,
getUnifiedSyncStatus,
type SupportedPlatform,
type UnifiedSyncOptions,
} from '@/lib/jobs/UnifiedSyncService'
// Geheimer Token für Cron-Authentifizierung
const CRON_SECRET = process.env.CRON_SECRET
/**
* GET /api/cron/community-sync
* Wird von externem Cron-Job aufgerufen (z.B. Vercel Cron, cron-job.org)
*
* Query Parameters:
* - platforms: Komma-separierte Liste (youtube,facebook,instagram)
* - accountIds: Komma-separierte Account-IDs
* - analyzeWithAI: true/false (default: true)
* - maxItems: Maximale Items pro Account (default: 100)
*/
export async function GET(request: NextRequest) {
// Auth prüfen wenn CRON_SECRET gesetzt
if (CRON_SECRET) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${CRON_SECRET}`) {
console.warn('[Cron] Unauthorized request to community-sync')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
// Query-Parameter parsen
const searchParams = request.nextUrl.searchParams
const options: UnifiedSyncOptions = {}
// Plattform-Filter
const platformsParam = searchParams.get('platforms')
if (platformsParam) {
const platforms = platformsParam.split(',').map((p) => p.trim().toLowerCase())
const validPlatforms = platforms.filter((p): p is SupportedPlatform =>
['youtube', 'facebook', 'instagram'].includes(p)
)
if (validPlatforms.length > 0) {
options.platforms = validPlatforms
}
}
// Account-ID Filter
const accountIdsParam = searchParams.get('accountIds')
if (accountIdsParam) {
const ids = accountIdsParam
.split(',')
.map((id) => parseInt(id.trim(), 10))
.filter((id) => !isNaN(id))
if (ids.length > 0) {
options.accountIds = ids
}
}
// AI-Analyse
const analyzeParam = searchParams.get('analyzeWithAI')
if (analyzeParam !== null) {
options.analyzeWithAI = analyzeParam !== 'false'
}
// Max Items
const maxItemsParam = searchParams.get('maxItems')
if (maxItemsParam) {
const maxItems = parseInt(maxItemsParam, 10)
if (!isNaN(maxItems) && maxItems > 0) {
options.maxItemsPerAccount = maxItems
}
}
console.log('[Cron] Starting community sync', { options })
const result = await runUnifiedSync(options)
if (result.success) {
// Gesamtstatistiken berechnen
let totalNew = 0
let totalUpdated = 0
let totalErrors = 0
for (const platform of Object.values(result.platforms)) {
if (platform) {
totalNew += platform.totalNewComments
totalUpdated += platform.totalUpdatedComments
totalErrors += platform.totalErrors
}
}
return NextResponse.json({
success: true,
message: `Sync completed: ${totalNew} new, ${totalUpdated} updated comments across ${result.results.length} accounts`,
duration: result.duration,
platforms: result.platforms,
results: result.results,
})
}
return NextResponse.json(
{
success: false,
errors: result.errors,
partialResults: result.results,
},
{ status: 500 }
)
}
/**
* POST /api/cron/community-sync
* Manueller Sync-Trigger mit erweiterten Optionen
*/
export async function POST(request: NextRequest) {
// Auth prüfen
if (CRON_SECRET) {
const authHeader = request.headers.get('authorization')
if (authHeader !== `Bearer ${CRON_SECRET}`) {
console.warn('[Cron] Unauthorized POST to community-sync')
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
try {
const body = await request.json()
const options: UnifiedSyncOptions = {}
// Optionen aus Body übernehmen
if (body.platforms && Array.isArray(body.platforms)) {
options.platforms = body.platforms.filter((p: string): p is SupportedPlatform =>
['youtube', 'facebook', 'instagram'].includes(p)
)
}
if (body.accountIds && Array.isArray(body.accountIds)) {
options.accountIds = body.accountIds.filter(
(id: unknown) => typeof id === 'number' && id > 0
)
}
if (typeof body.analyzeWithAI === 'boolean') {
options.analyzeWithAI = body.analyzeWithAI
}
if (typeof body.maxItemsPerAccount === 'number' && body.maxItemsPerAccount > 0) {
options.maxItemsPerAccount = body.maxItemsPerAccount
}
if (body.sinceDate) {
const date = new Date(body.sinceDate)
if (!isNaN(date.getTime())) {
options.sinceDate = date
}
}
console.log('[Cron] Manual community sync triggered', { options })
const result = await runUnifiedSync(options)
return NextResponse.json({
success: result.success,
startedAt: result.startedAt,
completedAt: result.completedAt,
duration: result.duration,
platforms: result.platforms,
results: result.results,
errors: result.errors,
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
console.error('[Cron] POST error:', error)
return NextResponse.json({ error: message }, { status: 500 })
}
}
/**
* HEAD /api/cron/community-sync
* Status-Check für Monitoring
*/
export async function HEAD() {
const status = getUnifiedSyncStatus()
return new NextResponse(null, {
status: status.isRunning ? 423 : 200, // 423 = Locked (running)
headers: {
'X-Sync-Running': status.isRunning.toString(),
'X-Current-Platform': status.currentPlatform || 'none',
'X-Current-Account': status.currentAccount || 'none',
'X-Progress': `${status.progress.percentage}%`,
'X-Last-Run': status.lastRunAt || 'never',
},
})
}
export const dynamic = 'force-dynamic'
export const maxDuration = 300 // 5 Minuten max (Vercel)

View file

@ -0,0 +1,554 @@
// src/lib/jobs/UnifiedSyncService.ts
// Unified Sync Service für alle Social Media Plattformen
import { getPayload, Payload } from 'payload'
import config from '@payload-config'
import { CommentsSyncService } from '../integrations/youtube/CommentsSyncService'
import { FacebookSyncService } from '../integrations/meta/FacebookSyncService'
import { InstagramSyncService } from '../integrations/meta/InstagramSyncService'
import { JobLogger } from './JobLogger'
// =============================================================================
// Types
// =============================================================================
export type SupportedPlatform = 'youtube' | 'facebook' | 'instagram'
export interface PlatformSyncResult {
platform: SupportedPlatform
accountId: number
accountName: string
success: boolean
newComments: number
updatedComments: number
newMessages: number
postsProcessed: number
errors: string[]
duration: number
}
export interface UnifiedSyncResult {
success: boolean
startedAt: Date
completedAt: Date
duration: number
platforms: {
[key in SupportedPlatform]?: {
accountsProcessed: number
totalNewComments: number
totalUpdatedComments: number
totalErrors: number
}
}
results: PlatformSyncResult[]
errors: string[]
}
export interface UnifiedSyncOptions {
/** Nur bestimmte Plattformen synchronisieren */
platforms?: SupportedPlatform[]
/** Nur bestimmte Account-IDs synchronisieren */
accountIds?: number[]
/** AI-Analyse aktivieren */
analyzeWithAI?: boolean
/** Maximale Items pro Account */
maxItemsPerAccount?: number
/** Nur seit diesem Datum synchronisieren */
sinceDate?: Date
}
export interface SyncStatus {
isRunning: boolean
currentPlatform: SupportedPlatform | null
currentAccount: string | null
progress: {
totalAccounts: number
processedAccounts: number
percentage: number
}
lastRunAt: string | null
lastResult: UnifiedSyncResult | null
}
// =============================================================================
// Global State
// =============================================================================
let syncStatus: SyncStatus = {
isRunning: false,
currentPlatform: null,
currentAccount: null,
progress: {
totalAccounts: 0,
processedAccounts: 0,
percentage: 0,
},
lastRunAt: null,
lastResult: null,
}
// =============================================================================
// Platform Detection
// =============================================================================
function detectPlatform(platformDoc: any): SupportedPlatform | null {
const slug = platformDoc?.slug?.toLowerCase()
const apiType = platformDoc?.apiConfig?.apiType
if (slug === 'youtube' || apiType === 'youtube_v3') {
return 'youtube'
}
if (slug === 'facebook' || apiType === 'facebook_graph' || apiType === 'meta_graph') {
return 'facebook'
}
if (slug === 'instagram' || apiType === 'instagram_graph') {
return 'instagram'
}
return null
}
// =============================================================================
// Unified Sync Service
// =============================================================================
export class UnifiedSyncService {
private payload: Payload
private logger: JobLogger
private youtubeSyncService: CommentsSyncService
private facebookSyncService: FacebookSyncService
private instagramSyncService: InstagramSyncService
constructor(payload: Payload) {
this.payload = payload
this.logger = new JobLogger('unified-sync')
this.youtubeSyncService = new CommentsSyncService(payload)
this.facebookSyncService = new FacebookSyncService(payload)
this.instagramSyncService = new InstagramSyncService(payload)
}
/**
* Führt einen vollständigen Sync aller Plattformen durch
*/
async runFullSync(options: UnifiedSyncOptions = {}): Promise<UnifiedSyncResult> {
const {
platforms,
accountIds,
analyzeWithAI = true,
maxItemsPerAccount = 100,
sinceDate,
} = options
const startedAt = new Date()
const results: PlatformSyncResult[] = []
const errors: string[] = []
// Prüfen ob bereits ein Sync läuft
if (syncStatus.isRunning) {
return {
success: false,
startedAt,
completedAt: new Date(),
duration: 0,
platforms: {},
results: [],
errors: ['Sync already running'],
}
}
syncStatus.isRunning = true
syncStatus.progress = { totalAccounts: 0, processedAccounts: 0, percentage: 0 }
try {
this.logger.info('Starting unified sync', { platforms, accountIds })
// Alle aktiven Accounts laden
const accounts = await this.loadActiveAccounts(platforms, accountIds)
syncStatus.progress.totalAccounts = accounts.length
this.logger.info(`Found ${accounts.length} active accounts to sync`)
if (accounts.length === 0) {
return this.createResult(startedAt, results, errors)
}
// Accounts nach Plattform gruppieren
const accountsByPlatform = this.groupAccountsByPlatform(accounts)
// Jede Plattform synchronisieren
for (const [platform, platformAccounts] of Object.entries(accountsByPlatform)) {
syncStatus.currentPlatform = platform as SupportedPlatform
for (const account of platformAccounts) {
syncStatus.currentAccount = account.displayName || `Account ${account.id}`
try {
const result = await this.syncAccount(
platform as SupportedPlatform,
account,
{
analyzeWithAI,
maxItems: maxItemsPerAccount,
sinceDate,
}
)
results.push(result)
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
errors.push(`${platform}/${account.id}: ${errorMsg}`)
this.logger.error(`Failed to sync account ${account.id}`, error)
}
syncStatus.progress.processedAccounts++
syncStatus.progress.percentage = Math.round(
(syncStatus.progress.processedAccounts / syncStatus.progress.totalAccounts) * 100
)
// Rate Limiting zwischen Accounts
await this.sleep(500)
}
}
const finalResult = this.createResult(startedAt, results, errors)
syncStatus.lastResult = finalResult
syncStatus.lastRunAt = startedAt.toISOString()
this.logger.summary({
success: finalResult.success,
processed: results.length,
errors: errors.length,
})
return finalResult
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
this.logger.error('Unified sync failed', error)
errors.push(errorMsg)
return this.createResult(startedAt, results, errors)
} finally {
syncStatus.isRunning = false
syncStatus.currentPlatform = null
syncStatus.currentAccount = null
}
}
/**
* Lädt alle aktiven Social Accounts
*/
private async loadActiveAccounts(
platforms?: SupportedPlatform[],
accountIds?: number[]
): Promise<any[]> {
const where: any = {
and: [{ isActive: { equals: true } }],
}
// Account-ID Filter
if (accountIds && accountIds.length > 0) {
where.and.push({
id: { in: accountIds },
})
}
const accounts = await this.payload.find({
collection: 'social-accounts',
where,
limit: 100,
depth: 2,
})
// Plattform-Filter anwenden
if (platforms && platforms.length > 0) {
return accounts.docs.filter((account) => {
const platform = detectPlatform(account.platform)
return platform && platforms.includes(platform)
})
}
// Nur Accounts mit unterstützter Plattform zurückgeben
return accounts.docs.filter((account) => {
const platform = detectPlatform(account.platform)
return platform !== null
})
}
/**
* Gruppiert Accounts nach Plattform
*/
private groupAccountsByPlatform(accounts: any[]): Record<SupportedPlatform, any[]> {
const grouped: Record<SupportedPlatform, any[]> = {
youtube: [],
facebook: [],
instagram: [],
}
for (const account of accounts) {
const platform = detectPlatform(account.platform)
if (platform) {
grouped[platform].push(account)
}
}
return grouped
}
/**
* Synchronisiert einen einzelnen Account
*/
private async syncAccount(
platform: SupportedPlatform,
account: any,
options: {
analyzeWithAI: boolean
maxItems: number
sinceDate?: Date
}
): Promise<PlatformSyncResult> {
const { analyzeWithAI, maxItems, sinceDate } = options
const accountId = account.id as number
const accountName = (account.displayName as string) || `Account ${accountId}`
const startTime = Date.now()
this.logger.info(`Syncing ${platform} account: ${accountName}`)
// Default since date: letzte 7 Tage oder letzter Sync
const stats = account.stats as { lastSyncedAt?: string } | undefined
const effectiveSinceDate =
sinceDate ||
(stats?.lastSyncedAt
? new Date(stats.lastSyncedAt)
: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
try {
let result: PlatformSyncResult
switch (platform) {
case 'youtube':
result = await this.syncYouTubeAccount(accountId, accountName, {
sinceDate: effectiveSinceDate,
maxItems,
analyzeWithAI,
})
break
case 'facebook':
result = await this.syncFacebookAccount(accountId, accountName, {
sinceDate: effectiveSinceDate,
maxItems,
analyzeWithAI,
})
break
case 'instagram':
result = await this.syncInstagramAccount(accountId, accountName, {
sinceDate: effectiveSinceDate,
maxItems,
analyzeWithAI,
})
break
default:
throw new Error(`Unsupported platform: ${platform}`)
}
result.duration = Date.now() - startTime
this.logger.info(
`${platform}/${accountName}: ${result.newComments} new, ${result.updatedComments} updated`
)
return result
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
return {
platform,
accountId,
accountName,
success: false,
newComments: 0,
updatedComments: 0,
newMessages: 0,
postsProcessed: 0,
errors: [errorMsg],
duration: Date.now() - startTime,
}
}
}
/**
* YouTube Account synchronisieren
*/
private async syncYouTubeAccount(
accountId: number,
accountName: string,
options: { sinceDate: Date; maxItems: number; analyzeWithAI: boolean }
): Promise<PlatformSyncResult> {
const syncResult = await this.youtubeSyncService.syncComments({
socialAccountId: accountId,
sinceDate: options.sinceDate,
maxComments: options.maxItems,
analyzeWithAI: options.analyzeWithAI,
})
return {
platform: 'youtube',
accountId,
accountName,
success: syncResult.success,
newComments: syncResult.newComments,
updatedComments: syncResult.updatedComments,
newMessages: 0,
postsProcessed: 0,
errors: syncResult.errors,
duration: 0,
}
}
/**
* Facebook Account synchronisieren
*/
private async syncFacebookAccount(
accountId: number,
accountName: string,
options: { sinceDate: Date; maxItems: number; analyzeWithAI: boolean }
): Promise<PlatformSyncResult> {
const syncResult = await this.facebookSyncService.syncComments({
socialAccountId: accountId,
sinceDate: options.sinceDate,
maxItems: options.maxItems,
analyzeWithAI: options.analyzeWithAI,
syncComments: true,
syncMessages: false,
})
return {
platform: 'facebook',
accountId,
accountName,
success: syncResult.errors.length === 0,
newComments: syncResult.newComments,
updatedComments: syncResult.updatedComments,
newMessages: syncResult.newMessages,
postsProcessed: syncResult.postsProcessed,
errors: syncResult.errors,
duration: syncResult.duration,
}
}
/**
* Instagram Account synchronisieren
*/
private async syncInstagramAccount(
accountId: number,
accountName: string,
options: { sinceDate: Date; maxItems: number; analyzeWithAI: boolean }
): Promise<PlatformSyncResult> {
const syncResult = await this.instagramSyncService.syncComments({
socialAccountId: accountId,
sinceDate: options.sinceDate,
maxItems: options.maxItems,
analyzeWithAI: options.analyzeWithAI,
syncComments: true,
syncMentions: true,
})
return {
platform: 'instagram',
accountId,
accountName,
success: syncResult.errors.length === 0,
newComments: syncResult.newComments,
updatedComments: syncResult.updatedComments,
newMessages: syncResult.newMessages,
postsProcessed: syncResult.postsProcessed,
errors: syncResult.errors,
duration: syncResult.duration,
}
}
/**
* Erstellt das finale Sync-Ergebnis
*/
private createResult(
startedAt: Date,
results: PlatformSyncResult[],
errors: string[]
): UnifiedSyncResult {
const completedAt = new Date()
// Ergebnisse nach Plattform aggregieren
const platforms: UnifiedSyncResult['platforms'] = {}
for (const result of results) {
if (!platforms[result.platform]) {
platforms[result.platform] = {
accountsProcessed: 0,
totalNewComments: 0,
totalUpdatedComments: 0,
totalErrors: 0,
}
}
platforms[result.platform]!.accountsProcessed++
platforms[result.platform]!.totalNewComments += result.newComments
platforms[result.platform]!.totalUpdatedComments += result.updatedComments
platforms[result.platform]!.totalErrors += result.errors.length
}
return {
success: errors.length === 0 && results.every((r) => r.success),
startedAt,
completedAt,
duration: completedAt.getTime() - startedAt.getTime(),
platforms,
results,
errors,
}
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
}
// =============================================================================
// Convenience Functions
// =============================================================================
/**
* Führt einen vollständigen Sync aus (Singleton-Pattern)
*/
export async function runUnifiedSync(
options: UnifiedSyncOptions = {}
): Promise<UnifiedSyncResult> {
const payload = await getPayload({ config })
const service = new UnifiedSyncService(payload)
return service.runFullSync(options)
}
/**
* Gibt den aktuellen Sync-Status zurück
*/
export function getUnifiedSyncStatus(): SyncStatus {
return { ...syncStatus }
}
/**
* Setzt den Sync-Status zurück (für Tests)
*/
export function resetUnifiedSyncStatus(): void {
syncStatus = {
isRunning: false,
currentPlatform: null,
currentAccount: null,
progress: {
totalAccounts: 0,
processedAccounts: 0,
percentage: 0,
},
lastRunAt: null,
lastResult: null,
}
}
export default UnifiedSyncService

View file

@ -2,7 +2,7 @@
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [
{
"path": "/api/cron/youtube-sync",
"path": "/api/cron/community-sync",
"schedule": "*/15 * * * *"
}
]