ReviewOS

also looking at this

stacks/postline

Queued posts carry title, link card, and image through to publish

#13
Merged glennmichael123 wants to merge feat/queue-content into main
8 files +158 -11
app/Services/Social/QueueService.tsmodified+95-1
Changes to app/Services/Social/QueueService.ts
@@ -1,4 +1,7 @@
1import type { CrosspostTargetResult, SocialProvider } from '../../Support/Social/types'
1import type { CrosspostTargetResult, PublishContent, SocialProvider } from '../../Support/Social/types'
2import { mkdir, unlink } from 'node:fs/promises'
3import { join } from 'node:path'
4import process from 'node:process'
25import { db } from '@stacksjs/database'
36import { env } from '@stacksjs/env'
47import { crosspost, crosspostProviders } from './CrosspostService'
@@ -6,6 +9,28 @@ import { ensureAccount, now, uuid } from './support'
69
710const database = db as any
811
12/** Uploaded images for queued posts live here until publish. */
13const MEDIA_DIR = join(process.cwd(), 'storage/app/postline-media')
14
15/** Serialized into posts.content — everything beyond the text itself. */
16interface StoredContent {
17 /** Only set when the composer provided an explicit title. */
18 title?: string
19 external?: { uri: string, title: string, description?: string }
20 media?: Array<{ url?: string, path?: string, mimeType?: string, altText?: string }>
21}
22
23function parseStoredContent(raw: unknown): StoredContent | null {
24 if (!raw) return null
25 try {
26 const parsed = JSON.parse(String(raw))
27 return parsed && typeof parsed === 'object' ? parsed as StoredContent : null
28 }
29 catch {
30 return null
31 }
32}
33
934/** Post statuses a user may still act on (publish now, delete). */
1035const ACTIONABLE = new Set(['draft', 'scheduled', 'failed'])
1136
@@ -25,6 +50,8 @@ export interface QueueItemView {
2550 publishedAt: string | null
2651 createdAt: string
2752 providers: QueueTargetView[]
53 hasImage: boolean
54 hasLink: boolean
2855}
2956
3057export interface SaveQueueInput {
@@ -34,6 +61,9 @@ export interface SaveQueueInput {
3461 title?: string | null
3562 /** UTC `YYYY-MM-DD HH:MM:SS`; omitted → saved as a draft. */
3663 scheduledAt?: string | null
64 external?: { uri: string, title: string, description?: string } | null
65 /** Attached image: raw bytes (stored to disk until publish) or a URL. */
66 image?: { bytes?: Uint8Array, url?: string, mimeType?: string, altText?: string } | null
3767}
3868
3969export class QueueService {
@@ -67,11 +97,26 @@ export class QueueService {
6797 const postUuid = uuid()
6898 const createdAt = now()
6999
100 const stored: StoredContent = {}
101 if (input.title?.trim()) stored.title = input.title.trim()
102 if (input.external?.uri && input.external.title) stored.external = input.external
103 if (input.image?.bytes?.length) {
104 const extension = (input.image.mimeType || 'image/jpeg').split('/')[1]?.replace(/[^a-z0-9]/gi, '') || 'jpg'
105 const filename = `${postUuid}.${extension}`
106 await mkdir(MEDIA_DIR, { recursive: true })
107 await Bun.write(join(MEDIA_DIR, filename), input.image.bytes)
108 stored.media = [{ path: filename, mimeType: input.image.mimeType, altText: input.image.altText }]
109 }
110 else if (input.image?.url) {
111 stored.media = [{ url: input.image.url, altText: input.image.altText }]
112 }
113
70114 await database.insertInto('posts').values({
71115 uuid: postUuid,
72116 title: input.title?.trim() || body.slice(0, 80),
73117 body,
74118 status,
119 content: Object.keys(stored).length ? JSON.stringify(stored) : null,
75120 scheduled_at: scheduledAt,
76121 timezone: env.TZ || 'America/Los_Angeles',
77122 source: 'composer',
@@ -128,6 +173,8 @@ export class QueueService {
128173 scheduledAt: post.scheduled_at || null,
129174 publishedAt: post.published_at || null,
130175 createdAt: String(post.created_at),
176 hasImage: Boolean(parseStoredContent(post.content)?.media?.length),
177 hasLink: Boolean(parseStoredContent(post.content)?.external),
131178 providers: targets
132179 .filter((target: any) => Number(target.post_id) === Number(post.id))
133180 .map((target: any): QueueTargetView => ({
@@ -142,6 +189,7 @@ export class QueueService {
142189 /** Delete a draft, scheduled, or failed post along with its targets. */
143190 async remove(id: number): Promise<void> {
144191 const post = await this.findActionable(id)
192 await this.removeStoredMedia(post)
145193 await database.deleteFrom('post_targets').where('post_id', '=', post.id).execute()
146194 await database.deleteFrom('posts').where('id', '=', post.id).execute()
147195 }
@@ -176,6 +224,7 @@ export class QueueService {
176224 const results = await crosspost.publishExisting(
177225 { id: Number(post.id), body: String(post.body) },
178226 providers,
227 await this.hydrateContent(post),
179228 )
180229
181230 const anyOk = results.some(result => result.ok)
@@ -186,9 +235,54 @@ export class QueueService {
186235 updated_at: finishedAt,
187236 }).where('id', '=', post.id).execute()
188237
238 if (anyOk) await this.removeStoredMedia(post)
239
189240 return { postId: Number(post.id), results }
190241 }
191242
243 /** Rebuild PublishContent from a queued post's stored content JSON. */
244 private async hydrateContent(post: any): Promise<PublishContent | undefined> {
245 const stored = parseStoredContent(post.content)
246 if (!stored) return undefined
247
248 const content: PublishContent = {}
249 if (stored.title) content.title = stored.title
250 if (stored.external) content.external = stored.external
251
252 if (stored.media?.length) {
253 const media: NonNullable<PublishContent['media']> = []
254 for (const item of stored.media) {
255 if (item.path) {
256 try {
257 const file = Bun.file(join(MEDIA_DIR, item.path))
258 if (await file.exists()) {
259 media.push({
260 bytes: new Uint8Array(await file.arrayBuffer()),
261 mimeType: item.mimeType,
262 altText: item.altText,
263 })
264 }
265 }
266 catch {}
267 }
268 else if (item.url) {
269 media.push({ url: item.url, altText: item.altText })
270 }
271 }
272 if (media.length) content.media = media
273 }
274
275 return Object.keys(content).length ? content : undefined
276 }
277
278 private async removeStoredMedia(post: any): Promise<void> {
279 const stored = parseStoredContent(post.content)
280 for (const item of stored?.media || []) {
281 if (!item.path) continue
282 await unlink(join(MEDIA_DIR, item.path)).catch(() => {})
283 }
284 }
285
192286 /**
193287 * Publish every scheduled post whose time has come. Called by the
194288 * PublishScheduledPosts job every minute; failures on one post never block