diff --git a/.gitignore b/.gitignore index c34b1df..ddea682 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +.migrations.json + # dependencies /node_modules /.pnp diff --git a/Dockerfile b/Dockerfile index 62aa1a1..7ff93a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,4 +33,4 @@ COPY --from=builder /app/package.json ./ COPY --from=builder /app/src ./src COPY --from=builder /app/scripts ./scripts EXPOSE 3000 -CMD ["node", "scripts/start.js"] +CMD ["node", "./start.js"] diff --git a/migrations/20251020_init.js b/migrations/20251020_init.js new file mode 100644 index 0000000..7344fe2 --- /dev/null +++ b/migrations/20251020_init.js @@ -0,0 +1,18 @@ +async function up(service) { + // Example migration: Add a new field to app metadata + if (service.isPreviousVersionLessThan('0.1.1')) { + console.log('Migrating to 0.1.1: ') + } +} + +async function down(service) { + // Example rollback: Remove the new field from app metadata + if (service.isPreviousVersionGreaterThan('0.1.0')) { + console.log('Reverting migration to 0.1.0: ') + } +} + +module.exports = { + up, + down, +} diff --git a/scripts/start.js b/scripts/start.js deleted file mode 100755 index 1f6d631..0000000 --- a/scripts/start.js +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env node -import { spawn } from 'node:child_process' - -console.log('Starting BskyBackup application...') - -// Wait for server to be ready by checking port -async function waitForServer(port = 3000, maxAttempts = 30) { - const net = await import('node:net') - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - try { - await new Promise((resolve, reject) => { - const socket = new net.Socket() - socket.setTimeout(1000) - socket.on('connect', () => { - socket.destroy() - resolve() - }) - socket.on('timeout', () => { - socket.destroy() - reject(new Error('Timeout')) - }) - socket.on('error', reject) - socket.connect(port, 'localhost') - }) - - console.log(`šŸš€ Server is ready on port ${port}!`) - return true - } catch { - if (attempt % 5 === 0) { - console.log(`Waiting for server... (attempt ${attempt}/${maxAttempts})`) - } - await new Promise((resolve) => setTimeout(resolve, 1000)) - } - } - throw new Error(`Server not ready after ${maxAttempts} attempts`) -} - -// Make initial fetch when server is ready -async function makeInitialFetch() { - try { - // Wait for server to be ready - await waitForServer(3000) - - // Additional small delay to ensure HTTP server is fully ready - await new Promise((resolve) => setTimeout(resolve, 2000)) - - const response = await fetch('http://localhost:3000/api/util?action=init', { - method: 'POST', - }) - - if (response.ok) { - console.log('āœ… Init successful') - } else { - console.log('āš ļø Init failed:', response.status) - } - } catch (error) { - console.error('āŒ Initial fetch failed:', error.message) - } -} - -async function signalStop() { - try { - // Wait for server to be ready - await waitForServer(3000) - - // Additional small delay to ensure HTTP server is fully ready - await new Promise((resolve) => setTimeout(resolve, 2000)) - - const response = await fetch( - 'http://localhost:3000/api/util?action=shutdown', - { - method: 'POST', - } - ) - - if (response.ok) { - console.log('āœ… Stop signal successful') - } else { - console.log('āš ļø Stop signal failed:', response.status) - } - } catch (error) { - console.error('āŒ Stop signal failed:', error.message) - } -} - -// Start the application -async function start() { - console.log('Starting Next.js server...') - const server = spawn('npm', ['run', 'start'], { - stdio: ['inherit', 'pipe', 'pipe'], // Pipe stdout and stderr to capture output - env: process.env, - }) - - let serverReady = false - - // Listen for stdout to detect when server is ready - server.stdout.on('data', (data) => { - const output = data.toString() - process.stdout.write(output) // Still show the output - - // Check for Next.js ready patterns - if ( - !serverReady && - (output.includes('Ready in') || - output.includes('ready - started server on') || - output.includes('Local:') || - output.includes('localhost:3000')) - ) { - serverReady = true - console.log('\nšŸš€ Server is ready! Making initial fetch...') - - // Make your fetch call here - makeInitialFetch() - } - }) - - // Listen for stderr - server.stderr.on('data', (data) => { - const output = data.toString() - process.stderr.write(output) // Still show error output - }) - - // Server events - server.on('spawn', () => { - console.log('Next.js server process spawned') - }) - - server.on('error', (error) => { - console.error('Server process error:', error) - }) - - server.on('close', (code) => { - console.log(`Next.js server exited with code ${code}`) - process.exit(code ?? 0) - }) - - // Handle graceful shutdown - process.on('SIGTERM', () => { - signalStop() - console.log('Received SIGTERM, shutting down gracefully') - server.kill('SIGTERM') - }) - - process.on('SIGINT', () => { - signalStop() - console.log('Received SIGINT, shutting down gracefully') - server.kill('SIGINT') - }) -} - -start().catch((error) => { - console.error('Failed to start application:', error) - process.exit(1) -}) diff --git a/src/app/api/drafts/route.ts b/src/app/api/drafts/route.ts index c11e906..5ffa6a5 100644 --- a/src/app/api/drafts/route.ts +++ b/src/app/api/drafts/route.ts @@ -2,40 +2,45 @@ import { NextResponse } from 'next/server' import { getDraftPosts, - getDraftPost, createDraftPost, updateDraftPost, getDraftPostsInGroup, + getDraftPostsInSchedule, } from '@/app/api/services/DraftPostService' +import { getSchedules } from '@/app/api/services/SchedulePostService' import type { CreateDraftInput, DraftPost } from '@/types/drafts' import Logger from '@/app/api-helpers/logger' +import { + withBskyLogoutForRequest, + withBskyLogoutWithId, +} from '@/app/api-helpers/apiWrapper' +import { Schedule } from '@/types/scheduler' const logger = new Logger('DraftsRoute') -export async function GET( - request: Request, - { params }: { params: Promise<{ id?: string }> } -) { - let id = undefined - - if (params) { - const resolvedParams = await params - id = resolvedParams.id - } - +export const GET = withBskyLogoutForRequest(async (request) => { const { searchParams } = new URL(request.url) const group = searchParams.get('group') || undefined + const scheduleId = searchParams.get('schedule') || undefined const searchTerm = searchParams.get('searchTerm') || undefined try { - if (id) { - const post = await getDraftPost(id) - return NextResponse.json(post) - } - let posts: DraftPost[] = [] if (group) { posts = await getDraftPostsInGroup(group) + } else if (scheduleId) { + const schedule = (await getSchedules()).find( + (s) => s.id === scheduleId + ) as Schedule + + if (!schedule) { + return NextResponse.json( + { error: 'Schedule not found' }, + { status: 404 } + ) + } + + posts = await getDraftPostsInSchedule(schedule) } else { posts = await getDraftPosts() } @@ -61,9 +66,9 @@ export async function GET( { status: 500 } ) } -} +}) -export async function POST(request: Request) { +export const POST = withBskyLogoutForRequest(async (request) => { try { const input = await request.json() if (Array.isArray(input)) { @@ -84,15 +89,11 @@ export async function POST(request: Request) { { status: 500 } ) } -} +}) -export async function PUT( - request: Request, - { params }: { params: Promise<{ id?: string }> } -) { +export const PUT = withBskyLogoutWithId(async (id, request) => { try { const input: CreateDraftInput = await request.json() - const { id } = await params if (!id) { return NextResponse.json( { error: 'Post ID is required' }, @@ -109,4 +110,4 @@ export async function PUT( { status: 500 } ) } -} +}) diff --git a/src/app/api/schedules/[id]/posts/route.ts b/src/app/api/schedules/[id]/route.ts similarity index 58% rename from src/app/api/schedules/[id]/posts/route.ts rename to src/app/api/schedules/[id]/route.ts index 3df633c..5f00c70 100644 --- a/src/app/api/schedules/[id]/posts/route.ts +++ b/src/app/api/schedules/[id]/route.ts @@ -1,24 +1,16 @@ import { getScheduleLookups, publishNextPost, -} from '../../../services/SchedulePostService' + reorderSchedulePosts, +} from '../../services/SchedulePostService' import { NextResponse } from 'next/server' import Logger from '@/app/api-helpers/logger' const logger = new Logger('SchPostRoute') import { withBskyLogoutWithId } from '@/app/api-helpers/apiWrapper' -export const GET = withBskyLogoutWithId(async (id, request) => { +// Get schedule lookups +export const GET = withBskyLogoutWithId(async (id) => { try { - const searchParams = new URL(request.url).searchParams - const dateCountParam = searchParams.get('dateCount') - const dateCount = dateCountParam ? parseInt(dateCountParam, 10) : 5 - if (Number.isNaN(dateCount) || dateCount <= 0) { - return NextResponse.json( - { error: 'dateCount must be a positive integer' }, - { status: 400 } - ) - } - if (!id) { logger.error('Schedule ID is required') return NextResponse.json( @@ -28,7 +20,7 @@ export const GET = withBskyLogoutWithId(async (id, request) => { } const now = new Date() - const lookups = await getScheduleLookups(now, id, dateCount) + const lookups = await getScheduleLookups(now, id) if (!lookups) { logger.error('No scheduled lookups found') return NextResponse.json( @@ -46,6 +38,40 @@ export const GET = withBskyLogoutWithId(async (id, request) => { } }) +// Reorder next posts for schedule +export const PUT = withBskyLogoutWithId(async (id, request) => { + try { + const { newOrder } = await request.json() + if (!id) { + logger.error('Schedule ID is required for updating next posts') + return NextResponse.json( + { error: 'Schedule ID is required' }, + { status: 400 } + ) + } + if (!Array.isArray(newOrder)) { + logger.error('newOrder must be an array') + return NextResponse.json( + { error: 'newOrder must be an array' }, + { status: 400 } + ) + } + + await reorderSchedulePosts(id, newOrder) + + return NextResponse.json({ + message: 'Schedule order updated successfully', + }) + } catch (error) { + logger.error('Failed to update schedule order', error) + return NextResponse.json( + { error: 'Failed to update schedule order' }, + { status: 500 } + ) + } +}) + +// Publish the next post for the schedule export const POST = withBskyLogoutWithId(async (id) => { try { if (!id) { diff --git a/src/app/api/services/DraftPostService.ts b/src/app/api/services/DraftPostService.ts index 2602a3c..817a931 100644 --- a/src/app/api/services/DraftPostService.ts +++ b/src/app/api/services/DraftPostService.ts @@ -16,7 +16,7 @@ import type { DraftMediaFileInput, } from '@/types/drafts' import { addPost as addPostToBsky } from '@/app/api-helpers/bluesky' -import type { SocialPlatform } from '@/types/scheduler' +import type { Schedule, SocialPlatform } from '@/types/scheduler' import Logger from '../../api-helpers/logger' import { setCache, getCache } from '../services/CacheService' import { ensureDir, removeDir, safeName } from '../../api-helpers/utils' @@ -60,6 +60,38 @@ export async function getDraftPostsInGroup( return posts.filter((p) => p.group === group) } +export async function getDraftPostsInSchedule( + schedule: Schedule +): Promise { + if (!schedule.group) { + return [] + } + + try { + let posts = await getDraftPosts() + posts = posts.filter((p) => p.group === schedule.group) + + // IDs in the order defined by the schedule + // If the post isn't already in here, then they go to end + const postOrder = schedule.postOrder || [] + + const orderedPosts = posts.sort((a, b) => { + if (!postOrder || postOrder.length === 0) return 0 + const indexA = postOrder.indexOf(a.meta.directoryName) + const indexB = postOrder.indexOf(b.meta.directoryName) + if (indexA === -1 && indexB === -1) return 0 + if (indexA === -1) return 1 + if (indexB === -1) return -1 + return indexA - indexB + }) + return orderedPosts + } catch (err) { + logger.error('Error getting draft posts in schedule:', err) + // No order, just return empty array + return [] + } +} + export async function getDraftPosts(): Promise { // simple caching to avoid repeated reads const now = Date.now() @@ -481,25 +513,6 @@ async function sendToSocialPlatform( } } -export async function getGroupOrder(group: string): Promise { - try { - const posts = await getDraftPostsInGroup(group) - posts.sort((a, b) => (a.meta.priority < b.meta.priority ? 1 : -1)) - for (let i = 0; i < posts.length; i++) { - posts[i].meta.priority = i - const metaPath = path.join(posts[i].dir, META_FILENAME) - await writeFile(metaPath, JSON.stringify(posts[i].meta, null, 2)) - } - const order = posts - .sort((a, b) => (a.meta.priority < b.meta.priority ? 1 : -1)) - .map((p) => p.meta.directoryName) - return order - } catch { - // No order, just return empty array - return [] - } -} - export async function reorderGroupPosts( group: string, newOrder: string[] diff --git a/src/app/api/services/SchedulePostService.ts b/src/app/api/services/SchedulePostService.ts index 00e76c3..5ccbac5 100644 --- a/src/app/api/services/SchedulePostService.ts +++ b/src/app/api/services/SchedulePostService.ts @@ -6,8 +6,8 @@ import type { } from '@/types/scheduler' import { getAppData, saveAppData } from '@/app/api-helpers/appData' import { - getDraftPosts, getDraftPostsInGroup, + getDraftPostsInSchedule, publishDraftPost, } from './DraftPostService' import type { DraftPost } from '@/types/drafts' @@ -128,7 +128,8 @@ export async function reorderSchedulePosts( newOrder: string[] ): Promise { logger.log(`Reordering posts for schedule ${scheduleId}.`, { newOrder }) - const schedule = (await getSchedules()).find((s) => s.id === scheduleId) + const schedules = await getSchedules() + const schedule = schedules.find((s) => s.id === scheduleId) if (!schedule) { throw new Error(`Schedule ${scheduleId} not found`) } @@ -147,7 +148,8 @@ export async function reorderSchedulePosts( } } - await reorderSchedulePosts(scheduleId, newOrder) + schedule.postOrder = newOrder + await updateScheduleData({ schedules }) } export async function getSchedulePosts( @@ -158,29 +160,25 @@ export async function getSchedulePosts( throw new Error(`Schedule ${scheduleId} not found`) } - const posts = await getDraftPosts() - return posts - .filter((p) => p.group === schedule.group) - .sort((a, b) => a.meta.priority - b.meta.priority) + return await getDraftPostsInSchedule(schedule) } export async function getScheduleLookups( startDate = new Date(), - scheduleId: string, - postDates: number = 1 + scheduleId: string ): Promise { - const nextPost = await getNextPost(scheduleId) + const nextPosts = await getSchedulePosts(scheduleId) let nextPostDates: Date[] = [] const schedules = await getSchedules() const schedule = schedules.find((s) => s.id === scheduleId) - if (schedule?.isActive) { + if (schedule?.isActive && nextPosts.length > 0) { nextPostDates = getNextTriggerTimes( startDate, schedule.frequency, - postDates + nextPosts.length ) } - return { nextPost, nextPostDates } + return { nextPosts, nextPostDates } } export async function getNextPost( diff --git a/src/app/api/services/__tests__/SchedulePostService.test.ts b/src/app/api/services/__tests__/SchedulePostService.test.ts index e53e9f4..0b21abe 100644 --- a/src/app/api/services/__tests__/SchedulePostService.test.ts +++ b/src/app/api/services/__tests__/SchedulePostService.test.ts @@ -167,35 +167,6 @@ describe('Schedule CRUD', () => { }) }) -describe('getSchedulePosts', () => { - it('should return posts for a schedule group', async () => { - const schedule: Schedule = { - id: 'schedule-1', - name: 'Test', - frequency: { - interval: { every: 1, unit: 'days' }, - timesOfDay: ['08:00'], - timeZone: 'UTC', - }, - isActive: true, - createdAt: new Date().toISOString(), - platforms: ['bluesky'], - group: 'group1', - } - ;(appDataHelpers.getAppData as jest.Mock).mockResolvedValue({ - schedules: [schedule], - }) - ;(draftPostService.getDraftPosts as jest.Mock).mockResolvedValue([ - { group: 'group1', meta: { priority: 2 } }, - { group: 'group1', meta: { priority: 1 } }, - { group: 'group2', meta: { priority: 0 } }, - ]) - const posts = await getSchedulePosts('schedule-1') - expect(posts.length).toBe(2) - expect(posts[0].meta.priority).toBe(1) - }) -}) - describe('publishNextPost', () => { it('should publish next post and update schedule', async () => { const schedule: Schedule = { diff --git a/src/app/groups/[id]/page.tsx b/src/app/groups/[id]/page.tsx deleted file mode 100644 index 1955013..0000000 --- a/src/app/groups/[id]/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import DraftProvider from "@/providers/DraftsProvider"; -import ReorderGroupPosts from "../../../components/ReorderGroupPosts"; - -export default async function GroupPage({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - - if (!id) { - return
Group not found
; - } - - return ( - -
-

Group {id}

-

- Reorder the posts in this group to change their order of publication - if the group is scheduled. -

- -
-
- ); -} diff --git a/src/app/schedules/[id]/page.tsx b/src/app/schedules/[id]/page.tsx new file mode 100644 index 0000000..eddb896 --- /dev/null +++ b/src/app/schedules/[id]/page.tsx @@ -0,0 +1,27 @@ +import ReorderSchedulePosts from '@/components/ReorderSchedulePosts' +import DraftProvider from '@/providers/DraftsProvider' + +export default async function ScheduleSortPage({ + params, +}: { + params: Promise<{ id: string }> +}) { + const { id } = await params + + if (!id) { + return
Schedule not found
+ } + + return ( + +
+

Schedule {id}

+

+ Reorder the posts in this schedule to change their order of + publication if the schedule is active. +

+ +
+
+ ) +} diff --git a/src/app/settings/components/SettingsForm.tsx b/src/app/settings/components/SettingsForm.tsx index 842a6fa..5c1fe4f 100644 --- a/src/app/settings/components/SettingsForm.tsx +++ b/src/app/settings/components/SettingsForm.tsx @@ -102,7 +102,7 @@ export default function SettingsForm() { setFormState((prev) => ({ ...prev, @@ -114,7 +114,7 @@ export default function SettingsForm() { />
- {formState.autoPruneFrequencyMinutes && ( + {formState.autoPruneFrequencyMinutes !== undefined && (
- {formState.autoBackupFrequencyMinutes && ( + {formState.autoBackupFrequencyMinutes !== undefined && (