* feat(inbox): document-type badge and filter, +lev/+ver plus-addressing (#2129) Phase 1: every inbox row shows its document kind (Kvitto, Leverantorsfaktura, Myndighetsbrev, Ovrigt) from the existing AI documentKind, and a second menu next to the status filter narrows the list to leverantorsfakturor or underlag. Pure predicate in lib/documents/inbox-kind.ts with tests. Phase 2: the shared inbox address accepts RFC 5233 plus-addressing. The webhook splits the local part at the first + and looks up the base, so <local>+anything@ now reaches the company instead of 404ing. +lev and +ver land in the new nullable invoice_inbox_items.kind_hint column (CHECK supplier_invoice | receipt), threaded through EmailMeta into both inbox inserts and returned by GET /items. kind_hint wins over documentKind for the badge and the filter and survives re-extraction because it is a column. The sources panel shows both tagged addresses with a one-line hint (sv + en). Tests: filter predicate per kind and null; parser and tag mapping; webhook routes +LEV and an unknown tag; pg test pins the CHECK and NULL default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr * fix(inbox): honest empty state under a type filter, detail pane shares the row's kind resolution Skeptic findings on #2148: with a type filter narrowing 'Att göra' to zero the empty state claimed 'allt är bearbetat' while the status trigger still counted pending rows; it now says no items of that type are here (sv + en). The fields rail printed the AI documentKind only, so a +lev hint could disagree with the row badge; it now uses resolveInboxKind like the list. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr * fix(inbox): keep the type-filter empty state off purchase lists, carry kind_hint onto rejected attachment rows CodeRabbit on #2148: the purchase lists (Saknar underlag, Hämta från portal) ignore the type menu, so a leftover kind filter must not pick their empty-state copy. A rejected attachment (unsupported MIME, too large) now keeps the sender's +lev / +ver hint on its error row like every other inbox insert; the allowlist test covers it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr * fix(inbox): set the +lev/+ver kind hint only when the shared address resolved the company CodeRabbit on #2148: the hint was computed before recipient resolution, so a tag on an unknown or retired shared address could ride along onto a custom-domain match. It is now assigned inside the active shared-inbox branch only; regression test covers the multi-recipient case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hzv2Z2eCq8iJAAe8XC1hNr --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
4c76fb10d7
commit
867767a22f
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
INBOX_KIND_FILTERS,
|
||||
matchesInboxKindFilter,
|
||||
resolveInboxKind,
|
||||
type InboxDocumentKind,
|
||||
} from '@/lib/documents/inbox-kind'
|
||||
|
||||
describe('resolveInboxKind', () => {
|
||||
it.each(['receipt', 'supplier_invoice', 'government_letter', 'other'] as const)(
|
||||
'returns the AI documentKind %s when there is no sender hint',
|
||||
(kind) => {
|
||||
expect(resolveInboxKind({ extracted_data: { documentKind: kind } })).toBe(kind)
|
||||
expect(resolveInboxKind({ kind_hint: null, extracted_data: { documentKind: kind } })).toBe(kind)
|
||||
},
|
||||
)
|
||||
|
||||
it('returns null when nothing is classified', () => {
|
||||
expect(resolveInboxKind({})).toBeNull()
|
||||
expect(resolveInboxKind({ extracted_data: null })).toBeNull()
|
||||
expect(resolveInboxKind({ extracted_data: {} })).toBeNull()
|
||||
expect(resolveInboxKind({ extracted_data: { documentKind: null } })).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a documentKind outside the vocabulary', () => {
|
||||
expect(resolveInboxKind({ extracted_data: { documentKind: 'parking_ticket' } })).toBeNull()
|
||||
expect(resolveInboxKind({ kind_hint: 'invoice' })).toBeNull()
|
||||
})
|
||||
|
||||
it('lets the sender hint win over the AI classification', () => {
|
||||
expect(
|
||||
resolveInboxKind({ kind_hint: 'supplier_invoice', extracted_data: { documentKind: 'receipt' } }),
|
||||
).toBe('supplier_invoice')
|
||||
expect(
|
||||
resolveInboxKind({ kind_hint: 'receipt', extracted_data: { documentKind: 'supplier_invoice' } }),
|
||||
).toBe('receipt')
|
||||
})
|
||||
|
||||
it('uses the sender hint even before extraction has landed', () => {
|
||||
expect(resolveInboxKind({ kind_hint: 'receipt', extracted_data: null })).toBe('receipt')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchesInboxKindFilter', () => {
|
||||
const kinds: Array<InboxDocumentKind | null> = [
|
||||
'receipt',
|
||||
'supplier_invoice',
|
||||
'government_letter',
|
||||
'other',
|
||||
null,
|
||||
]
|
||||
|
||||
it("'all' passes every kind including unclassified", () => {
|
||||
for (const kind of kinds) expect(matchesInboxKindFilter(kind, 'all')).toBe(true)
|
||||
})
|
||||
|
||||
it("'supplier_invoice' passes only supplier invoices", () => {
|
||||
expect(matchesInboxKindFilter('supplier_invoice', 'supplier_invoice')).toBe(true)
|
||||
expect(matchesInboxKindFilter('receipt', 'supplier_invoice')).toBe(false)
|
||||
expect(matchesInboxKindFilter('government_letter', 'supplier_invoice')).toBe(false)
|
||||
expect(matchesInboxKindFilter('other', 'supplier_invoice')).toBe(false)
|
||||
expect(matchesInboxKindFilter(null, 'supplier_invoice')).toBe(false)
|
||||
})
|
||||
|
||||
it("'underlag' passes receipt, government_letter and other", () => {
|
||||
expect(matchesInboxKindFilter('receipt', 'underlag')).toBe(true)
|
||||
expect(matchesInboxKindFilter('government_letter', 'underlag')).toBe(true)
|
||||
expect(matchesInboxKindFilter('other', 'underlag')).toBe(true)
|
||||
expect(matchesInboxKindFilter('supplier_invoice', 'underlag')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps unclassified items out of both narrow filters', () => {
|
||||
expect(matchesInboxKindFilter(null, 'underlag')).toBe(false)
|
||||
expect(matchesInboxKindFilter(null, 'supplier_invoice')).toBe(false)
|
||||
})
|
||||
|
||||
it('exposes the three filters in menu order', () => {
|
||||
expect(INBOX_KIND_FILTERS).toEqual(['all', 'supplier_invoice', 'underlag'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Which kind of document an inbox item is, for the list-row badge and the
|
||||
* type filter (issue #2129: "one Inkorg view that shows whether each row is
|
||||
* a leverantörsfaktura or bokföringsunderlag").
|
||||
*
|
||||
* Two sources, in priority order:
|
||||
* 1. kind_hint: what the sender declared through the +lev / +ver
|
||||
* plus-address tag. A column, so it survives re-extraction.
|
||||
* 2. extracted_data.documentKind: the AI classification.
|
||||
*
|
||||
* Anything outside the known vocabulary resolves to null and shows nothing,
|
||||
* rather than guessing.
|
||||
*
|
||||
* React-free on purpose: this repo has no jsdom or testing-library, so the
|
||||
* predicate is tested here rather than through the component.
|
||||
*/
|
||||
|
||||
export const INBOX_DOCUMENT_KINDS = [
|
||||
'receipt',
|
||||
'supplier_invoice',
|
||||
'government_letter',
|
||||
'other',
|
||||
] as const
|
||||
|
||||
export type InboxDocumentKind = (typeof INBOX_DOCUMENT_KINDS)[number]
|
||||
|
||||
/**
|
||||
* 'underlag' is everything that is not a supplier invoice: receipts,
|
||||
* government letters and other documents all book as bokföringsunderlag.
|
||||
*/
|
||||
export type InboxKindFilter = 'all' | 'supplier_invoice' | 'underlag'
|
||||
|
||||
export const INBOX_KIND_FILTERS: readonly InboxKindFilter[] = ['all', 'supplier_invoice', 'underlag']
|
||||
|
||||
export interface InboxKindSource {
|
||||
kind_hint?: string | null
|
||||
extracted_data?: { documentKind?: string | null } | null
|
||||
}
|
||||
|
||||
function isInboxDocumentKind(value: unknown): value is InboxDocumentKind {
|
||||
return typeof value === 'string' && (INBOX_DOCUMENT_KINDS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** The kind to show for an item: the sender's hint first, then the AI's. */
|
||||
export function resolveInboxKind(item: InboxKindSource): InboxDocumentKind | null {
|
||||
if (isInboxDocumentKind(item.kind_hint)) return item.kind_hint
|
||||
const aiKind = item.extracted_data?.documentKind
|
||||
return isInboxDocumentKind(aiKind) ? aiKind : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a resolved kind passes the type filter. An unclassified item
|
||||
* (null) only passes 'all': the narrow filters promise a known kind.
|
||||
*/
|
||||
export function matchesInboxKindFilter(
|
||||
kind: InboxDocumentKind | null,
|
||||
filter: InboxKindFilter,
|
||||
): boolean {
|
||||
if (filter === 'all') return true
|
||||
if (kind === null) return false
|
||||
if (filter === 'supplier_invoice') return kind === 'supplier_invoice'
|
||||
return kind !== 'supplier_invoice'
|
||||
}
|
||||
Reference in New Issue
Block a user