Added extension functionality
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
SECTORS,
|
||||
getSector,
|
||||
getExtensionDefinition,
|
||||
getAllExtensions,
|
||||
getExtensionsBySector,
|
||||
} from '../sectors'
|
||||
|
||||
describe('sectors registry', () => {
|
||||
it('should have 6 sectors', () => {
|
||||
expect(SECTORS.length).toBe(6)
|
||||
})
|
||||
|
||||
it('should have 17 total extensions', () => {
|
||||
expect(getAllExtensions().length).toBe(17)
|
||||
})
|
||||
|
||||
it('should have unique slugs within each sector', () => {
|
||||
for (const sector of SECTORS) {
|
||||
const slugs = sector.extensions.map(e => e.slug)
|
||||
const uniqueSlugs = new Set(slugs)
|
||||
expect(uniqueSlugs.size).toBe(slugs.length)
|
||||
}
|
||||
})
|
||||
|
||||
it('should have at least one extension per sector', () => {
|
||||
for (const sector of SECTORS) {
|
||||
expect(sector.extensions.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('getSector returns correct sector', () => {
|
||||
const sector = getSector('restaurant')
|
||||
expect(sector).toBeDefined()
|
||||
expect(sector!.slug).toBe('restaurant')
|
||||
expect(sector!.name).toBe('Restaurang & Café')
|
||||
})
|
||||
|
||||
it('getSector returns undefined for unknown slug', () => {
|
||||
const sector = getSector('invalid' as any)
|
||||
expect(sector).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getExtensionDefinition returns correct extension', () => {
|
||||
const ext = getExtensionDefinition('restaurant', 'food-cost')
|
||||
expect(ext).toBeDefined()
|
||||
expect(ext!.slug).toBe('food-cost')
|
||||
expect(ext!.name).toBe('Food Cost %')
|
||||
expect(ext!.sector).toBe('restaurant')
|
||||
})
|
||||
|
||||
it('getExtensionDefinition returns undefined for unknown extension', () => {
|
||||
const ext = getExtensionDefinition('restaurant', 'nonexistent')
|
||||
expect(ext).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getExtensionsBySector returns extensions for a sector', () => {
|
||||
const extensions = getExtensionsBySector('restaurant')
|
||||
expect(extensions.length).toBe(4)
|
||||
})
|
||||
|
||||
it('all extensions have required fields', () => {
|
||||
for (const ext of getAllExtensions()) {
|
||||
expect(ext.slug).toBeTruthy()
|
||||
expect(ext.name).toBeTruthy()
|
||||
expect(ext.sector).toBeTruthy()
|
||||
expect(ext.category).toBeTruthy()
|
||||
expect(ext.description).toBeTruthy()
|
||||
expect(ext.longDescription).toBeTruthy()
|
||||
expect(ext.icon).toBeTruthy()
|
||||
expect(ext.dataPattern).toBeTruthy()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { createMockSupabase } from '@/tests/helpers'
|
||||
|
||||
const { supabase: mockSupabase, mockResult } = createMockSupabase()
|
||||
vi.mock('@/lib/supabase/server', () => ({
|
||||
createServiceClient: () => Promise.resolve(mockSupabase),
|
||||
}))
|
||||
|
||||
import { isExtensionEnabled } from '../toggle-check'
|
||||
|
||||
describe('isExtensionEnabled', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns true when toggle exists and is enabled', async () => {
|
||||
mockResult({ data: { enabled: true }, error: null })
|
||||
|
||||
const result = await isExtensionEnabled('user-1', 'general', 'receipt-ocr')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(mockSupabase.from).toHaveBeenCalledWith('extension_toggles')
|
||||
})
|
||||
|
||||
it('returns false when toggle exists and is disabled', async () => {
|
||||
mockResult({ data: { enabled: false }, error: null })
|
||||
|
||||
const result = await isExtensionEnabled('user-1', 'general', 'receipt-ocr')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for legacy general extensions when no toggle row exists', async () => {
|
||||
mockResult({ data: null, error: null })
|
||||
|
||||
const result = await isExtensionEnabled('user-1', 'general', 'receipt-ocr')
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for non-legacy extensions when no toggle row exists', async () => {
|
||||
mockResult({ data: null, error: null })
|
||||
|
||||
const result = await isExtensionEnabled('user-1', 'restaurant', 'tip-tracking')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { ExtensionToggle } from './types'
|
||||
|
||||
export function useEnabledExtensions() {
|
||||
const [extensions, setExtensions] = useState<ExtensionToggle[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/extensions/toggles')
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setExtensions(data ?? [])
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [refresh])
|
||||
|
||||
return { extensions, isLoading, refresh }
|
||||
}
|
||||
|
||||
export function useExtensionToggle(sectorSlug: string, extensionSlug: string) {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const check = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/extensions/toggles/${sectorSlug}/${extensionSlug}`)
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
setEnabled(data?.enabled ?? false)
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
check()
|
||||
}, [sectorSlug, extensionSlug])
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
const newValue = !enabled
|
||||
setEnabled(newValue) // Optimistic update
|
||||
try {
|
||||
const res = await fetch('/api/extensions/toggles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sector_slug: sectorSlug,
|
||||
extension_slug: extensionSlug,
|
||||
enabled: newValue,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
setEnabled(!newValue) // Revert on error
|
||||
}
|
||||
} catch {
|
||||
setEnabled(!newValue) // Revert on error
|
||||
}
|
||||
}, [enabled, sectorSlug, extensionSlug])
|
||||
|
||||
return { enabled, isLoading, toggle }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
Camera,
|
||||
Sparkles,
|
||||
MessageSquare,
|
||||
Bell,
|
||||
Landmark,
|
||||
UtensilsCrossed,
|
||||
ChefHat,
|
||||
Wine,
|
||||
FileSpreadsheet,
|
||||
HandCoins,
|
||||
HardHat,
|
||||
Calculator,
|
||||
FolderKanban,
|
||||
Hotel,
|
||||
TrendingUp,
|
||||
BedDouble,
|
||||
Monitor,
|
||||
Clock,
|
||||
ReceiptText,
|
||||
ShoppingCart,
|
||||
Store,
|
||||
BarChart3,
|
||||
Layers,
|
||||
Puzzle,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
Camera,
|
||||
Sparkles,
|
||||
MessageSquare,
|
||||
Bell,
|
||||
Landmark,
|
||||
UtensilsCrossed,
|
||||
ChefHat,
|
||||
Wine,
|
||||
FileSpreadsheet,
|
||||
HandCoins,
|
||||
HardHat,
|
||||
Calculator,
|
||||
FolderKanban,
|
||||
Hotel,
|
||||
TrendingUp,
|
||||
BedDouble,
|
||||
Monitor,
|
||||
Clock,
|
||||
ReceiptText,
|
||||
ShoppingCart,
|
||||
Store,
|
||||
BarChart3,
|
||||
Layers,
|
||||
Puzzle,
|
||||
}
|
||||
|
||||
export function resolveIcon(name: string): LucideIcon {
|
||||
return ICON_MAP[name] ?? Puzzle
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
import { extensionRegistry } from './registry'
|
||||
import { receiptOcrExtension } from '@/extensions/receipt-ocr'
|
||||
import { aiCategorizationExtension } from '@/extensions/ai-categorization'
|
||||
import { pushNotificationsExtension } from '@/extensions/push-notifications'
|
||||
import { receiptOcrExtension } from '@/extensions/general/receipt-ocr'
|
||||
import { aiCategorizationExtension } from '@/extensions/general/ai-categorization'
|
||||
import { pushNotificationsExtension } from '@/extensions/general/push-notifications'
|
||||
import { sruExportExtension } from '@/extensions/sru-export'
|
||||
import { neBilagaExtension } from '@/extensions/ne-bilaga'
|
||||
import { aiChatExtension } from '@/extensions/ai-chat'
|
||||
import { aiChatExtension } from '@/extensions/general/ai-chat'
|
||||
import type { Extension } from './types'
|
||||
|
||||
// ── Enable Banking (PSD2) — opt-in extension ───────────────────────────
|
||||
// Uncomment the following line to enable automatic PSD2 bank transaction sync.
|
||||
// Requires ENABLE_BANKING_APP_ID and ENABLE_BANKING_PRIVATE_KEY env vars.
|
||||
//
|
||||
// import { enableBankingExtension } from '@/extensions/enable-banking'
|
||||
// import { enableBankingExtension } from '@/extensions/general/enable-banking'
|
||||
|
||||
/**
|
||||
* Explicit list of first-party extensions.
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import type { Sector, SectorSlug, ExtensionDefinition } from './types'
|
||||
|
||||
// ============================================================
|
||||
// Sector & Extension Registry
|
||||
// ============================================================
|
||||
//
|
||||
// Pure data file. No React, no database calls.
|
||||
// All sector and extension metadata lives here.
|
||||
// ============================================================
|
||||
|
||||
export const SECTORS: Sector[] = [
|
||||
// ── General ──────────────────────────────────────────────
|
||||
{
|
||||
slug: 'general',
|
||||
name: 'Generella verktyg',
|
||||
icon: 'Layers',
|
||||
description: 'Verktyg som passar alla verksamheter',
|
||||
extensions: [
|
||||
{
|
||||
slug: 'receipt-ocr',
|
||||
name: 'Kvittoscanning',
|
||||
sector: 'general',
|
||||
category: 'import',
|
||||
icon: 'Camera',
|
||||
dataPattern: 'manual',
|
||||
hasOwnData: true,
|
||||
description: 'Skanna kvitton och extrahera data automatiskt',
|
||||
longDescription:
|
||||
'Ladda upp kvittofoton och låt systemet automatiskt extrahera leverantör, belopp, moms och datum. Sparar tid och minskar manuell inmatning.',
|
||||
},
|
||||
{
|
||||
slug: 'ai-categorization',
|
||||
name: 'AI-kategorisering',
|
||||
sector: 'general',
|
||||
category: 'operations',
|
||||
icon: 'Sparkles',
|
||||
dataPattern: 'core',
|
||||
readsCoreTables: ['transactions'],
|
||||
description: 'AI-drivna kategoriförslag för transaktioner',
|
||||
longDescription:
|
||||
'Använder AI för att automatiskt föreslå BAS-kontokategorier för dina banktransaktioner. Lär sig från dina tidigare bokföringsval.',
|
||||
},
|
||||
{
|
||||
slug: 'ai-chat',
|
||||
name: 'AI-assistent',
|
||||
sector: 'general',
|
||||
category: 'operations',
|
||||
icon: 'MessageSquare',
|
||||
dataPattern: 'manual',
|
||||
hasOwnData: true,
|
||||
description: 'AI-assistent för skatte- och bokföringsfrågor',
|
||||
longDescription:
|
||||
'Ställ frågor om skatt, bokföring och företagande till en AI-assistent som förstår svensk redovisning. Svar baserade på aktuella regler och praxis.',
|
||||
},
|
||||
{
|
||||
slug: 'push-notifications',
|
||||
name: 'Push-notiser',
|
||||
sector: 'general',
|
||||
category: 'operations',
|
||||
icon: 'Bell',
|
||||
dataPattern: 'core',
|
||||
readsCoreTables: ['journal_entries', 'invoices', 'receipts'],
|
||||
description: 'Händelsenotiser för bokföringsaktiviteter',
|
||||
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: 'enable-banking',
|
||||
name: 'Bankintegration (PSD2)',
|
||||
sector: 'general',
|
||||
category: 'import',
|
||||
icon: 'Landmark',
|
||||
dataPattern: 'manual',
|
||||
hasOwnData: true,
|
||||
description: 'Automatisk banktransaktionssynk via PSD2',
|
||||
longDescription:
|
||||
'Koppla ditt bankkonto direkt och synka transaktioner automatiskt via säker PSD2-bankintegration. Stöder de flesta svenska banker.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Restaurant ───────────────────────────────────────────
|
||||
{
|
||||
slug: 'restaurant',
|
||||
name: 'Restaurang & Café',
|
||||
icon: 'UtensilsCrossed',
|
||||
description: 'Branschverktyg för restauranger och caféverksamhet',
|
||||
extensions: [
|
||||
{
|
||||
slug: 'food-cost',
|
||||
name: 'Food Cost %',
|
||||
sector: 'restaurant',
|
||||
category: 'reports',
|
||||
icon: 'ChefHat',
|
||||
dataPattern: 'core',
|
||||
readsCoreTables: ['journal_entry_lines'],
|
||||
description: 'Beräkna råvarukostnad i procent av omsättning',
|
||||
longDescription:
|
||||
'Beräkna och följ upp din råvarukostnadsprocent (food cost) automatiskt utifrån bokföringen. Jämför inköpskonton (4000-serien) mot livsmedelsomsättning (3000-serien) och se trender över tid.',
|
||||
},
|
||||
{
|
||||
slug: 'earnings-per-liter',
|
||||
name: 'Intäkt per liter alkohol',
|
||||
sector: 'restaurant',
|
||||
category: 'reports',
|
||||
icon: 'Wine',
|
||||
dataPattern: 'both',
|
||||
readsCoreTables: ['journal_entry_lines'],
|
||||
hasOwnData: true,
|
||||
description: 'Beräkna intäkt per såld liter alkohol',
|
||||
longDescription:
|
||||
'Kombinerar alkoholintäkter från bokföringen med manuellt inmatade literuppgifter för att räkna ut intäkt per liter. Följ trender och optimera ditt sortiment.',
|
||||
},
|
||||
{
|
||||
slug: 'pos-import',
|
||||
name: 'Kassa Z-rapport Import',
|
||||
sector: 'restaurant',
|
||||
category: 'import',
|
||||
icon: 'FileSpreadsheet',
|
||||
dataPattern: 'manual',
|
||||
hasOwnData: true,
|
||||
description: 'Importera Z-rapporter från kassasystem',
|
||||
longDescription:
|
||||
'Importera dagliga Z-rapporter från ditt kassasystem (CSV/Excel). Se daglig försäljningsstatistik, betalsättsfördelning och trender.',
|
||||
},
|
||||
{
|
||||
slug: 'tip-tracking',
|
||||
name: 'Dricksuppföljning',
|
||||
sector: 'restaurant',
|
||||
category: 'operations',
|
||||
icon: 'HandCoins',
|
||||
dataPattern: 'both',
|
||||
readsCoreTables: ['journal_entry_lines'],
|
||||
hasOwnData: true,
|
||||
description: 'Spåra dricks per skift och anställd',
|
||||
longDescription:
|
||||
'Registrera dricks per skift och anställd. Se totaler, snitt per anställd och dricks som andel av omsättningen.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Construction ─────────────────────────────────────────
|
||||
{
|
||||
slug: 'construction',
|
||||
name: 'Bygg & Anläggning',
|
||||
icon: 'HardHat',
|
||||
description: 'Branschverktyg för byggföretag och hantverkare',
|
||||
extensions: [
|
||||
{
|
||||
slug: 'rot-calculator',
|
||||
name: 'ROT-kalkylator',
|
||||
sector: 'construction',
|
||||
category: 'accounting',
|
||||
icon: 'Calculator',
|
||||
dataPattern: 'both',
|
||||
readsCoreTables: ['invoices'],
|
||||
hasOwnData: true,
|
||||
description: 'Beräkna ROT-avdrag per kund och jobb',
|
||||
longDescription:
|
||||
'Beräkna ROT-avdrag (30% av arbetskostnad, max 50 000 kr/år per kund). Håll koll på utnyttjad kvot per kund och undvik att överskrida maxbeloppet.',
|
||||
},
|
||||
{
|
||||
slug: 'project-cost',
|
||||
name: 'Projektkostnad',
|
||||
sector: 'construction',
|
||||
category: 'reports',
|
||||
icon: 'FolderKanban',
|
||||
dataPattern: 'both',
|
||||
readsCoreTables: ['journal_entry_lines', 'invoices'],
|
||||
hasOwnData: true,
|
||||
description: 'Följ upp kostnader och intäkter per byggprojekt',
|
||||
longDescription:
|
||||
'Samla alla kostnader och intäkter för varje byggprojekt. Se marginaler, jämför budget mot utfall och identifiera olönsamma projekt.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Hotel ────────────────────────────────────────────────
|
||||
{
|
||||
slug: 'hotel',
|
||||
name: 'Hotell & Logi',
|
||||
icon: 'Hotel',
|
||||
description: 'Branschverktyg för hotell och logi',
|
||||
extensions: [
|
||||
{
|
||||
slug: 'revpar',
|
||||
name: 'RevPAR',
|
||||
sector: 'hotel',
|
||||
category: 'reports',
|
||||
icon: 'TrendingUp',
|
||||
dataPattern: 'both',
|
||||
readsCoreTables: ['journal_entry_lines'],
|
||||
hasOwnData: true,
|
||||
description: 'Revenue Per Available Room',
|
||||
longDescription:
|
||||
'Beräkna Revenue Per Available Room (RevPAR) — det viktigaste nyckeltalet inom hotellbranschen. Kombinerar beläggningsgrad och snittpris per rum.',
|
||||
},
|
||||
{
|
||||
slug: 'occupancy',
|
||||
name: 'Beläggningsgrad',
|
||||
sector: 'hotel',
|
||||
category: 'reports',
|
||||
icon: 'BedDouble',
|
||||
dataPattern: 'manual',
|
||||
hasOwnData: true,
|
||||
description: 'Spåra beläggning och rumsstatus',
|
||||
longDescription:
|
||||
'Registrera daglig beläggning, tillgängliga rum och belagda rum. Se beläggningsgrad över tid och identifiera säsongsmönster.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── Tech ─────────────────────────────────────────────────
|
||||
{
|
||||
slug: 'tech',
|
||||
name: 'IT & Konsulting',
|
||||
icon: 'Monitor',
|
||||
description: 'Branschverktyg för IT-konsulter och teknikföretag',
|
||||
extensions: [
|
||||
{
|
||||
slug: 'billable-hours',
|
||||
name: 'Debiterbar tid',
|
||||
sector: 'tech',
|
||||
category: 'reports',
|
||||
icon: 'Clock',
|
||||
dataPattern: 'both',
|
||||
readsCoreTables: ['invoices'],
|
||||
hasOwnData: true,
|
||||
description: 'Följ debiteringsgrad och effektiv timtaxa',
|
||||
longDescription:
|
||||
'Registrera arbetade timmar per projekt och beräkna debiteringsgrad (debiterbar/total tid). Se effektiv timtaxa och optimera din tidsanvändning.',
|
||||
},
|
||||
{
|
||||
slug: 'project-billing',
|
||||
name: 'Projektfakturering',
|
||||
sector: 'tech',
|
||||
category: 'reports',
|
||||
icon: 'ReceiptText',
|
||||
dataPattern: 'both',
|
||||
readsCoreTables: ['invoices', 'journal_entry_lines'],
|
||||
hasOwnData: true,
|
||||
description: 'Fakturerade belopp per projekt och kund',
|
||||
longDescription:
|
||||
'Följ fakturerade belopp per projekt och kund. Jämför mot budget, se olönsamma projekt och identifiera dina mest lönsamma kunder.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ── E-commerce ───────────────────────────────────────────
|
||||
{
|
||||
slug: 'ecommerce',
|
||||
name: 'E-handel',
|
||||
icon: 'ShoppingCart',
|
||||
description: 'Branschverktyg för nätbutiker och e-handel',
|
||||
extensions: [
|
||||
{
|
||||
slug: 'shopify-import',
|
||||
name: 'Shopify-import',
|
||||
sector: 'ecommerce',
|
||||
category: 'import',
|
||||
icon: 'Store',
|
||||
dataPattern: 'manual',
|
||||
hasOwnData: true,
|
||||
description: 'Importera ordrar från Shopify',
|
||||
longDescription:
|
||||
'Importera orderdata från Shopify-export (CSV). Se intäkter per produkt, ordertrender och genomsnittligt ordervärde.',
|
||||
},
|
||||
{
|
||||
slug: 'multichannel-revenue',
|
||||
name: 'Flerkanalsintäkter',
|
||||
sector: 'ecommerce',
|
||||
category: 'reports',
|
||||
icon: 'BarChart3',
|
||||
dataPattern: 'both',
|
||||
readsCoreTables: ['journal_entry_lines'],
|
||||
hasOwnData: true,
|
||||
description: 'Intäktsanalys per försäljningskanal',
|
||||
longDescription:
|
||||
'Analysera intäkter fördelat på försäljningskanaler — webshop, marknadsplatser, fysisk butik. Identifiera dina mest lönsamma kanaler.',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// ============================================================
|
||||
// Helper functions
|
||||
// ============================================================
|
||||
|
||||
export function getSector(slug: SectorSlug): Sector | undefined {
|
||||
return SECTORS.find(s => s.slug === slug)
|
||||
}
|
||||
|
||||
export function getExtensionDefinition(sectorSlug: string, extensionSlug: string): ExtensionDefinition | undefined {
|
||||
const sector = SECTORS.find(s => s.slug === sectorSlug)
|
||||
return sector?.extensions.find(e => e.slug === extensionSlug)
|
||||
}
|
||||
|
||||
export function getAllExtensions(): ExtensionDefinition[] {
|
||||
return SECTORS.flatMap(s => s.extensions)
|
||||
}
|
||||
|
||||
export function getExtensionsBySector(slug: SectorSlug): ExtensionDefinition[] {
|
||||
return getSector(slug)?.extensions ?? []
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createServiceClient } from '@/lib/supabase/server'
|
||||
|
||||
/**
|
||||
* Check if an extension is enabled for a specific user.
|
||||
* Used by event handlers to gate execution.
|
||||
*
|
||||
* For backward compatibility during the transition period,
|
||||
* general extensions that were previously always-on default to
|
||||
* enabled when no toggle row exists.
|
||||
*/
|
||||
const LEGACY_GENERAL_EXTENSIONS = [
|
||||
'receipt-ocr',
|
||||
'ai-categorization',
|
||||
'ai-chat',
|
||||
'push-notifications',
|
||||
'enable-banking',
|
||||
]
|
||||
|
||||
export async function isExtensionEnabled(
|
||||
userId: string,
|
||||
sectorSlug: string,
|
||||
extensionSlug: string
|
||||
): Promise<boolean> {
|
||||
const supabase = await createServiceClient()
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_toggles')
|
||||
.select('enabled')
|
||||
.eq('user_id', userId)
|
||||
.eq('sector_slug', sectorSlug)
|
||||
.eq('extension_slug', extensionSlug)
|
||||
.single()
|
||||
|
||||
// If no toggle row exists, check if this is a legacy extension
|
||||
if (!data) {
|
||||
return sectorSlug === 'general' && LEGACY_GENERAL_EXTENSIONS.includes(extensionSlug)
|
||||
}
|
||||
|
||||
return data.enabled
|
||||
}
|
||||
@@ -1,4 +1,53 @@
|
||||
import type { CoreEventType } from '@/lib/events/types'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
// ============================================================
|
||||
// Extension Marketplace Types
|
||||
// ============================================================
|
||||
|
||||
/** Extension category for marketplace grouping */
|
||||
export type ExtensionCategory = 'accounting' | 'reports' | 'import' | 'operations'
|
||||
|
||||
/** Sector slugs for extension organization */
|
||||
export type SectorSlug = 'general' | 'restaurant' | 'construction' | 'hotel' | 'tech' | 'ecommerce'
|
||||
|
||||
/** How an extension gets its data */
|
||||
export type ExtensionDataPattern = 'core' | 'manual' | 'both'
|
||||
|
||||
/** Extension metadata for the marketplace and workspace routing */
|
||||
export interface ExtensionDefinition {
|
||||
slug: string
|
||||
name: string
|
||||
sector: SectorSlug
|
||||
category: ExtensionCategory
|
||||
description: string
|
||||
longDescription: string
|
||||
icon: string
|
||||
entityTypes?: EntityType[]
|
||||
dataPattern: ExtensionDataPattern
|
||||
readsCoreTables?: string[]
|
||||
hasOwnData?: boolean
|
||||
}
|
||||
|
||||
/** Sector definition with its extensions */
|
||||
export interface Sector {
|
||||
slug: SectorSlug
|
||||
name: string
|
||||
icon: string
|
||||
description: string
|
||||
extensions: ExtensionDefinition[]
|
||||
}
|
||||
|
||||
/** Database row for extension toggle state */
|
||||
export interface ExtensionToggle {
|
||||
id: string
|
||||
user_id: string
|
||||
sector_slug: string
|
||||
extension_slug: string
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Extension Interface & Supporting Types
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
export interface WorkspaceComponentProps {
|
||||
userId: string
|
||||
}
|
||||
|
||||
type WorkspaceKey = `${string}/${string}`
|
||||
|
||||
const WORKSPACES: Record<WorkspaceKey, ComponentType<WorkspaceComponentProps>> = {
|
||||
// General
|
||||
'general/receipt-ocr': dynamic(() => import('@/components/extensions/general/ReceiptOcrWorkspace')),
|
||||
'general/ai-categorization': dynamic(() => import('@/components/extensions/general/AiCategorizationWorkspace')),
|
||||
'general/ai-chat': dynamic(() => import('@/components/extensions/general/AiChatWorkspace')),
|
||||
'general/push-notifications': dynamic(() => import('@/components/extensions/general/PushNotificationsWorkspace')),
|
||||
'general/enable-banking': dynamic(() => import('@/components/extensions/general/EnableBankingWorkspace')),
|
||||
// Restaurant
|
||||
'restaurant/food-cost': dynamic(() => import('@/components/extensions/restaurant/FoodCostWorkspace')),
|
||||
'restaurant/earnings-per-liter': dynamic(() => import('@/components/extensions/restaurant/EarningsPerLiterWorkspace')),
|
||||
'restaurant/pos-import': dynamic(() => import('@/components/extensions/restaurant/PosImportWorkspace')),
|
||||
'restaurant/tip-tracking': dynamic(() => import('@/components/extensions/restaurant/TipTrackingWorkspace')),
|
||||
// Construction
|
||||
'construction/rot-calculator': dynamic(() => import('@/components/extensions/construction/RotCalculatorWorkspace')),
|
||||
'construction/project-cost': dynamic(() => import('@/components/extensions/construction/ProjectCostWorkspace')),
|
||||
// Hotel
|
||||
'hotel/revpar': dynamic(() => import('@/components/extensions/hotel/RevparWorkspace')),
|
||||
'hotel/occupancy': dynamic(() => import('@/components/extensions/hotel/OccupancyWorkspace')),
|
||||
// Tech
|
||||
'tech/billable-hours': dynamic(() => import('@/components/extensions/tech/BillableHoursWorkspace')),
|
||||
'tech/project-billing': dynamic(() => import('@/components/extensions/tech/ProjectBillingWorkspace')),
|
||||
// E-commerce
|
||||
'ecommerce/shopify-import': dynamic(() => import('@/components/extensions/ecommerce/ShopifyImportWorkspace')),
|
||||
'ecommerce/multichannel-revenue': dynamic(() => import('@/components/extensions/ecommerce/MultichannelRevenueWorkspace')),
|
||||
}
|
||||
|
||||
export function getWorkspaceComponent(
|
||||
sector: string,
|
||||
slug: string
|
||||
): ComponentType<WorkspaceComponentProps> | null {
|
||||
return WORKSPACES[`${sector}/${slug}` as WorkspaceKey] ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user