Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions apps/admin/src/features/inbox/inbox-feed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { readFileSync } from "node:fs"

import { describe, expect, it } from "vitest"
import { INBOX_FEED_FILTER_LABELS, InboxFeedFilterSchema } from "@civfix/shared"

import {
INBOX_EMPTY_COPY,
INBOX_FEED_FILTER_ORDER,
emailFocusKey,
feedKey,
isInboxFeedFilter,
parseFeedKey,
replyOriginLabel,
resolveFeedSelection,
} from "./inbox-feed"

describe("inbox feed keys", () => {
it("keys an item by source and id and parses it back", () => {
expect(feedKey({ source: "reply", id: "m1" })).toBe("reply:m1")
expect(parseFeedKey("reply:m1")).toEqual({ source: "reply", id: "m1" })
expect(parseFeedKey(emailFocusKey("e1"))).toEqual({ source: "email", id: "e1" })
})

it("rejects keys that name no feed source or no id", () => {
for (const key of ["e1", "thread:t1", "email:", ":e1"]) expect(parseFeedKey(key)).toBeNull()
})
})

describe("selected feed item", () => {
const picked = { item: { source: "reply" as const, id: "m1", unread: true }, view: "unread:" }
const listed = { ...picked.item, unread: false }

it("reads the listed row, and keeps the picked row once marking it read drops it", () => {
expect(resolveFeedSelection(new Map([["reply:m1", listed]]), picked, "reply:m1", "unread:")).toBe(listed)
expect(resolveFeedSelection(new Map(), picked, "reply:m1", "unread:")).toBe(picked.item)
})

it("resolves nothing for an unlisted key unless it was picked under the same filter and search", () => {
expect(resolveFeedSelection(new Map(), picked, "reply:m1", "replies:")).toBeUndefined()
expect(resolveFeedSelection(new Map(), picked, "reply:m1", "unread:city")).toBeUndefined()
expect(resolveFeedSelection(new Map(), picked, "reply:m2", "unread:")).toBeUndefined()
expect(resolveFeedSelection(new Map(), null, "email:e1", "unread:")).toBeUndefined()
})
})

describe("inbox feed filters", () => {
it("offers every contract filter, in contract order, with a label and empty copy", () => {
expect(INBOX_FEED_FILTER_ORDER).toEqual(InboxFeedFilterSchema.options)
for (const filter of INBOX_FEED_FILTER_ORDER) {
expect(INBOX_FEED_FILTER_LABELS[filter]).toBeTruthy()
expect(INBOX_EMPTY_COPY[filter].title).toBeTruthy()
expect(INBOX_EMPTY_COPY[filter].sub).toBeTruthy()
}
})

it("does not treat an Outreach chip as an inbox filter", () => {
expect(isInboxFeedFilter("review")).toBe(true)
for (const chip of ["in", "out", "attn"]) expect(isInboxFeedFilter(chip)).toBe(false)
})
})

describe("reply rows", () => {
it("names what the reply answers", () => {
expect(replyOriginLabel({ reportId: "r1", cleanupId: null })).toBe("Report reply")
expect(replyOriginLabel({ reportId: null, cleanupId: "c1" })).toBe("Event reply")
expect(replyOriginLabel({ reportId: null, cleanupId: null })).toBe("Thread reply")
})
})

describe("inbound mail rendering", () => {
it("renders sender-controlled mail as text, never as markup", () => {
const sources = [
"./inbox-views.tsx",
"../mail/mail-page.tsx",
"../mail/mail-badges.tsx",
].map((path) => readFileSync(new URL(path, import.meta.url), "utf8"))
for (const source of sources) {
expect(source).not.toMatch(/dangerouslySetInnerHTML|innerHTML|bodyHtml/)
}
})
})
55 changes: 55 additions & 0 deletions apps/admin/src/features/inbox/inbox-feed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {
InboxFeedFilterSchema,
type InboxFeedFilter,
type InboxFeedItemDTO,
type InboxFeedReplyItemDTO,
} from "@civfix/shared"

type FeedSource = InboxFeedItemDTO["source"]

export const INBOX_FEED_FILTER_ORDER: readonly InboxFeedFilter[] = InboxFeedFilterSchema.options

