Merge remote-tracking branch 'origin/main' into fixing-extensions-bug
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createExtensionContext } from '../context-factory'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { createMockSupabase } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/transactions/ingest', () => ({
|
||||
ingestTransactions: vi.fn().mockResolvedValue({
|
||||
imported: 0, duplicates: 0, reconciled: 0,
|
||||
auto_categorized: 0, auto_matched_invoices: 0, errors: 0,
|
||||
transaction_ids: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
eventBus.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('createExtensionContext', () => {
|
||||
it('returns context with correct userId and extensionId', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
expect(ctx.userId).toBe('user-1')
|
||||
expect(ctx.extensionId).toBe('test-ext')
|
||||
})
|
||||
|
||||
it('provides supabase client', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
expect(ctx.supabase).toBe(supabase)
|
||||
})
|
||||
|
||||
it('emit() delegates to eventBus.emit()', async () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
const handler = vi.fn()
|
||||
eventBus.on('journal_entry.committed', handler)
|
||||
|
||||
await ctx.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: { id: 'e1' } as never, userId: 'user-1' },
|
||||
})
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'user-1' })
|
||||
})
|
||||
|
||||
it('log methods prefix with extensionId', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'my-ext')
|
||||
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
ctx.log.info('hello', 42)
|
||||
ctx.log.warn('caution')
|
||||
ctx.log.error('oops')
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith('[my-ext]', 'hello', 42)
|
||||
expect(warnSpy).toHaveBeenCalledWith('[my-ext]', 'caution')
|
||||
expect(errorSpy).toHaveBeenCalledWith('[my-ext]', 'oops')
|
||||
|
||||
logSpy.mockRestore()
|
||||
warnSpy.mockRestore()
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('settings.get() queries extension_data table', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: { value: { autoOcr: true } }, error: null })
|
||||
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'receipt-ocr')
|
||||
const result = await ctx.settings.get<{ autoOcr: boolean }>('settings')
|
||||
|
||||
expect(result).toEqual({ autoOcr: true })
|
||||
expect(supabase.from).toHaveBeenCalledWith('extension_data')
|
||||
})
|
||||
|
||||
it('settings.get() without key defaults to "settings"', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: { value: { foo: 'bar' } }, error: null })
|
||||
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
const result = await ctx.settings.get<{ foo: string }>()
|
||||
|
||||
expect(result).toEqual({ foo: 'bar' })
|
||||
})
|
||||
|
||||
it('settings.get() returns null when no data', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: null, error: null })
|
||||
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
const result = await ctx.settings.get('missing-key')
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('settings.set() upserts into extension_data table', async () => {
|
||||
const { supabase, mockResult } = createMockSupabase()
|
||||
mockResult({ data: null, error: null })
|
||||
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
await ctx.settings.set('my-key', { value: 123 })
|
||||
|
||||
expect(supabase.from).toHaveBeenCalledWith('extension_data')
|
||||
})
|
||||
|
||||
it('storage.getPublicUrl() returns URL string', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
const url = ctx.storage.getPublicUrl('documents', 'path/to/file.pdf')
|
||||
expect(typeof url).toBe('string')
|
||||
})
|
||||
|
||||
it('services.ingestTransactions is a function', () => {
|
||||
const { supabase } = createMockSupabase()
|
||||
const ctx = createExtensionContext(supabase as never, 'user-1', 'test-ext')
|
||||
|
||||
expect(typeof ctx.services.ingestTransactions).toBe('function')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { extensionRegistry } from '../registry'
|
||||
import { extensionRegistry, setContextFactory } from '../registry'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { Extension } from '../types'
|
||||
import type { Extension, ExtensionContext } from '../types'
|
||||
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createClient: vi.fn().mockResolvedValue({
|
||||
from: vi.fn(),
|
||||
storage: { from: vi.fn() },
|
||||
}),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
extensionRegistry.clear()
|
||||
@@ -40,7 +47,8 @@ describe('ExtensionRegistry', () => {
|
||||
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ entry: { id: 'e1' }, userId: 'u1' })
|
||||
expect(handler).toHaveBeenCalled()
|
||||
expect(handler.mock.calls[0][0]).toEqual({ entry: { id: 'e1' }, userId: 'u1' })
|
||||
})
|
||||
|
||||
it('register() skips duplicate registration (same id)', () => {
|
||||
@@ -126,4 +134,28 @@ describe('ExtensionRegistry', () => {
|
||||
expect(handler1).not.toHaveBeenCalled()
|
||||
expect(handler2).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handler receives ExtensionContext when context factory is set', async () => {
|
||||
const handler = vi.fn()
|
||||
const mockCtx = { extensionId: 'ctx-ext', userId: 'u1' } as ExtensionContext
|
||||
|
||||
setContextFactory((_supabase, _userId, _extId) => mockCtx)
|
||||
|
||||
const ext = makeExtension({
|
||||
id: 'ctx-ext',
|
||||
eventHandlers: [{ eventType: 'journal_entry.committed', handler }],
|
||||
})
|
||||
|
||||
extensionRegistry.register(ext)
|
||||
|
||||
await eventBus.emit({
|
||||
type: 'journal_entry.committed',
|
||||
payload: { entry: { id: 'e1' } as never, userId: 'u1' },
|
||||
})
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
{ entry: { id: 'e1' }, userId: 'u1' },
|
||||
mockCtx
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,8 +11,8 @@ describe('sectors registry', () => {
|
||||
expect(SECTORS.length).toBe(6)
|
||||
})
|
||||
|
||||
it('should have 17 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(17)
|
||||
it('should have 18 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(18)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { CoreEvent } from '@/lib/events/types'
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import { ingestTransactions } from '@/lib/transactions/ingest'
|
||||
import type {
|
||||
ExtensionContext,
|
||||
ExtensionLogger,
|
||||
ExtensionSettings,
|
||||
ExtensionStorage,
|
||||
ExtensionServices,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* Create a prefixed logger for an extension.
|
||||
*/
|
||||
function createLogger(extensionId: string): ExtensionLogger {
|
||||
const prefix = `[${extensionId}]`
|
||||
return {
|
||||
info: (message: string, ...args: unknown[]) => console.log(prefix, message, ...args),
|
||||
warn: (message: string, ...args: unknown[]) => console.warn(prefix, message, ...args),
|
||||
error: (message: string, ...args: unknown[]) => console.error(prefix, message, ...args),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a settings accessor scoped to a specific extension.
|
||||
*/
|
||||
function createSettings(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
extensionId: string
|
||||
): ExtensionSettings {
|
||||
return {
|
||||
async get<T>(key?: string): Promise<T | null> {
|
||||
const lookupKey = key ?? 'settings'
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', userId)
|
||||
.eq('extension_id', extensionId)
|
||||
.eq('key', lookupKey)
|
||||
.single()
|
||||
|
||||
return (data?.value as T) ?? null
|
||||
},
|
||||
|
||||
async set<T>(key: string, value: T): Promise<void> {
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: extensionId,
|
||||
key,
|
||||
value,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a storage accessor wrapping Supabase storage.
|
||||
*/
|
||||
function createStorage(supabase: SupabaseClient): ExtensionStorage {
|
||||
return {
|
||||
async download(bucket: string, path: string) {
|
||||
const { data, error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.download(path)
|
||||
return { data, error: error?.message }
|
||||
},
|
||||
|
||||
async upload(bucket: string, path: string, data: ArrayBuffer, options?: { contentType?: string }) {
|
||||
const { error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.upload(path, data, options ? { contentType: options.contentType } : undefined)
|
||||
if (error) return { path: '', error: error.message }
|
||||
return { path }
|
||||
},
|
||||
|
||||
getPublicUrl(bucket: string, path: string): string {
|
||||
const { data } = supabase.storage
|
||||
.from(bucket)
|
||||
.getPublicUrl(path)
|
||||
return data.publicUrl
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create core services exposed to extensions.
|
||||
*/
|
||||
function createServices(): ExtensionServices {
|
||||
return {
|
||||
ingestTransactions,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fully populated ExtensionContext.
|
||||
*
|
||||
* The context gives extensions access to Supabase, event emission, settings,
|
||||
* storage, logging, and core services — without importing from core modules.
|
||||
*/
|
||||
export function createExtensionContext(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
extensionId: string
|
||||
): ExtensionContext {
|
||||
return {
|
||||
userId,
|
||||
extensionId,
|
||||
supabase,
|
||||
emit: (event: CoreEvent) => eventBus.emit(event),
|
||||
settings: createSettings(supabase, userId, extensionId),
|
||||
storage: createStorage(supabase),
|
||||
log: createLogger(extensionId),
|
||||
services: createServices(),
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { pushNotificationsExtension } from '@/extensions/general/push-notificati
|
||||
import { sruExportExtension } from '@/extensions/sru-export'
|
||||
import { neBilagaExtension } from '@/extensions/ne-bilaga'
|
||||
import { aiChatExtension } from '@/extensions/general/ai-chat'
|
||||
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
|
||||
import type { Extension } from './types'
|
||||
|
||||
// ── Enable Banking (PSD2) — opt-in extension ───────────────────────────
|
||||
@@ -26,6 +27,7 @@ const FIRST_PARTY_EXTENSIONS: Extension[] = [
|
||||
sruExportExtension,
|
||||
neBilagaExtension,
|
||||
aiChatExtension,
|
||||
invoiceInboxExtension,
|
||||
// enableBankingExtension, // Uncomment to activate PSD2 bank sync
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,25 @@
|
||||
import { eventBus } from '@/lib/events/bus'
|
||||
import type { CoreEventType } from '@/lib/events/types'
|
||||
import type { Extension } from './types'
|
||||
import type { Extension, ExtensionContext } from './types'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
/** Factory function type for creating extension contexts */
|
||||
export type ContextFactory = (
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
extensionId: string
|
||||
) => ExtensionContext
|
||||
|
||||
/** Lazy-loaded context factory — set during initialization */
|
||||
let contextFactory: ContextFactory | null = null
|
||||
|
||||
/**
|
||||
* Set the context factory used to build ExtensionContext for event handlers.
|
||||
* Called once during system initialization.
|
||||
*/
|
||||
export function setContextFactory(factory: ContextFactory): void {
|
||||
contextFactory = factory
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension Registry — singleton that manages extension lifecycle.
|
||||
@@ -15,6 +34,10 @@ class ExtensionRegistry {
|
||||
|
||||
/**
|
||||
* Register an extension: store it and wire its event handlers to the bus.
|
||||
*
|
||||
* Each handler is wrapped so it receives `(payload, ctx)`. The context is
|
||||
* built lazily when the event fires, using the userId from the payload and
|
||||
* a Supabase client created from the current request cookies.
|
||||
*/
|
||||
register(extension: Extension): void {
|
||||
if (this.extensions.has(extension.id)) {
|
||||
@@ -28,8 +51,24 @@ class ExtensionRegistry {
|
||||
const unsubs: (() => void)[] = []
|
||||
if (extension.eventHandlers) {
|
||||
for (const { eventType, handler } of extension.eventHandlers) {
|
||||
// Cast is safe: the handler is stored by eventType key, so it only receives matching payloads
|
||||
const unsub = eventBus.on(eventType as CoreEventType, handler)
|
||||
// Wrap handler to inject ExtensionContext as second argument
|
||||
const wrappedHandler = async (payload: { userId: string; [key: string]: unknown }) => {
|
||||
let ctx: ExtensionContext | undefined
|
||||
if (contextFactory && payload.userId) {
|
||||
try {
|
||||
// Dynamic import to avoid circular deps at module load time
|
||||
const { createClient } = await import('@/lib/supabase/server')
|
||||
const supabase = await createClient()
|
||||
ctx = contextFactory(supabase, payload.userId, extension.id)
|
||||
} catch {
|
||||
// Context creation failed (e.g. no request cookies in cron jobs).
|
||||
// Handler still gets called — ctx will be undefined.
|
||||
}
|
||||
}
|
||||
return handler(payload, ctx)
|
||||
}
|
||||
|
||||
const unsub = eventBus.on(eventType as CoreEventType, wrappedHandler)
|
||||
unsubs.push(unsub)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,18 @@ export const SECTORS: Sector[] = [
|
||||
longDescription:
|
||||
'Få push-notiser direkt i webbläsaren när viktiga händelser sker — nya fakturor, förfallna betalningar, slutförda bokföringar med mera.',
|
||||
},
|
||||
{
|
||||
slug: 'invoice-inbox',
|
||||
name: 'Leverantörsfaktura-inbox',
|
||||
sector: 'general',
|
||||
category: 'import',
|
||||
icon: 'Inbox',
|
||||
dataPattern: 'manual',
|
||||
hasOwnData: true,
|
||||
description: 'Ta emot leverantörsfakturor via e-post eller uppladdning',
|
||||
longDescription:
|
||||
'Skicka leverantörsfakturor till en dedikerad e-postadress eller ladda upp manuellt. AI extraherar automatiskt leverantörsdata, belopp och moms. Granska och bekräfta med ett klick för att skapa leverantörsfakturor.',
|
||||
},
|
||||
{
|
||||
slug: 'enable-banking',
|
||||
name: 'Bankintegration (PSD2)',
|
||||
|
||||
+37
-5
@@ -1,5 +1,6 @@
|
||||
import type { CoreEventType } from '@/lib/events/types'
|
||||
import type { EntityType } from '@/types'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { CoreEvent, CoreEventType } from '@/lib/events/types'
|
||||
import type { EntityType, RawTransaction, IngestResult } from '@/types'
|
||||
|
||||
// ============================================================
|
||||
// Extension Marketplace Types
|
||||
@@ -63,7 +64,7 @@ export interface RouteDefinition {
|
||||
export interface ApiRouteDefinition {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
||||
path: string
|
||||
handler: (request: Request) => Promise<Response>
|
||||
handler: (request: Request, ctx?: ExtensionContext) => Promise<Response>
|
||||
}
|
||||
|
||||
/** Sidebar navigation item added by an extension */
|
||||
@@ -112,13 +113,44 @@ export interface MappingRuleTypeDefinition {
|
||||
export interface ExtensionEventHandler {
|
||||
eventType: CoreEventType
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
handler: (payload: any) => Promise<void> | void
|
||||
handler: (payload: any, ctx?: ExtensionContext) => Promise<void> | void
|
||||
}
|
||||
|
||||
/** Context passed to extension lifecycle hooks */
|
||||
/** Logger interface for extensions */
|
||||
export interface ExtensionLogger {
|
||||
info(message: string, ...args: unknown[]): void
|
||||
warn(message: string, ...args: unknown[]): void
|
||||
error(message: string, ...args: unknown[]): void
|
||||
}
|
||||
|
||||
/** Settings accessor for extension-scoped key-value data */
|
||||
export interface ExtensionSettings {
|
||||
get<T>(key?: string): Promise<T | null>
|
||||
set<T>(key: string, value: T): Promise<void>
|
||||
}
|
||||
|
||||
/** Storage accessor wrapping Supabase storage */
|
||||
export interface ExtensionStorage {
|
||||
download(bucket: string, path: string): Promise<{ data: Blob | null; error?: string }>
|
||||
upload(bucket: string, path: string, data: ArrayBuffer, options?: { contentType?: string }): Promise<{ path: string; error?: string }>
|
||||
getPublicUrl(bucket: string, path: string): string
|
||||
}
|
||||
|
||||
/** Core services exposed to extensions */
|
||||
export interface ExtensionServices {
|
||||
ingestTransactions(supabase: SupabaseClient, userId: string, raw: RawTransaction[]): Promise<IngestResult>
|
||||
}
|
||||
|
||||
/** Context passed to extension lifecycle hooks and event handlers */
|
||||
export interface ExtensionContext {
|
||||
userId: string
|
||||
extensionId: string
|
||||
supabase: SupabaseClient
|
||||
emit(event: CoreEvent): Promise<void>
|
||||
settings: ExtensionSettings
|
||||
storage: ExtensionStorage
|
||||
log: ExtensionLogger
|
||||
services: ExtensionServices
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user