Ai-Mee Help Centre
Home
Features
How-To Guides
FAQ
Need Help?
Home
Features
How-To Guides
FAQ
Need Help?

Step 4 — Rewrite planAndScheduleApprovedPosts (Full Algorithmic)

File: api/src/routes/posts.ts
Prerequisites: Step 2 — Posts Service, Step 3 — Clients Service


What This Does

Replaces the LLM-based parent-post scheduler with a pure-algorithmic per-platform scheduler that:

  1. Looks up each platform's posting frequency from customer_posting_schedule
  2. Computes interval in milliseconds from FREQUENCY_OPTIONS.approxDaysInterval
  3. Seeds a per-platform cursor from the latest existing scheduled_at for that platform
  4. Advances the cursor by the interval for each platform post in ascending created_at order
  5. Sets preferred posting hours (10:00 UTC) when seeding from scratch
  6. Calls scheduleManyPlatformPosts (added in Step 2)

Also removes the now-unused LLM helper functions (extractFirstJsonObject, normalizeLlmResponseContent, buildFallbackScheduleTimes, toFutureIso) and updates the two PATCH /post/:id/schedule and PATCH /post/:id/unschedule route handlers.


4.1 — Update imports at the top of posts.ts

Find the existing import block that includes scheduleManyPosts:

import {
  initPost,
  savePlatformContent,
  getPostStatus,
  botGeneratePost,
  scheduleManyPosts,
} from '../services/posts.service.js'

Replace with (add scheduleManyPlatformPosts, keep scheduleManyPosts for POST /posts/schedule-bulk):

import {
  initPost,
  savePlatformContent,
  getPostStatus,
  botGeneratePost,
  scheduleManyPosts,
  scheduleManyPlatformPosts,
} from '../services/posts.service.js'
import { listPostingSchedules } from '../services/clients.service.js'
import { FREQUENCY_OPTIONS } from '../constants/posting-frequency.js'

Also remove the existing getLLM import if it is only used by planAndScheduleApprovedPosts (verify first — it may be used by other routes in the same file). If still needed elsewhere, leave it.


4.2 — Remove LLM helper functions

Delete the four standalone helper functions that are only used by the old LLM scheduler. Find and delete each of:

function extractFirstJsonObject(raw: string): string | null { ... }
function toFutureIso(value: string, now: Date): string | null { ... }
function normalizeLlmResponseContent(response: unknown): string { ... }
function buildFallbackScheduleTimes(...): string[] { ... }

Also delete the AutoSchedulePostRow interface (it referenced scheduled: string | null).


4.3 — Replace planAndScheduleApprovedPosts

Find the entire planAndScheduleApprovedPosts function and replace it with:

const MS_PER_DAY = 24 * 60 * 60 * 1000
const DEFAULT_INTERVAL_DAYS = 3.5
const PREFERRED_POSTING_HOUR_UTC = 10

/** Returns interval in milliseconds for a given platform from the posting schedule. */
function platformIntervalMs(
  platform: string,
  scheduleMap: Map<string, { frequency: string; custom_interval_days: number | null }>
): number {
  const entry = scheduleMap.get(platform)
  if (!entry) return DEFAULT_INTERVAL_DAYS * MS_PER_DAY

  if (entry.frequency === 'custom' && entry.custom_interval_days != null) {
    return entry.custom_interval_days * MS_PER_DAY
  }

  const opt = FREQUENCY_OPTIONS.find((o) => o.value === entry.frequency)
  return (opt?.approxDaysInterval ?? DEFAULT_INTERVAL_DAYS) * MS_PER_DAY
}

/** Advances a cursor to the preferred posting hour (10:00 UTC) on its current or next day. */
function alignToPreferredHour(date: Date, now: Date): Date {
  const aligned = new Date(date)
  aligned.setUTCHours(PREFERRED_POSTING_HOUR_UTC, 0, 0, 0)
  if (aligned <= now) aligned.setUTCDate(aligned.getUTCDate() + 1)
  return aligned
}