export const INBOX_EMPTY_COPY: Record<InboxFeedFilter, { title: string; sub: string }> = {
all: { title: "Inbox is empty", sub: "City replies and other mail sent to civfix show up here." },
unread: { title: "All caught up", sub: "No unread mail." },
replies: { title: "No replies yet", sub: "Replies to report and event forwards show up here." },
review: { title: "Nothing to review", sub: "Replies held back from a report chat or event show up here." },
unmatched: { title: "No other mail", sub: "Mail that isn't a reply to a forward shows up here." },
archived: { title: "Nothing archived", sub: "Archived mail shows up here." },
}

export function feedKey(item: { source: FeedSource; id: string }): string {
return `${item.source}:${item.id}`
}

export function emailFocusKey(id: string): string {
return feedKey({ source: "email", id })
}

export function parseFeedKey(key: string): { source: FeedSource; id: string } | null {
const at = key.indexOf(":")
const source = key.slice(0, at)
const id = key.slice(at + 1)
if (at < 1 || !id || (source !== "email" && source !== "reply")) return null
return { source, id }
}

export function resolveFeedSelection<T extends { source: FeedSource; id: string }>(
feedByKey: ReadonlyMap<string, T>,
picked: { item: T; view: string } | null,
selKey: string | null,
view: string,
): T | undefined {
const listed = selKey ? feedByKey.get(selKey) : undefined
return listed ?? (picked?.view === view && feedKey(picked.item) === selKey ? picked.item : undefined)
}

export function isInboxFeedFilter(value: string): value is InboxFeedFilter {
return InboxFeedFilterSchema.safeParse(value).success
}

export function replyOriginLabel(item: Pick<InboxFeedReplyItemDTO, "reportId" | "cleanupId">): string {
if (item.reportId) return "Report reply"
if (item.cleanupId) return "Event reply"
return "Thread reply"
}
34 changes: 26 additions & 8 deletions apps/admin/src/features/inbox/inbox-views.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
import * as React from "react"
import {
INBOUND_EMAIL_STATUS_LABELS,
MAIL_STATUS_LABELS,
relativeAgo,
type InboundEmailListItemDTO,
type InboundEmailStatus,
type InboxFeedItemDTO,
} from "@civfix/shared"

import { Icons } from "@/components/icons"
import { EmptyState } from "@/components/shared/page-primitives"
import { LoadingState, ErrorState } from "@/components/shared/data-states"
import { useInboxMessage, useSetInboxStatus } from "@/features/inbox/use-inbox"
import { AuthVerdictBadge } from "@/features/mail/mail-badges"
import { replyOriginLabel } from "@/features/inbox/inbox-feed"
import { AuthVerdictBadge, PublicationBadge } from "@/features/mail/mail-badges"
import { MAIL_STATUS_CLS } from "@/features/mail/mail-presentation"
import { useToast } from "@/store/ui-store"


