ReviewOS

also looking at this

stacks/postline

Edit queued and scheduled posts (#21)

#22
Merged glennmichael123 wants to merge feat/edit-queued-posts into main
6 files +359 -7
app/Services/Social/QueueService.tsmodified+121-0
Changes to app/Services/Social/QueueService.ts
@@ -66,6 +66,23 @@ export interface SaveQueueInput {
6666 image?: { bytes?: Uint8Array, url?: string, mimeType?: string, altText?: string } | null
6767}
6868
69/** Edit input. `image`: undefined = keep, null = remove, object = replace. */
70export interface UpdateQueueInput extends Omit<SaveQueueInput, 'image'> {
71 image?: { bytes?: Uint8Array, url?: string, mimeType?: string, altText?: string } | null
72}
73
74/** Editable snapshot of a queued post for prefilling the composer. */
75export interface QueueEditView {
76 id: number
77 text: string
78 title: string | null
79 status: string
80 scheduledAt: string | null
81 providers: SocialProvider[]
82 external: { uri: string, title: string, description?: string } | null
83 image: { kind: 'file' | 'url', url: string | null } | null
84}
85
6986export class QueueService {
7087 /**
7188 * Persist a post without publishing it. With `scheduledAt` the post is
@@ -194,6 +211,110 @@ export class QueueService {
194211 await database.deleteFrom('posts').where('id', '=', post.id).execute()
195212 }
196213
214 /**
215 * Full editable state for one draft/scheduled/failed post, for prefilling
216 * the composer. Media is described (present/kind), not returned as bytes —
217 * the composer keeps the existing image unless the user replaces/removes it.
218 */
219 async get(id: number): Promise<QueueEditView> {
220 const post = await this.findActionable(id)
221 const stored = parseStoredContent(post.content)
222 const targets = await database
223 .selectFrom('post_targets')
224 .select(['provider'])
225 .where('post_id', '=', post.id)
226 .execute()
227
228 const media = stored?.media?.[0]
229 return {
230 id: Number(post.id),
231 text: String(post.body || ''),
232 title: stored?.title || null,
233 status: String(post.status),
234 scheduledAt: post.scheduled_at || null,
235 providers: [...new Set(targets.map((t: any) => t.provider))] as SocialProvider[],
236 external: stored?.external || null,
237 image: media ? { kind: media.path ? 'file' : 'url', url: media.url || null } : null,
238 }
239 }
240
241 /**
242 * Update a draft/scheduled/failed post in place: text, title, providers,
243 * schedule, link card, and image. `image` semantics — undefined keeps the
244 * existing media, null removes it, an object replaces it. Placeholder
245 * targets are rebuilt to match the new provider set.
246 */
247 async update(id: number, input: UpdateQueueInput): Promise<{ postId: number, status: 'draft' | 'scheduled' }> {
248 const post = await this.findActionable(id)
249
250 const body = input.text.trim()
251 if (!body)
252 throw new Error('Post text is required.')
253
254 const available = new Set<string>(crosspostProviders())
255 const providers = input.providers.filter(provider => available.has(provider))
256 if (providers.length === 0)
257 throw new Error('Select at least one connected provider.')
258
259 const scheduledAt = input.scheduledAt?.trim() || null
260 if (scheduledAt) {
261 if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(scheduledAt))
262 throw new Error('Scheduled time must be a UTC YYYY-MM-DD HH:MM:SS timestamp.')
263 if (scheduledAt <= now())
264 throw new Error('Scheduled time must be in the future.')
265 }
266
267 const status = scheduledAt ? 'scheduled' as const : 'draft' as const
268 const existing = parseStoredContent(post.content)
269 const stored: StoredContent = {}
270 if (input.title?.trim()) stored.title = input.title.trim()
271 if (input.external?.uri && input.external.title) stored.external = input.external
272
273 // Media: replace / remove / keep. Replacing or removing drops the old file.
274 if (input.image === null || input.image?.bytes?.length || input.image?.url) {
275 await this.removeStoredMedia(post)
276 }
277 if (input.image?.bytes?.length) {
278 const extension = (input.image.mimeType || 'image/jpeg').split('/')[1]?.replace(/[^a-z0-9]/gi, '') || 'jpg'
279 const filename = `${post.uuid}-${uuid().slice(0, 8)}.${extension}`
280 await mkdir(MEDIA_DIR, { recursive: true })
281 await Bun.write(join(MEDIA_DIR, filename), input.image.bytes)
282 stored.media = [{ path: filename, mimeType: input.image.mimeType, altText: input.image.altText }]
283 }
284 else if (input.image?.url) {
285 stored.media = [{ url: input.image.url, altText: input.image.altText }]
286 }
287 else if (input.image === undefined && existing?.media?.length) {
288 stored.media = existing.media
289 }
290
291 const updatedAt = now()
292 await database.updateTable('posts').set({
293 title: input.title?.trim() || body.slice(0, 80),
294 body,
295 status,
296 content: Object.keys(stored).length ? JSON.stringify(stored) : null,
297 scheduled_at: scheduledAt,
298 updated_at: updatedAt,
299 }).where('id', '=', post.id).execute()
300
301 // Rebuild placeholder targets to match the new provider set.
302 await database.deleteFrom('post_targets').where('post_id', '=', post.id).execute()
303 for (const provider of providers) {
304 await database.insertInto('post_targets').values({
305 uuid: uuid(),
306 provider,
307 status,
308 scheduled_at: scheduledAt,
309 post_id: post.id,
310 created_at: updatedAt,
311 updated_at: updatedAt,
312 }).execute()
313 }
314
315 return { postId: Number(post.id), status }
316 }
317
197318 /** Publish a draft/scheduled/failed post immediately. */
198319 async publishNow(id: number): Promise<{ postId: number, results: CrosspostTargetResult[] }> {
199320 const post = await this.findActionable(id)