feat: add SENT status, QUEUED_EMAIL_ONLY queue status, and email_only field for email-only posts

Phase 4b.1: Add data model support for email-only posts.
- PostStatus: add SENT enum value
- QueueStatus: add QUEUED_EMAIL_ONLY enum value
- PostFilter: add SENT filter with "status:sent" ghost filter
- GhostPost: add email_only Boolean field
- FeedPost: add emailOnly Boolean field
- LocalPostDao: include QUEUED_EMAIL_ONLY in queued posts query
- OverallStats: handle SENT status in stats calculation
- FeedScreen: show "Pending email send" for QUEUED_EMAIL_ONLY queue status
- Update tests for new enum values and fields
This commit is contained in:
Paweł Orzech 2026-03-20 00:54:34 +01:00
parent 3b1061694d
commit f9d060ed7d
6 changed files with 139 additions and 13 deletions

View file

@ -26,13 +26,13 @@ interface LocalPostDao {
@Query("SELECT * FROM local_posts WHERE queueStatus IN (:statuses) ORDER BY createdAt ASC")
suspend fun getQueuedPosts(
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED, QueueStatus.QUEUED_EMAIL_ONLY)
): List<LocalPost>
@Query("SELECT * FROM local_posts WHERE accountId = :accountId AND queueStatus IN (:statuses) ORDER BY createdAt ASC")
suspend fun getQueuedPostsByAccount(
accountId: String,
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED)
statuses: List<QueueStatus> = listOf(QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED, QueueStatus.QUEUED_EMAIL_ONLY)
): List<LocalPost>
@Query("SELECT * FROM local_posts WHERE localId = :localId")

View file

@ -48,7 +48,8 @@ data class GhostPost(
val visibility: String? = "public",
val authors: List<Author>? = null,
val reading_time: Int? = null,
val tags: List<GhostTag>? = null
val tags: List<GhostTag>? = null,
val email_only: Boolean? = null
)
data class GhostTag(
@ -107,13 +108,15 @@ data class LocalPost(
enum class PostStatus {
DRAFT,
PUBLISHED,
SCHEDULED
SCHEDULED,
SENT
}
enum class QueueStatus {
NONE,
QUEUED_PUBLISH,
QUEUED_SCHEDULED,
QUEUED_EMAIL_ONLY,
UPLOADING,
FAILED
}
@ -145,7 +148,8 @@ data class FeedPost(
val isLocal: Boolean = false,
val queueStatus: QueueStatus = QueueStatus.NONE,
val fileUrl: String? = null,
val fileName: String? = null
val fileName: String? = null,
val emailOnly: Boolean = false
)
@Stable
@ -162,7 +166,8 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
ALL("All", null),
PUBLISHED("Published", "status:published"),
DRAFT("Drafts", "status:draft"),
SCHEDULED("Scheduled", "status:scheduled");
SCHEDULED("Scheduled", "status:scheduled"),
SENT("Sent", "status:sent");
/** Returns the matching [PostStatus] for local filtering, or null for ALL. */
fun toPostStatus(): PostStatus? = when (this) {
@ -170,6 +175,7 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
PUBLISHED -> PostStatus.PUBLISHED
DRAFT -> PostStatus.DRAFT
SCHEDULED -> PostStatus.SCHEDULED
SENT -> PostStatus.SENT
}
/** Empty-state message shown when filter yields no results. */
@ -178,6 +184,7 @@ enum class PostFilter(val displayName: String, val ghostFilter: String?) {
PUBLISHED -> "No published posts yet"
DRAFT -> "No drafts yet"
SCHEDULED -> "No scheduled posts yet"
SENT -> "No sent newsletters yet"
}
}

View file

@ -38,6 +38,7 @@ data class OverallStats(
PostStatus.PUBLISHED -> publishedCount++
PostStatus.DRAFT -> draftCount++
PostStatus.SCHEDULED -> scheduledCount++
PostStatus.SENT -> publishedCount++ // sent counts as published
}
}
@ -47,6 +48,7 @@ data class OverallStats(
"published" -> publishedCount++
"draft" -> draftCount++
"scheduled" -> scheduledCount++
"sent" -> publishedCount++
}
}

View file

