feat(db): phase 4 group 1 — lib/ settings + notifications to Drizzle

Convert lib/settings.ts, lib/notifications/{in-app,email,capa-escalation,
effectiveness-recheck}.ts from Supabase PostgREST to Drizzle asAdmin queries.
Drop supabase arg from all call sites in app/api/ and cron routes. Rewrite
notification unit tests to mock @/lib/db/with-user instead of SupabaseClient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 16:37:51 +08:00
co-authored by Claude Sonnet 4.6
parent d18d29168a
commit 25f923f530
19 changed files with 296 additions and 300 deletions
+54 -89
View File
@@ -1,3 +1,4 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { getEscalationThreshold, escalateOverdueCapa } from '@/lib/notifications/capa-escalation'
@@ -17,6 +18,16 @@ vi.mock('@/lib/settings', () => ({
getApiKey: vi.fn().mockResolvedValue('test-cred'),
}))
vi.mock('@/lib/notifications/in-app', () => ({
createInAppNotifications: vi.fn().mockResolvedValue({ created: 1 }),
}))
// At top of file, before other vi.mock calls
let mockAsAdminImpl: (fn: (db: unknown) => unknown) => unknown
vi.mock('@/lib/db/with-user', () => ({
asAdmin: vi.fn().mockImplementation((fn: (db: unknown) => unknown) => mockAsAdminImpl(fn)),
}))
describe('getEscalationThreshold', () => {
function daysFromNow(n: number): string {
const d = new Date()
@@ -49,106 +60,60 @@ describe('getEscalationThreshold', () => {
})
})
describe('escalateOverdueCapa — WhatsApp', () => {
describe('escalateOverdueCapa', () => {
function daysFromNow(n: number): string {
const d = new Date()
d.setDate(d.getDate() + n)
return d.toISOString().split('T')[0]
}
function makeSupabaseMock(overrides: {
capas?: unknown[]
alreadySent?: unknown
hseUsers?: unknown[]
}) {
const { capas = [], alreadySent = null, hseUsers = [] } = overrides
beforeEach(() => { vi.clearAllMocks() })
// Each .from() call returns a fresh chainable builder
// We track call order: first from('capa_actions'), then from('notifications_log'), then from('users'), then from('notifications_log').insert
let fromCallIndex = 0
it('returns notified:0 when no active capas', async () => {
let call = 0
mockAsAdminImpl = (fn) => {
call++
if (call === 1) return fn({ select: () => ({ from: () => ({ leftJoin: () => ({ leftJoin: () => ({ where: () => [] }) }) }) }) })
return fn({})
}
const { notified } = await escalateOverdueCapa()
expect(notified).toBe(0)
})
const makeChain = (resolvedValue: unknown) => {
const chain: Record<string, unknown> = {}
const methods = ['select', 'not', 'eq', 'in', 'limit', 'maybeSingle', 'insert']
for (const m of methods) {
chain[m] = vi.fn(() => chain)
it('skips capa with no threshold match', async () => {
let call = 0
mockAsAdminImpl = (fn) => {
call++
if (call === 1) {
// Return capa due in 2 days (no threshold)
return Promise.resolve([{
id: 'c1', description: 'Fix it', dueDate: daysFromNow(2),
incidentId: 'i1', ownerUserId: 'u1',
incidentRef: 'SITE-202507-0001', siteId: 's1',
ownerEmail: 'owner@example.com', ownerName: 'Owner', ownerPhone: null,
}])
}
// Terminal: awaiting the chain resolves to resolvedValue
Object.defineProperty(chain, 'then', {
get() {
return (resolve: (v: unknown) => unknown) => Promise.resolve(resolvedValue).then(resolve)
},
})
return chain
return Promise.resolve([])
}
const supabase = {
from: vi.fn(() => {
const index = fromCallIndex++
if (index === 0) return makeChain({ data: capas, error: null }) // capa_actions
if (index === 1) return makeChain({ data: alreadySent, error: null }) // notifications_log check
if (index === 2) return makeChain({ data: hseUsers, error: null }) // hse users for CC
return makeChain({ data: null, error: null }) // notifications_log insert
}),
}
return supabase as unknown as import('@supabase/supabase-js').SupabaseClient
}
beforeEach(() => {
vi.clearAllMocks()
const { notified } = await escalateOverdueCapa()
expect(notified).toBe(0)
})
it('sends WhatsApp when threshold is overdue_7d and owner has phone', async () => {
const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp')
const supabase = makeSupabaseMock({
capas: [
{
id: 'capa-1',
description: 'Fix safety barrier',
due_date: daysFromNow(-7),
incident_id: 'inc-1',
incidents: { reference_no: 'KL-202501-0001', site_id: 'site-1' },
owner: { email: 'owner@test.com', name: 'Alice', phone: '60123456789' },
},
],
alreadySent: null,
hseUsers: [],
})
await escalateOverdueCapa(supabase)
expect(mockWA).toHaveBeenCalledWith(
expect.stringMatching(/^\d+$/),
'ims_capa_overdue',
expect.arrayContaining([expect.any(String)]),
'test-cred',
'test-cred',
)
})
it('does NOT send WhatsApp when threshold is warning_3d', async () => {
const { sendWhatsAppMessage: mockWA } = await import('@/lib/notifications/whatsapp')
vi.clearAllMocks()
const supabase = makeSupabaseMock({
capas: [
{
id: 'capa-2',
description: 'Inspect equipment',
due_date: daysFromNow(3),
incident_id: 'inc-2',
incidents: { reference_no: 'KL-202501-0002', site_id: 'site-1' },
owner: { email: 'owner@test.com', name: 'Bob', phone: '60129876543' },
},
],
alreadySent: null,
hseUsers: [],
})
await escalateOverdueCapa(supabase)
expect(mockWA).not.toHaveBeenCalled()
it('sends email and WhatsApp for overdue_3d capa with phone', async () => {
const { sendWhatsAppMessage } = await import('@/lib/notifications/whatsapp')
let call = 0
mockAsAdminImpl = (fn) => {
call++
if (call === 1) return Promise.resolve([{
id: 'c1', description: 'Fix it', dueDate: daysFromNow(-3),
incidentId: 'i1', ownerUserId: 'u1',
incidentRef: 'SITE-202507-0001', siteId: 's1',
ownerEmail: 'owner@example.com', ownerName: 'Owner', ownerPhone: '+60123456789',
}])
return Promise.resolve([]) // alreadySent = [], hseUsers = [], insert void
}
const { notified } = await escalateOverdueCapa()
expect(notified).toBe(1)
expect(sendWhatsAppMessage).toHaveBeenCalled()
})
})
+27 -32
View File
@@ -1,8 +1,8 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from 'vitest'
const { mockSend, mockFrom } = vi.hoisted(() => ({
const { mockSend } = vi.hoisted(() => ({
mockSend: vi.fn(),
mockFrom: vi.fn(),
}))
vi.mock('resend', () => {
@@ -11,42 +11,36 @@ vi.mock('resend', () => {
return { Resend: MockResend }
})
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn().mockResolvedValue({ from: mockFrom }),
// Mock asAdmin: first call returns recipients, second returns incident+join rows
let asAdminCallCount = 0
const mockRecipients = [
{ email: 'supervisor@setiacorp.com' },
{ email: 'hse@setiacorp.com' },
]
const mockIncidentRows = [{
reportedAt: new Date('2026-07-10T09:00:00Z'),
siteName: 'SCW1',
reporterName: 'John Doe',
}]
vi.mock('@/lib/db/with-user', () => ({
asAdmin: vi.fn().mockImplementation((fn: (db: unknown) => unknown) => {
asAdminCallCount++
if (asAdminCallCount % 2 === 1) {
// first call: recipients query
return Promise.resolve(mockRecipients)
}
// second call: incident join query
return Promise.resolve(mockIncidentRows)
}),
}))
import { sendNewIncidentEmail } from '@/lib/notifications/email'
const mockIncidentRow = {
reported_at: '2026-07-10T09:00:00Z',
sites: { name: 'SCW1' },
reporter: { name: 'John Doe' },
}
function setupMocks(recipientData: object[]) {
mockFrom.mockImplementation((table: string) => {
if (table === 'users') {
return {
select: vi.fn().mockReturnThis(),
in: vi.fn().mockReturnThis(),
eq: vi.fn().mockResolvedValue({ data: recipientData, error: null }),
}
}
return {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: mockIncidentRow, error: null }),
}
})
}
beforeEach(() => {
vi.clearAllMocks()
asAdminCallCount = 0
mockSend.mockResolvedValue({ data: { id: 'email-123' }, error: null })
setupMocks([
{ email: 'supervisor@setiacorp.com', name: 'Ahmad', role: 'supervisor' },
{ email: 'hse@setiacorp.com', name: 'Priya', role: 'hse' },
])
})
describe('sendNewIncidentEmail', () => {
@@ -59,7 +53,8 @@ describe('sendNewIncidentEmail', () => {
})
it('does not send if no recipients', async () => {
setupMocks([])
const { asAdmin } = await import('@/lib/db/with-user')
vi.mocked(asAdmin).mockResolvedValueOnce([])
await sendNewIncidentEmail('inc-001', 'site-001', 'SCW1-202607-0001', 'near_miss')
expect(mockSend).not.toHaveBeenCalled()
})
+26 -28
View File
@@ -1,60 +1,58 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createInAppNotifications } from '@/lib/notifications/in-app'
import type { SupabaseClient } from '@supabase/supabase-js'
function makeSupabaseMock(rpcResult: { error: unknown } = { error: null }) {
return {
rpc: vi.fn().mockResolvedValue(rpcResult),
} as unknown as SupabaseClient
}
// Mock asAdmin before importing the module under test
const mockInsert = vi.fn()
const mockValues = vi.fn().mockResolvedValue([])
vi.mock('@/lib/db/with-user', () => ({
asAdmin: vi.fn().mockImplementation(fn =>
fn({
insert: mockInsert.mockReturnValue({ values: mockValues }),
})
),
}))
import { createInAppNotifications } from '@/lib/notifications/in-app'
describe('createInAppNotifications', () => {
beforeEach(() => {
vi.clearAllMocks()
mockInsert.mockReturnValue({ values: mockValues })
mockValues.mockResolvedValue([])
})
it('calls create_in_app_notification RPC once per recipient', async () => {
const supabase = makeSupabaseMock()
const { created } = await createInAppNotifications(supabase, [
it('inserts once per recipient', async () => {
const { created } = await createInAppNotifications([
{ userId: 'u1', title: 'New incident', link: '/hse/incidents/i1', incidentId: 'i1' },
{ userId: 'u2', title: 'New incident', link: '/hse/incidents/i1', incidentId: 'i1' },
])
expect(created).toBe(2)
expect(supabase.rpc).toHaveBeenCalledTimes(2)
expect(supabase.rpc).toHaveBeenCalledWith('create_in_app_notification', {
p_recipient: 'u1',
p_title: 'New incident',
p_link: '/hse/incidents/i1',
p_incident_id: 'i1',
p_capa_id: null,
})
expect(mockValues).toHaveBeenCalledTimes(2)
})
it('skips entries missing userId or title', async () => {
const supabase = makeSupabaseMock()
const { created } = await createInAppNotifications(supabase, [
const { created } = await createInAppNotifications([
{ userId: '', title: 'x' },
{ userId: 'u1', title: '' },
])
expect(created).toBe(0)
expect(supabase.rpc).not.toHaveBeenCalled()
expect(mockValues).not.toHaveBeenCalled()
})
it('counts only successful inserts when RPC errors', async () => {
const supabase = makeSupabaseMock({ error: { message: 'boom' } })
const { created } = await createInAppNotifications(supabase, [
it('counts only successful inserts; failed insert is caught', async () => {
mockValues.mockRejectedValueOnce(new Error('DB error'))
const { created } = await createInAppNotifications([
{ userId: 'u1', title: 'x' },
])
expect(created).toBe(0)
})
it('deduplicates recipients for the same notification', async () => {
const supabase = makeSupabaseMock()
const { created } = await createInAppNotifications(supabase, [
it('deduplicates same userId+title+incidentId', async () => {
const { created } = await createInAppNotifications([
{ userId: 'u1', title: 'same', incidentId: 'i1' },
{ userId: 'u1', title: 'same', incidentId: 'i1' },
])
expect(created).toBe(1)
expect(supabase.rpc).toHaveBeenCalledTimes(1)
expect(mockValues).toHaveBeenCalledTimes(1)
})
})