also looking at this
Queued posts carry title, link card, and image through to publish
#13
8 files
+158
-11
| @@ -1,4 +1,7 @@ | ||
| 1 | import type { CrosspostTargetResult, SocialProvider } from '../../Support/Social/types' | |
| 1 | import type { CrosspostTargetResult, PublishContent, SocialProvider } from '../../Support/Social/types' | |
| 2 | import { mkdir, unlink } from 'node:fs/promises' | |
| 3 | import { join } from 'node:path' | |
| 4 | import process from 'node:process' | |
| 2 | 5 | import { db } from '@stacksjs/database' |
| 3 | 6 | import { env } from '@stacksjs/env' |
| 4 | 7 | import { crosspost, crosspostProviders } from './CrosspostService' |
| @@ -6,6 +9,28 @@ import { ensureAccount, now, uuid } from './support' | ||
| 6 | 9 | |
| 7 | 10 | const database = db as any |
| 8 | 11 | |
| 12 | /** Uploaded images for queued posts live here until publish. */ | |
| 13 | const MEDIA_DIR = join(process.cwd(), 'storage/app/postline-media') | |
| 14 | ||
| 15 | /** Serialized into posts.content — everything beyond the text itself. */ | |
| 16 | interface 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 | ||
| 23 | function 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 | ||
| 9 | 34 | /** Post statuses a user may still act on (publish now, delete). */ |
| 10 | 35 | const ACTIONABLE = new Set(['draft', 'scheduled', 'failed']) |
| 11 | 36 | |
| @@ -25,6 +50,8 @@ export interface QueueItemView { | ||
| 25 | 50 | publishedAt: string | null |
| 26 | 51 | createdAt: string |
| 27 | 52 | providers: QueueTargetView[] |
| 53 | hasImage: boolean | |
| 54 | hasLink: boolean | |
| 28 | 55 | } |
| 29 | 56 | |
| 30 | 57 | export interface SaveQueueInput { |
| @@ -34,6 +61,9 @@ export interface SaveQueueInput { | ||
| 34 | 61 | title?: string | null |
| 35 | 62 | /** UTC `YYYY-MM-DD HH:MM:SS`; omitted → saved as a draft. */ |
| 36 | 63 | 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 | |
| 37 | 67 | } |
| 38 | 68 | |
| 39 | 69 | export class QueueService { |
| @@ -67,11 +97,26 @@ export class QueueService { | ||
| 67 | 97 | const postUuid = uuid() |
| 68 | 98 | const createdAt = now() |
| 69 | 99 | |
| 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 | ||
| 70 | 114 | await database.insertInto('posts').values({ |
| 71 | 115 | uuid: postUuid, |
| 72 | 116 | title: input.title?.trim() || body.slice(0, 80), |
| 73 | 117 | body, |
| 74 | 118 | status, |
| 119 | content: Object.keys(stored).length ? JSON.stringify(stored) : null, | |
| 75 | 120 | scheduled_at: scheduledAt, |
| 76 | 121 | timezone: env.TZ || 'America/Los_Angeles', |
| 77 | 122 | source: 'composer', |
| @@ -128,6 +173,8 @@ export class QueueService { | ||
| 128 | 173 | scheduledAt: post.scheduled_at || null, |
| 129 | 174 | publishedAt: post.published_at || null, |
| 130 | 175 | createdAt: String(post.created_at), |
| 176 | hasImage: Boolean(parseStoredContent(post.content)?.media?.length), | |
| 177 | hasLink: Boolean(parseStoredContent(post.content)?.external), | |
| 131 | 178 | providers: targets |
| 132 | 179 | .filter((target: any) => Number(target.post_id) === Number(post.id)) |
| 133 | 180 | .map((target: any): QueueTargetView => ({ |
| @@ -142,6 +189,7 @@ export class QueueService { | ||
| 142 | 189 | /** Delete a draft, scheduled, or failed post along with its targets. */ |
| 143 | 190 | async remove(id: number): Promise<void> { |
| 144 | 191 | const post = await this.findActionable(id) |
| 192 | await this.removeStoredMedia(post) | |
| 145 | 193 | await database.deleteFrom('post_targets').where('post_id', '=', post.id).execute() |
| 146 | 194 | await database.deleteFrom('posts').where('id', '=', post.id).execute() |
| 147 | 195 | } |
| @@ -176,6 +224,7 @@ export class QueueService { | ||
| 176 | 224 | const results = await crosspost.publishExisting( |
| 177 | 225 | { id: Number(post.id), body: String(post.body) }, |
| 178 | 226 | providers, |
| 227 | await this.hydrateContent(post), | |
| 179 | 228 | ) |
| 180 | 229 | |
| 181 | 230 | const anyOk = results.some(result => result.ok) |
| @@ -186,9 +235,54 @@ export class QueueService { | ||
| 186 | 235 | updated_at: finishedAt, |
| 187 | 236 | }).where('id', '=', post.id).execute() |
| 188 | 237 | |
| 238 | if (anyOk) await this.removeStoredMedia(post) | |
| 239 | ||
| 189 | 240 | return { postId: Number(post.id), results } |
| 190 | 241 | } |
| 191 | 242 | |
| 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 | ||
| 192 | 286 | /** |
| 193 | 287 | * Publish every scheduled post whose time has come. Called by the |
| 194 | 288 | * PublishScheduledPosts job every minute; failures on one post never block |