Expand All @@ -27,10 +30,11 @@ export function InboxRow({
selected,
onClick,
}: {
item: InboundEmailListItemDTO
item: InboxFeedItemDTO
selected: boolean
onClick: () => void
}) {
const isEmail = item.source === "email"
return (
<div
className={`mail-row ${selected ? "selected" : ""} ${item.unread ? "unread" : ""}`}
Expand All @@ -41,7 +45,9 @@ export function InboxRow({
</span>
<div className="mail-row-body">
<div className="mail-row-top">
<span className="mail-from">{item.from || "(unknown sender)"}</span>
<span className="mail-from">
{(isEmail ? item.from : item.org || item.from) || "(unknown sender)"}
</span>
<span className="mail-ts mono" title={new Date(item.ts).toLocaleString()}>
{relativeAgo(item.ts)}
</span>
Expand All @@ -56,14 +62,26 @@ export function InboxRow({
</div>
<div className="mail-preview">
<span className="mono" style={{ opacity: 0.6 }}>
{item.localPart || item.recipient}
{isEmail ? item.localPart || item.recipient : replyOriginLabel(item)}
</span>{" "}
{item.preview}
</div>
</div>
<span className={`pill ${STATUS_CLS[item.status]} tight mail-status-pill`}>
{INBOUND_EMAIL_STATUS_LABELS[item.status]}
</span>
{isEmail ? (
<span className={`pill ${STATUS_CLS[item.status]} tight mail-status-pill`}>
{INBOUND_EMAIL_STATUS_LABELS[item.status]}
</span>
) : item.publication ? (
<PublicationBadge
publication={item.publication}
isReport={item.reportId !== null}
className="mail-status-pill"
/>
) : (
<span className={`pill ${MAIL_STATUS_CLS[item.threadStatus]} tight mail-status-pill`}>
{MAIL_STATUS_LABELS[item.threadStatus]}
</span>
)}
</div>
)
}
Expand Down
65 changes: 65 additions & 0 deletions apps/admin/src/features/inbox/use-inbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { QueryClient } from "@tanstack/react-query"
import { beforeEach, describe, expect, it, vi } from "vitest"
import type { InboxFeedResponse } from "@civfix/shared"

import type * as apiModule from "@/lib/api"
import { api } from "@/lib/api"
import { invalidateMail } from "@/features/mail/use-mail"

import { inboxFeedQueryOptions } from "./use-inbox"

vi.mock("@/lib/api", async (importOriginal) => ({
...(await importOriginal<typeof apiModule>()),
api: { listInboxFeed: vi.fn() },
}))

const listInboxFeed = vi.mocked(api.listInboxFeed)

const reply = {
source: "reply",
id: "m1",
threadId: "t1",
reportId: "r1",
cleanupId: null,
org: "City of Springfield",
from: "clerk@springfield.gov",
subject: "Re: pothole",
preview: "Crew is scheduled",
ts: "2026-09-22T10:00:00.000Z",
unread: true,
threadStatus: "needs_action",
hasAttachments: false,
authVerdict: "fail",
publication: "withheld",
} as const satisfies InboxFeedResponse["items"][number]

describe("inbox feed query", () => {
beforeEach(() => listInboxFeed.mockReset())

it("pages the feed with its filter and search until the server cursor runs out", async () => {
listInboxFeed
.mockResolvedValueOnce({ items: [reply], nextCursor: "c1" })
.mockResolvedValueOnce({ items: [{ ...reply, id: "m2" }], nextCursor: null })
const qc = new QueryClient()
const params = { filter: "review", q: "pothole" } as const

const data = await qc.fetchInfiniteQuery({ ...inboxFeedQueryOptions(params), pages: 3 })

expect(listInboxFeed.mock.calls.map(([input]) => input)).toEqual([
{ filter: "review", q: "pothole" },
{ filter: "review", q: "pothole", cursor: "c1" },
])
expect(data.pages.flatMap((p) => p.items.map((item) => item.id))).toEqual(["m1", "m2"])
})

it("refreshes when a thread changes, since reply rows carry thread state", async () => {
listInboxFeed.mockResolvedValue({ items: [reply], nextCursor: null })
const qc = new QueryClient()
const options = inboxFeedQueryOptions({ filter: "all" })
await qc.fetchInfiniteQuery(options)

invalidateMail(qc, "t1")

expect(qc.getQueryState(options.queryKey)?.isInvalidated).toBe(true)
})
})
26 changes: 25 additions & 1 deletion apps/admin/src/features/inbox/use-inbox.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
"use client"

import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
infiniteQueryOptions,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query"
import type {
GetInboxMessageResponse,
InboxFeedQuery,
InboxListQuery,
InboxListResponse,
SetInboxStatusRequest,
Expand Down Expand Up @@ -32,6 +39,23 @@ export function useInboxListInfinite(params: InboxListQuery) {
})
}

export function inboxFeedQueryOptions(params: InboxFeedQuery) {
return infiniteQueryOptions({
queryKey: queryKeys.inbox.feed(params),
queryFn: ({ pageParam }) =>
api.listInboxFeed({
...params,
...(typeof pageParam === "string" ? { cursor: pageParam } : {}),
}),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
}

export function useInboxFeedInfinite(params: InboxFeedQuery) {
return useInfiniteQuery(inboxFeedQueryOptions(params))
}

export function useInboxMessage(id: string | null) {
return useQuery<GetInboxMessageResponse>({
queryKey: queryKeys.inbox.detail(id ?? ""),
Expand Down
Loading
Loading