* feat(notices): lib/notices aggregator + single notice line on Hem
Degraded-state surfaces (broken/expiring bank connections, Skatteverket
reconnect, failing cloud backups, wrong-account hint) each hand-rolled
their own detection and stacked independently on the dashboard. This adds
lib/notices, mirroring lib/worklist, as the single owner of every health
predicate, and de-clutters the surfaces:
- lib/notices/{types,predicates,categories,aggregate}: five documented
categories with a fixed priority order; every predicate soft-fails to
null; pure decision helpers live in predicates.ts so 'use client' pages
can import them without pulling server-only modules. Broken supersedes
expiring for the same bank connection by construction (status filter).
- GET /api/notices + POST /api/notices/dismiss (withRouteContext), and a
notice_dismissals table (per company+user+notice_id, RLS user-scoped).
Notice ids embed a state discriminator, so a dismissal hides exactly
the state the user saw and a NEW failure surfaces again.
- Hem renders only the highest-priority notice as ONE AttnLine where the
boxed BackupHealthBanner card sat (banner deleted; its multi-provider
sentence logic moved into the backup_failing predicate), with a quiet
"+N till" inline expander. otherAccountHint joins the same list as the
lowest-priority category instead of an unconditional extra line.
- transactions and skattekonto keep their own AttnLine copy/CTA but source
the reconnect decision from the shared skvStatusNeedsReconnect /
skvAuthErrorNeedsReconnect predicates; Hem's Bevaka row imports the
expiring-consent day-math instead of duplicating it.
- design.md convention 6 addendum: max one global notice line + max one
page-domain attn line (locked convention: needs founder sign-off).
- i18n: new notices namespace in sv+en; moved banner/hint keys deleted.
- notice_dismissals classified as archive-excluded (UI state, not
räkenskapsinformation) to satisfy the full-archive contract.
SkatteverketPromoCard keeps its localStorage dismiss for now; migrating it
to notice_dismissals is a follow-up.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(notices): stable dismissals with reaping, bounded ids, unnamed-bank copy
Review fixes on the notice aggregator:
- Migration renamed 20260819080000 -> 20260819190000_notice_dismissals.sql
(version collision with another in-flight PR; content unchanged).
- backup_failing dismissal stability: the id no longer embeds
last_auto_sync_at / needs_reauth_at, which the cron re-stamps while the
SAME incident persists and so resurrected a dismissed notice daily. The
id is now stable per (provider, reason), and the opposite direction is
kept correct by stale-dismissal reaping in getCompanyNotices: when a
category is currently healthy, the caller's stored dismissals for that
category (matched on the 'category:' id prefix) are best-effort deleted,
so error -> dismiss -> healthy (reaped) -> new error resurfaces. Audit of
the other ids: bank ids embed connection id + status/expiry and skv
embeds the incident's first-error/expiry timestamp (markNeedsReconsent
only fires post-connect), all stable per incident; they get the same
reaping as hygiene. Contract documented on Notice.id in types.ts.
- NULL bank_name no longer interpolates the Swedish fallback 'banken' into
the English message: a bank_broken_one_unnamed message variant (sv + en)
is selected instead of a name param.
- Bounded notice ids: folding several connections into one discriminator
now collapses to count + first 8 hex of a sha256 over the sorted parts
(node:crypto, server-only) instead of concatenating uuids; single
connection ids stay human-readable. Dismiss schema cap tightened to 200
with an updated rationale.
- Tests: persisting failure stays dismissed across two aggregations,
healthy state reaps, new failure after reap resurfaces, hint never
reaped, failed reap swallowed, 30-connection id under 200 chars and
stable across orderings, unnamed-bank variant, sorted backup id stable
across cron re-stamps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(notices): pg-real coverage for the notice_dismissals policies
The coverage gate is right to flag the migration: every policy on this table
binds company membership AND auth.uid(), and nothing exercised it. The suite
pins the property that makes the table different from the rest of the schema:
a dismissal is personal, so a colleague in the same company keeps seeing a
notice the other member hid. It also covers the upsert re-stamp (which needs
the UPDATE policy), cross-tenant refusal, dismissing on behalf of another
user, the caller-scoped DELETE that reaping relies on, and the composite key.
Falsification-verified against a real Postgres: weakening the SELECT policy
to company-only scoping fails the colleague-isolation test.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
194 lines
7.4 KiB
TypeScript
194 lines
7.4 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { insertAuthUser, insertCompanyMember, seedCompany } from '@/tests/pg/fixtures'
|
|
import { getPool, withUserContext } from '@/tests/pg/setup'
|
|
|
|
/**
|
|
* pg-real coverage for 20260819190000_notice_dismissals.sql.
|
|
*
|
|
* Every policy on this table binds BOTH company membership and auth.uid(),
|
|
* which is the property that separates it from the rest of the schema: a
|
|
* dismissal is personal, so a colleague in the same company must keep seeing
|
|
* a notice the other member dismissed. Company-only scoping would silence a
|
|
* degraded-state notice for the whole company the moment one person hid it,
|
|
* which is exactly the failure the notices work exists to avoid.
|
|
*
|
|
* Note on the helper: withUserContext ALWAYS rolls back (tests/pg/setup.ts),
|
|
* so a write and its verification have to live inside the same callback.
|
|
* Rows that must survive for a cross-user read are seeded on the pool
|
|
* connection instead, which bypasses RLS as superuser.
|
|
*/
|
|
|
|
const NOTICE_ID = 'bank_connection_broken:11111111-1111-1111-1111-111111111111=error'
|
|
|
|
async function seedDismissal(
|
|
companyId: string,
|
|
userId: string,
|
|
noticeId: string = NOTICE_ID,
|
|
): Promise<void> {
|
|
await getPool().query(
|
|
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
|
|
VALUES ($1, $2, $3)`,
|
|
[companyId, userId, noticeId],
|
|
)
|
|
}
|
|
|
|
async function countRows(companyId: string, userId: string): Promise<number> {
|
|
const res = await getPool().query<{ n: string }>(
|
|
`SELECT count(*)::text AS n FROM public.notice_dismissals
|
|
WHERE company_id = $1 AND user_id = $2`,
|
|
[companyId, userId],
|
|
)
|
|
return Number(res.rows[0]!.n)
|
|
}
|
|
|
|
describe('notice_dismissals RLS', () => {
|
|
it('lets a member dismiss a notice for themselves and read it back', async () => {
|
|
const a = await seedCompany()
|
|
|
|
await withUserContext(a.userId, async (client) => {
|
|
const ins = await client.query(
|
|
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
|
|
VALUES ($1, $2, $3)`,
|
|
[a.companyId, a.userId, NOTICE_ID],
|
|
)
|
|
expect(ins.rowCount).toBe(1)
|
|
|
|
const read = await client.query<{ notice_id: string }>(
|
|
`SELECT notice_id FROM public.notice_dismissals WHERE company_id = $1`,
|
|
[a.companyId],
|
|
)
|
|
expect(read.rows.map((r) => r.notice_id)).toEqual([NOTICE_ID])
|
|
})
|
|
})
|
|
|
|
it('re-stamps dismissed_at on a repeat dismissal (the upsert needs UPDATE)', async () => {
|
|
const a = await seedCompany()
|
|
await getPool().query(
|
|
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id, dismissed_at)
|
|
VALUES ($1, $2, $3, now() - interval '3 days')`,
|
|
[a.companyId, a.userId, NOTICE_ID],
|
|
)
|
|
|
|
await withUserContext(a.userId, async (client) => {
|
|
const res = await client.query<{ dismissed_at: Date }>(
|
|
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (company_id, user_id, notice_id)
|
|
DO UPDATE SET dismissed_at = now()
|
|
RETURNING dismissed_at`,
|
|
[a.companyId, a.userId, NOTICE_ID],
|
|
)
|
|
expect(res.rowCount).toBe(1)
|
|
// Without the UPDATE policy the ON CONFLICT branch is rejected outright,
|
|
// so reaching a fresh timestamp is the assertion that pins it.
|
|
const age = Date.now() - new Date(res.rows[0]!.dismissed_at).getTime()
|
|
expect(age).toBeLessThan(60_000)
|
|
})
|
|
})
|
|
|
|
it('keeps one member from seeing a colleague dismissal in the same company', async () => {
|
|
const a = await seedCompany()
|
|
const colleagueId = await insertAuthUser()
|
|
await insertCompanyMember({ companyId: a.companyId, userId: colleagueId, role: 'member' })
|
|
await seedDismissal(a.companyId, a.userId)
|
|
|
|
await withUserContext(colleagueId, async (client) => {
|
|
const res = await client.query(
|
|
`SELECT notice_id FROM public.notice_dismissals WHERE company_id = $1`,
|
|
[a.companyId],
|
|
)
|
|
expect(res.rowCount).toBe(0)
|
|
})
|
|
|
|
// The owner still sees their own row: the colleague is filtered, not the row.
|
|
await withUserContext(a.userId, async (client) => {
|
|
const res = await client.query(
|
|
`SELECT notice_id FROM public.notice_dismissals WHERE company_id = $1`,
|
|
[a.companyId],
|
|
)
|
|
expect(res.rowCount).toBe(1)
|
|
})
|
|
})
|
|
|
|
it('blocks dismissing on behalf of another user', async () => {
|
|
const a = await seedCompany()
|
|
const colleagueId = await insertAuthUser()
|
|
await insertCompanyMember({ companyId: a.companyId, userId: colleagueId, role: 'member' })
|
|
|
|
await withUserContext(a.userId, async (client) => {
|
|
await expect(
|
|
client.query(
|
|
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
|
|
VALUES ($1, $2, $3)`,
|
|
[a.companyId, colleagueId, NOTICE_ID],
|
|
),
|
|
).rejects.toThrow(/row-level security/i)
|
|
})
|
|
})
|
|
|
|
it('blocks a non-member from dismissing or reading another company notice', async () => {
|
|
const a = await seedCompany()
|
|
const b = await seedCompany()
|
|
await seedDismissal(a.companyId, a.userId)
|
|
|
|
await withUserContext(b.userId, async (client) => {
|
|
await expect(
|
|
client.query(
|
|
`INSERT INTO public.notice_dismissals (company_id, user_id, notice_id)
|
|
VALUES ($1, $2, $3)`,
|
|
[a.companyId, b.userId, NOTICE_ID],
|
|
),
|
|
).rejects.toThrow(/row-level security/i)
|
|
})
|
|
|
|
// A separate context: the rejected INSERT above aborts its transaction,
|
|
// so the read has to happen in a fresh one.
|
|
await withUserContext(b.userId, async (client) => {
|
|
const read = await client.query(
|
|
`SELECT notice_id FROM public.notice_dismissals WHERE company_id = $1`,
|
|
[a.companyId],
|
|
)
|
|
expect(read.rowCount).toBe(0)
|
|
})
|
|
})
|
|
|
|
it('deletes only the caller own rows, which is what reaping relies on', async () => {
|
|
const a = await seedCompany()
|
|
const colleagueId = await insertAuthUser()
|
|
await insertCompanyMember({ companyId: a.companyId, userId: colleagueId, role: 'member' })
|
|
await seedDismissal(a.companyId, a.userId)
|
|
await seedDismissal(a.companyId, colleagueId)
|
|
|
|
await withUserContext(colleagueId, async (client) => {
|
|
// An unqualified DELETE is still filtered to the caller's rows.
|
|
const res = await client.query(
|
|
`DELETE FROM public.notice_dismissals WHERE company_id = $1`,
|
|
[a.companyId],
|
|
)
|
|
expect(res.rowCount).toBe(1)
|
|
})
|
|
|
|
// Rolled back by the helper, so both rows are still there for the check
|
|
// that matters: the DELETE matched exactly one row, not both.
|
|
expect(await countRows(a.companyId, a.userId)).toBe(1)
|
|
expect(await countRows(a.companyId, colleagueId)).toBe(1)
|
|
})
|
|
|
|
it('scopes the primary key per (company, user, notice) so the same id can repeat', async () => {
|
|
const a = await seedCompany()
|
|
const b = await seedCompany()
|
|
const colleagueId = await insertAuthUser()
|
|
await insertCompanyMember({ companyId: a.companyId, userId: colleagueId, role: 'member' })
|
|
|
|
await seedDismissal(a.companyId, a.userId)
|
|
// Same notice id, different user in the same company: allowed.
|
|
await expect(seedDismissal(a.companyId, colleagueId)).resolves.not.toThrow()
|
|
// Same notice id, different company: allowed.
|
|
await expect(seedDismissal(b.companyId, b.userId)).resolves.not.toThrow()
|
|
// Exact same triple: rejected by the primary key.
|
|
await expect(seedDismissal(a.companyId, a.userId)).rejects.toThrow(
|
|
/notice_dismissals_pkey|duplicate key/i,
|
|
)
|
|
})
|
|
})
|