@ -1621,6 +1621,7 @@ fun PostCardContent(
Spacer(modifier = Modifier.height(8.dp))
val queueLabel = when (post.queueStatus) {
QueueStatus.QUEUED_PUBLISH, QueueStatus.QUEUED_SCHEDULED -> "Pending upload"
QueueStatus.QUEUED_EMAIL_ONLY -> "Pending email send"
QueueStatus.UPLOADING -> "Uploading..."
QueueStatus.FAILED -> "Upload failed"
else -> ""
@ -1631,7 +1632,7 @@ fun PostCardContent(
label = queueLabel,
isUploading = isUploading
)
if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED) {
if (post.queueStatus == QueueStatus.QUEUED_PUBLISH || post.queueStatus == QueueStatus.QUEUED_SCHEDULED || post.queueStatus == QueueStatus.QUEUED_EMAIL_ONLY) {
Spacer(modifier = Modifier.width(8.dp))
TextButton(onClick = onCancelQueue) {
Text("Cancel", style = MaterialTheme.typography.labelSmall)

View file

@ -265,13 +265,13 @@ class GhostModelsTest {
// --- Enum values ---
@Test
fun `PostStatus has exactly 3 values`() {
assertEquals(3, PostStatus.values().size)
fun `PostStatus has exactly 4 values`() {
assertEquals(4, PostStatus.values().size)
}
@Test
fun `QueueStatus has exactly 5 values`() {
assertEquals(5, QueueStatus.values().size)
fun `QueueStatus has exactly 6 values`() {
assertEquals(6, QueueStatus.values().size)
}
@Test
@ -279,6 +279,7 @@ class GhostModelsTest {
assertEquals(PostStatus.DRAFT, PostStatus.valueOf("DRAFT"))
assertEquals(PostStatus.PUBLISHED, PostStatus.valueOf("PUBLISHED"))
assertEquals(PostStatus.SCHEDULED, PostStatus.valueOf("SCHEDULED"))
assertEquals(PostStatus.SENT, PostStatus.valueOf("SENT"))
}
@Test
@ -286,6 +287,7 @@ class GhostModelsTest {
assertEquals(QueueStatus.NONE, QueueStatus.valueOf("NONE"))
assertEquals(QueueStatus.QUEUED_PUBLISH, QueueStatus.valueOf("QUEUED_PUBLISH"))
assertEquals(QueueStatus.QUEUED_SCHEDULED, QueueStatus.valueOf("QUEUED_SCHEDULED"))
assertEquals(QueueStatus.QUEUED_EMAIL_ONLY, QueueStatus.valueOf("QUEUED_EMAIL_ONLY"))
assertEquals(QueueStatus.UPLOADING, QueueStatus.valueOf("UPLOADING"))
assertEquals(QueueStatus.FAILED, QueueStatus.valueOf("FAILED"))
}
@ -422,4 +424,95 @@ class GhostModelsTest {
val json = gson.toJson(wrapper)
assertTrue(json.contains("\"feature_image_alt\":\"Photo description\""))
}
// --- email_only field ---
@Test
fun `GhostPost default email_only is null`() {
val post = GhostPost()
assertNull(post.email_only)
}
@Test
fun `GhostPost stores email_only true`() {
val post = GhostPost(email_only = true)
assertEquals(true, post.email_only)
}
@Test
fun `GhostPost serializes email_only to JSON`() {
val post = GhostPost(title = "Email post", email_only = true)
val json = gson.toJson(post)
assertTrue(json.contains("\"email_only\":true"))
}
@Test
fun `GhostPost deserializes email_only from JSON`() {
val json = """{"id":"1","email_only":true}"""
val post = gson.fromJson(json, GhostPost::class.java)
assertEquals(true, post.email_only)
}
@Test
fun `GhostPost deserializes with missing email_only`() {
val json = """{"id":"1","title":"Test"}"""
val post = gson.fromJson(json, GhostPost::class.java)
assertNull(post.email_only)
}
// --- FeedPost emailOnly ---
@Test
fun `FeedPost default emailOnly is false`() {
val post = FeedPost(
title = "Test",
textContent = "Content",
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = "published",
publishedAt = null,
createdAt = null,
updatedAt = null
)
assertFalse(post.emailOnly)
}
@Test
fun `FeedPost stores emailOnly true`() {
val post = FeedPost(
title = "Test",
textContent = "Content",
htmlContent = null,
imageUrl = null,
linkUrl = null,
linkTitle = null,
linkDescription = null,
linkImageUrl = null,
status = "sent",
publishedAt = null,
createdAt = null,
updatedAt = null,
emailOnly = true
)
assertTrue(post.emailOnly)
}
// --- LocalPost emailOnly ---
@Test
fun `LocalPost default emailOnly is false`() {
val post = LocalPost()
assertFalse(post.emailOnly)
}
@Test
fun `LocalPost stores emailOnly true`() {
val post = LocalPost(emailOnly = true, newsletterSlug = "default-newsletter")
assertTrue(post.emailOnly)
assertEquals("default-newsletter", post.newsletterSlug)
}
}

View file

@ -8,8 +8,8 @@ class PostFilterTest {
// --- Enum values ---
@Test
fun `PostFilter has exactly 4 values`() {
assertEquals(4, PostFilter.values().size)
fun `PostFilter has exactly 5 values`() {
assertEquals(5, PostFilter.values().size)
}
@Test
@ -18,6 +18,7 @@ class PostFilterTest {
assertEquals(PostFilter.PUBLISHED, PostFilter.valueOf("PUBLISHED"))
assertEquals(PostFilter.DRAFT, PostFilter.valueOf("DRAFT"))
assertEquals(PostFilter.SCHEDULED, PostFilter.valueOf("SCHEDULED"))
assertEquals(PostFilter.SENT, PostFilter.valueOf("SENT"))
}
// --- Display names ---
@ -107,4 +108,26 @@ class PostFilterTest {
fun `SCHEDULED emptyMessage returns No scheduled posts yet`() {
assertEquals("No scheduled posts yet", PostFilter.SCHEDULED.emptyMessage())
}
// --- SENT filter ---
@Test
fun `SENT displayName is Sent`() {
assertEquals("Sent", PostFilter.SENT.displayName)
}
@Test
fun `SENT ghostFilter is status_sent`() {
assertEquals("status:sent", PostFilter.SENT.ghostFilter)
}
@Test
fun `SENT toPostStatus returns PostStatus SENT`() {
assertEquals(PostStatus.SENT, PostFilter.SENT.toPostStatus())
}
@Test
fun `SENT emptyMessage returns No sent newsletters yet`() {
assertEquals("No sent newsletters yet", PostFilter.SENT.emptyMessage())
}
}