* 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>
81 lines
3.1 KiB
TypeScript
81 lines
3.1 KiB
TypeScript
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'])
|
|
})
|
|
})
|