async function planAndScheduleApprovedPosts(
  supabase: ReturnType<typeof createSupabaseClient>,
  customerId: number
) {
  const now = new Date()

  // 1. Fetch approved parent posts (no platform posts scheduled yet)
  const { data: approvedData, error: approvedErr } = await supabase
    .from('customer_posts')
    .select('id, title, created_at')
    .eq('customer_id', customerId)
    .eq('status', 'approved')
    .order('created_at', { ascending: true })

  if (approvedErr) throw new Error(approvedErr.message)

  const approvedPosts = approvedData ?? []
  if (approvedPosts.length === 0) {
    return { attempted_count: 0, scheduled_count: 0, failed_count: 0, results: [] }
  }

  const approvedPostIds = approvedPosts.map((p: any) => p.id as number)

  // 2. Fetch platform posts for the approved parent posts
  const { data: platformPostData, error: platformErr } = await supabase
    .from('customer_platform_post')
    .select('id, platform, customer_post_id, scheduled_at')
    .in('customer_post_id', approvedPostIds)

  if (platformErr) throw new Error(platformErr.message)

  const platformPosts = (platformPostData ?? []) as Array<{
    id: number
    platform: string
    customer_post_id: number
    scheduled_at: string | null
  }>

  // Only schedule platform posts that don't already have a scheduled_at
  const unscheduled = platformPosts.filter((pp) => !pp.scheduled_at)
  if (unscheduled.length === 0) {
    return { attempted_count: 0, scheduled_count: 0, failed_count: 0, results: [] }
  }

  // 3. Load platform posting schedules → interval map
  const { schedules: postingSchedules } = await listPostingSchedules(customerId)
  const scheduleMap = new Map(
    postingSchedules.map((s) => [
      s.platform,
      { frequency: s.frequency, custom_interval_days: s.custom_interval_days },
    ])
  )

  // 4. Seed per-platform cursor from latest existing scheduled_at for this customer
  const { data: existingScheduled } = await supabase
    .from('customer_platform_post')
    .select('platform, scheduled_at')
    .eq('customer_posts.customer_id', customerId)
    .not('scheduled_at', 'is', null)
    .gt('scheduled_at', now.toISOString())
    .order('scheduled_at', { ascending: false })

  const platformCursorMap = new Map<string, Date>()
  for (const row of existingScheduled ?? []) {
    const platform = row.platform as string
    const scheduledAt = new Date(row.scheduled_at as string)
    if (!platformCursorMap.has(platform)) {
      platformCursorMap.set(platform, scheduledAt)
    }
  }

  // 5. Group unscheduled platform posts by platform, sorted by parent created_at
  const postCreatedAt = new Map(
    approvedPosts.map((p: any) => [p.id as number, p.created_at as string])
  )
  const byPlatform = new Map<string, typeof unscheduled>()
  for (const pp of unscheduled) {
    const list = byPlatform.get(pp.platform) ?? []
    list.push(pp)
    byPlatform.set(pp.platform, list)
  }
  for (const [, list] of byPlatform) {
    list.sort((a, b) => {
      const ta = postCreatedAt.get(a.customer_post_id) ?? ''
      const tb = postCreatedAt.get(b.customer_post_id) ?? ''
      return ta < tb ? -1 : ta > tb ? 1 : 0
    })
  }

  // 6. Assign scheduled_at per platform post using interval arithmetic
  const toSchedule: Array<{ platformPostId: number; scheduledAt: string; parentPostId: number }> =
    []

  for (const [platform, posts] of byPlatform) {
    const intervalMs = platformIntervalMs(platform, scheduleMap)
    let cursor = platformCursorMap.get(platform)

    for (const pp of posts) {
      if (cursor) {
        cursor = new Date(cursor.getTime() + intervalMs)
      } else {
        // No existing schedule — start from tomorrow at preferred hour
        cursor = alignToPreferredHour(new Date(now.getTime() + intervalMs), now)
      }

      toSchedule.push({
        platformPostId: pp.id,
        scheduledAt: cursor.toISOString(),
        parentPostId: pp.customer_post_id,
      })
    }

    // Update cursor map so subsequent batches respect already-assigned times
    if (cursor) platformCursorMap.set(platform, cursor)
  }

  // 7. Persist in batches of 50
  const BATCH_SIZE = 50
  const combinedResults: Array<Record<string, unknown>> = []
  let scheduledCount = 0
  let failedCount = 0

  for (let i = 0; i < toSchedule.length; i += BATCH_SIZE) {
    const batch = toSchedule.slice(i, i + BATCH_SIZE)
    try {
      const result = await scheduleManyPlatformPosts(batch)
      combinedResults.push(...result.results)
      scheduledCount += result.scheduled_count
      failedCount += result.failed_count
    } catch (err) {
      console.error('plan_post_schedule: batch failed', { customerId, batchStart: i, err })
      failedCount += batch.length
      combinedResults.push(
        ...batch.map((item) => ({
          id: item.platformPostId,
          error: err instanceof Error ? err.message : String(err),
        }))
      )
    }
  }

  return {
    attempted_count: toSchedule.length,
    scheduled_count: scheduledCount,
    failed_count: failedCount,
    results: combinedResults,
  }
}

4.4 — Update PATCH /post/:id/schedule route handler

Find the handler body (look for the comment PATCH /post/:id/schedule — set the scheduled publish time). The handler currently writes scheduled directly to customer_posts. Replace the entire handler body with a call to schedulePost from the service:

app.patch(
  '/post/:id/schedule',
  {
    schema: { params: PostIdParamsSchema, body: SchedulePostSchema },
  },
  async (request, reply) => {
    const postId = parseInt(request.params.id, 10)
    if (isNaN(postId)) return reply.code(400).send({ error: 'Invalid post ID' })

    try {
      const result = await schedulePost(postId, request.body.scheduled_at)
      return result
    } catch (err: any) {
      return reply.code(err?.statusCode ?? 500).send({ error: err?.message ?? String(err) })
    }
  }
)

Import schedulePost and unschedulePost from posts.service.ts — add them to the existing import in 4.1:

import {
  initPost,
  savePlatformContent,
  getPostStatus,
  botGeneratePost,
  scheduleManyPosts,
  scheduleManyPlatformPosts,
  schedulePost,
  unschedulePost,
} from '../services/posts.service.js'

4.5 — Update PATCH /post/:id/unschedule route handler

Replace the handler body:

app.patch(
  '/post/:id/unschedule',
  {
    schema: { params: PostIdParamsSchema },
  },
  async (request, reply) => {
    const postId = parseInt(request.params.id, 10)
    if (isNaN(postId)) return reply.code(400).send({ error: 'Invalid post ID' })

    try {
      const result = await unschedulePost(postId)
      return result
    } catch (err: any) {
      return reply.code(err?.statusCode ?? 500).send({ error: err?.message ?? String(err) })
    }
  }
)

4.6 — Verify

cd api
pnpm build
# No TypeScript errors

pnpm test:routes
# Route tests pass

✅ Done

Proceed to Step 5 — Bot Tools.