Added extension functionality
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { Bell, BellOff, Loader2, Moon } from 'lucide-react'
|
||||
import type { NotificationSettings as NotificationSettingsType } from '@/types'
|
||||
|
||||
interface NotificationSettingsProps {
|
||||
onSettingsChange?: () => void
|
||||
}
|
||||
|
||||
export function NotificationSettings({ onSettingsChange }: NotificationSettingsProps) {
|
||||
const { toast } = useToast()
|
||||
const supabase = createClient()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [settings, setSettings] = useState<NotificationSettingsType | null>(null)
|
||||
const [isPushSupported, setIsPushSupported] = useState(false)
|
||||
const [isPushSubscribed, setIsPushSubscribed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings()
|
||||
checkPushSupport()
|
||||
}, [])
|
||||
|
||||
const checkPushSupport = async () => {
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
setIsPushSupported(true)
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
setIsPushSubscribed(!!subscription)
|
||||
} catch (error) {
|
||||
console.error('Error checking push subscription:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSettings = async () => {
|
||||
setIsLoading(true)
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (!error && data) {
|
||||
setSettings(data)
|
||||
} else {
|
||||
// Create default settings if not found
|
||||
const { data: newSettings, error: insertError } = await supabase
|
||||
.from('notification_settings')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
tax_deadlines_enabled: true,
|
||||
invoice_reminders_enabled: true,
|
||||
push_enabled: true,
|
||||
email_enabled: true,
|
||||
quiet_start: '21:00',
|
||||
quiet_end: '08:00',
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (!insertError && newSettings) {
|
||||
setSettings(newSettings)
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const updateSetting = async (key: keyof NotificationSettingsType, value: boolean | string) => {
|
||||
if (!settings) return
|
||||
|
||||
setIsSaving(true)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('notification_settings')
|
||||
.update({ [key]: value })
|
||||
.eq('id', settings.id)
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte spara inställning',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
setSettings({ ...settings, [key]: value })
|
||||
onSettingsChange?.()
|
||||
}
|
||||
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
const subscribeToPush = async () => {
|
||||
try {
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
toast({
|
||||
title: 'Aviseringar avslagna',
|
||||
description: 'Du behöver ge tillåtelse för push-aviseringar i webbläsaren.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const vapidResponse = await fetch('/api/extensions/push-notifications/subscribe')
|
||||
const { vapidPublicKey } = await vapidResponse.json()
|
||||
|
||||
if (!vapidPublicKey) {
|
||||
throw new Error('Push not configured')
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
|
||||
const response = await fetch('/api/extensions/push-notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save subscription')
|
||||
}
|
||||
|
||||
setIsPushSubscribed(true)
|
||||
toast({
|
||||
title: 'Push-aviseringar aktiverade',
|
||||
description: 'Du kommer nu få push-aviseringar.',
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte aktivera push-aviseringar.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribeFromPush = async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
|
||||
if (subscription) {
|
||||
await subscription.unsubscribe()
|
||||
|
||||
await fetch('/api/extensions/push-notifications/subscribe', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: subscription.endpoint }),
|
||||
})
|
||||
}
|
||||
|
||||
setIsPushSubscribed(false)
|
||||
toast({
|
||||
title: 'Push-aviseringar inaktiverade',
|
||||
description: 'Du kommer inte längre få push-aviseringar.',
|
||||
})
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte inaktivera push-aviseringar.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Push notification status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
Push-aviseringar
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Få påminnelser direkt i din webbläsare
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isPushSupported ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{isPushSubscribed ? 'Aktiverade' : 'Inaktiverade'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isPushSubscribed
|
||||
? 'Du får push-aviseringar på denna enhet'
|
||||
: 'Aktivera för att få push-aviseringar'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={isPushSubscribed ? 'outline' : 'default'}
|
||||
onClick={isPushSubscribed ? unsubscribeFromPush : subscribeToPush}
|
||||
>
|
||||
{isPushSubscribed ? (
|
||||
<>
|
||||
<BellOff className="mr-2 h-4 w-4" />
|
||||
Inaktivera
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
Aktivera
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Din webbläsare stöder inte push-aviseringar.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Category settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Aviseringskategorier</CardTitle>
|
||||
<CardDescription>
|
||||
Välj vilka typer av aviseringar du vill få
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="tax-deadlines">Skattedeadlines</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Moms, F-skatt, inkomstdeklaration m.m.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="tax-deadlines"
|
||||
checked={settings.tax_deadlines_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting('tax_deadlines_enabled', checked)
|
||||
}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="invoice-reminders">Fakturapåminnelser</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Fakturor som förfaller eller är försenade
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="invoice-reminders"
|
||||
checked={settings.invoice_reminders_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting('invoice_reminders_enabled', checked)
|
||||
}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quiet hours */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Moon className="h-5 w-5" />
|
||||
Tysta timmar
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Inga aviseringar skickas under dessa timmar
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quiet-start">Från</Label>
|
||||
<Input
|
||||
id="quiet-start"
|
||||
type="time"
|
||||
value={settings.quiet_start}
|
||||
onChange={(e) => updateSetting('quiet_start', e.target.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="quiet-end">Till</Label>
|
||||
<Input
|
||||
id="quiet-end"
|
||||
type="time"
|
||||
value={settings.quiet_end}
|
||||
onChange={(e) => updateSetting('quiet_end', e.target.value)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Standard: 21:00 - 08:00 (Sveriges tid)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
|
||||
return outputArray.buffer
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { Bell, BellOff, Loader2 } from 'lucide-react'
|
||||
|
||||
interface PushPromptProps {
|
||||
onSubscribed?: () => void
|
||||
onDismissed?: () => void
|
||||
}
|
||||
|
||||
export function PushPrompt({ onSubscribed, onDismissed }: PushPromptProps) {
|
||||
const { toast } = useToast()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isSupported, setIsSupported] = useState(false)
|
||||
const [isSubscribed, setIsSubscribed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if push notifications are supported
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
setIsSupported(true)
|
||||
checkSubscription()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const checkSubscription = async () => {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
setIsSubscribed(!!subscription)
|
||||
} catch (error) {
|
||||
console.error('Error checking push subscription:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const requestPermission = async () => {
|
||||
if (!isSupported) return
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Request notification permission
|
||||
const permission = await Notification.requestPermission()
|
||||
|
||||
if (permission !== 'granted') {
|
||||
toast({
|
||||
title: 'Aviseringar avslagna',
|
||||
description: 'Du kan aktivera aviseringar senare i inställningarna.',
|
||||
})
|
||||
setIsOpen(false)
|
||||
onDismissed?.()
|
||||
return
|
||||
}
|
||||
|
||||
// Get VAPID public key
|
||||
const vapidResponse = await fetch('/api/extensions/push-notifications/subscribe')
|
||||
const { vapidPublicKey } = await vapidResponse.json()
|
||||
|
||||
if (!vapidPublicKey) {
|
||||
throw new Error('Push notifications not configured')
|
||||
}
|
||||
|
||||
// Subscribe to push notifications
|
||||
const registration = await navigator.serviceWorker.ready
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
|
||||
// Send subscription to server
|
||||
const response = await fetch('/api/extensions/push-notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(subscription.toJSON()),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save subscription')
|
||||
}
|
||||
|
||||
setIsSubscribed(true)
|
||||
setIsOpen(false)
|
||||
|
||||
toast({
|
||||
title: 'Aviseringar aktiverade',
|
||||
description: 'Du kommer få påminnelser om viktiga deadlines.',
|
||||
})
|
||||
|
||||
onSubscribed?.()
|
||||
} catch (error) {
|
||||
console.error('Error subscribing to push:', error)
|
||||
toast({
|
||||
title: 'Fel',
|
||||
description: 'Kunde inte aktivera aviseringar. Försök igen senare.',
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsOpen(false)
|
||||
// Store dismissal in localStorage to not show again
|
||||
localStorage.setItem('push-prompt-dismissed', 'true')
|
||||
onDismissed?.()
|
||||
}
|
||||
|
||||
// Don't show if not supported, already subscribed, or previously dismissed
|
||||
if (!isSupported || isSubscribed) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Show dialog after a delay or when triggered
|
||||
return (
|
||||
<>
|
||||
{/* Floating button to show prompt */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="fixed bottom-4 right-4 gap-2 shadow-lg z-50"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
Aktivera aviseringar
|
||||
</Button>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-primary" />
|
||||
Aktivera push-aviseringar
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Få påminnelser om viktiga skattedeadlines, fakturor som förfaller och
|
||||
kampanjleveranser direkt i din webbläsare.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<span>Skattedeadlines (7 och 1 dag innan)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<span>Fakturor som förfaller</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-primary rounded-full" />
|
||||
<span>Kampanjleveranser</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Du kan när som helst stänga av aviseringar i inställningarna.
|
||||
Vi respekterar tysta timmar (21:00-08:00).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="ghost" onClick={handleDismiss}>
|
||||
Inte nu
|
||||
</Button>
|
||||
<Button onClick={requestPermission} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Aktiverar...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bell className="mr-2 h-4 w-4" />
|
||||
Aktivera
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 VAPID key to Uint8Array for subscription
|
||||
*/
|
||||
function urlBase64ToUint8Array(base64String: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
|
||||
return outputArray.buffer
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Push Notifications Extension
|
||||
*
|
||||
* Converts system events into instant push notifications.
|
||||
* Also provides cron-based scheduling for time-dependent checks
|
||||
* (tax deadlines, invoice due/overdue).
|
||||
*
|
||||
* Event handlers follow the gate pattern:
|
||||
* check extension setting -> build payload -> send via unified pipeline
|
||||
*/
|
||||
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import type { Extension } from '@/lib/extensions/types'
|
||||
import type { EventPayload } from '@/lib/events/types'
|
||||
import { sendNotificationToUser } from './notification-sender'
|
||||
import {
|
||||
createPeriodLockedPayload,
|
||||
createYearClosedPayload,
|
||||
createInvoiceSentPayload,
|
||||
createReceiptExtractedPayload,
|
||||
createReceiptMatchedPayload,
|
||||
} from './payload-builders'
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
// ============================================================
|
||||
|
||||
export interface PushNotificationSettings {
|
||||
periodLockedEnabled: boolean
|
||||
periodYearClosedEnabled: boolean
|
||||
invoiceSentEnabled: boolean
|
||||
receiptExtractedEnabled: boolean
|
||||
receiptMatchedEnabled: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: PushNotificationSettings = {
|
||||
periodLockedEnabled: true,
|
||||
periodYearClosedEnabled: true,
|
||||
invoiceSentEnabled: false,
|
||||
receiptExtractedEnabled: true,
|
||||
receiptMatchedEnabled: true,
|
||||
}
|
||||
|
||||
export async function getSettings(userId: string): Promise<PushNotificationSettings> {
|
||||
const supabase = await createClient()
|
||||
|
||||
const { data } = await supabase
|
||||
.from('extension_data')
|
||||
.select('value')
|
||||
.eq('user_id', userId)
|
||||
.eq('extension_id', 'push-notifications')
|
||||
.eq('key', 'settings')
|
||||
.single()
|
||||
|
||||
if (!data?.value) return { ...DEFAULT_SETTINGS }
|
||||
|
||||
return { ...DEFAULT_SETTINGS, ...(data.value as Partial<PushNotificationSettings>) }
|
||||
}
|
||||
|
||||
export async function saveSettings(
|
||||
userId: string,
|
||||
partial: Partial<PushNotificationSettings>
|
||||
): Promise<PushNotificationSettings> {
|
||||
const current = await getSettings(userId)
|
||||
const merged = { ...current, ...partial }
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
await supabase
|
||||
.from('extension_data')
|
||||
.upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
extension_id: 'push-notifications',
|
||||
key: 'settings',
|
||||
value: merged,
|
||||
},
|
||||
{ onConflict: 'user_id,extension_id,key' }
|
||||
)
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event Handlers
|
||||
// ============================================================
|
||||
|
||||
async function handlePeriodLocked(
|
||||
payload: EventPayload<'period.locked'>
|
||||
): Promise<void> {
|
||||
const { period, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
if (!settings.periodLockedEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const notificationPayload = createPeriodLockedPayload(period.name, period.id)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
notificationPayload,
|
||||
'period_locked',
|
||||
period.id
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Period locked notification sent for ${period.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleYearClosed(
|
||||
payload: EventPayload<'period.year_closed'>
|
||||
): Promise<void> {
|
||||
const { period, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
if (!settings.periodYearClosedEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const notificationPayload = createYearClosedPayload(period.name, period.id)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
notificationPayload,
|
||||
'period_year_closed',
|
||||
period.id
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Year closed notification sent for ${period.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInvoiceSent(
|
||||
payload: EventPayload<'invoice.sent'>
|
||||
): Promise<void> {
|
||||
const { invoice, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
if (!settings.invoiceSentEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const notificationPayload = createInvoiceSentPayload(
|
||||
invoice.invoice_number,
|
||||
invoice.id
|
||||
)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
notificationPayload,
|
||||
'invoice_sent',
|
||||
invoice.id
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Invoice sent notification for #${invoice.invoice_number}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReceiptExtracted(
|
||||
payload: EventPayload<'receipt.extracted'>
|
||||
): Promise<void> {
|
||||
const { receipt, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
if (!settings.receiptExtractedEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const notificationPayload = createReceiptExtractedPayload(
|
||||
receipt.merchant_name,
|
||||
receipt.id
|
||||
)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
notificationPayload,
|
||||
'receipt_extracted',
|
||||
receipt.id
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Receipt extracted notification for ${receipt.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReceiptMatched(
|
||||
payload: EventPayload<'receipt.matched'>
|
||||
): Promise<void> {
|
||||
const { receipt, transaction, userId } = payload
|
||||
|
||||
const settings = await getSettings(userId)
|
||||
if (!settings.receiptMatchedEnabled) return
|
||||
|
||||
const supabase = await createClient()
|
||||
const notificationPayload = createReceiptMatchedPayload(receipt.id, transaction.id)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
notificationPayload,
|
||||
'receipt_matched',
|
||||
receipt.id
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
console.log(`[push-notifications] Receipt matched notification for ${receipt.id}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Extension Object
|
||||
// ============================================================
|
||||
|
||||
export const pushNotificationsExtension: Extension = {
|
||||
id: 'push-notifications',
|
||||
name: 'Push-notiser',
|
||||
version: '1.0.0',
|
||||
eventHandlers: [
|
||||
{ eventType: 'period.locked', handler: handlePeriodLocked },
|
||||
{ eventType: 'period.year_closed', handler: handleYearClosed },
|
||||
{ eventType: 'invoice.sent', handler: handleInvoiceSent },
|
||||
{ eventType: 'receipt.extracted', handler: handleReceiptExtracted },
|
||||
{ eventType: 'receipt.matched', handler: handleReceiptMatched },
|
||||
],
|
||||
settingsPanel: {
|
||||
label: 'Push-notiser',
|
||||
path: '/settings/extensions/push-notifications',
|
||||
},
|
||||
async onInstall(ctx) {
|
||||
await saveSettings(ctx.userId, DEFAULT_SETTINGS)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Cron-based notification scheduling.
|
||||
*
|
||||
* Handles time-dependent checks that cannot be event-driven:
|
||||
* - Tax deadlines approaching (7 days, 1 day, today)
|
||||
* - Invoice due/overdue reminders (3 days before, on due date, 3/7 days overdue)
|
||||
*
|
||||
* Both functions call `sendNotificationToUser()` from the sender module
|
||||
* instead of duplicating the send pipeline.
|
||||
*/
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { NotificationType } from '@/types'
|
||||
import { sendNotificationToUser } from './notification-sender'
|
||||
import {
|
||||
createTaxDeadlinePayload,
|
||||
createInvoiceOverduePayload,
|
||||
createInvoiceDuePayload,
|
||||
} from './payload-builders'
|
||||
|
||||
/**
|
||||
* Send tax deadline notifications.
|
||||
* Checks for deadlines due in 7 days, 1 day, or today.
|
||||
*/
|
||||
export async function sendTaxDeadlineNotifications(
|
||||
supabase: SupabaseClient
|
||||
): Promise<{ sent: number; skipped: number }> {
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const todayStr = today.toISOString().split('T')[0]
|
||||
|
||||
const in7Days = new Date(today)
|
||||
in7Days.setDate(in7Days.getDate() + 7)
|
||||
const in7DaysStr = in7Days.toISOString().split('T')[0]
|
||||
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(tomorrow.getDate() + 1)
|
||||
const tomorrowStr = tomorrow.toISOString().split('T')[0]
|
||||
|
||||
const { data: deadlines } = await supabase
|
||||
.from('deadlines')
|
||||
.select('id, user_id, title, due_date')
|
||||
.eq('deadline_type', 'tax')
|
||||
.eq('is_completed', false)
|
||||
.in('status', ['upcoming', 'action_needed'])
|
||||
.in('due_date', [in7DaysStr, tomorrowStr, todayStr])
|
||||
|
||||
if (!deadlines || deadlines.length === 0) {
|
||||
return { sent: 0, skipped: 0 }
|
||||
}
|
||||
|
||||
// Group by user
|
||||
const userDeadlines = new Map<string, typeof deadlines>()
|
||||
for (const deadline of deadlines) {
|
||||
const list = userDeadlines.get(deadline.user_id) || []
|
||||
list.push(deadline)
|
||||
userDeadlines.set(deadline.user_id, list)
|
||||
}
|
||||
|
||||
for (const [userId, userDls] of userDeadlines) {
|
||||
// Check user-level tax_deadlines_enabled
|
||||
const { data: settings } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('tax_deadlines_enabled')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (settings && !settings.tax_deadlines_enabled) {
|
||||
skipped += userDls.length
|
||||
continue
|
||||
}
|
||||
|
||||
for (const deadline of userDls) {
|
||||
const daysUntil = Math.ceil(
|
||||
(new Date(deadline.due_date).getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
|
||||
const payload = createTaxDeadlinePayload(
|
||||
deadline.title,
|
||||
deadline.due_date,
|
||||
daysUntil,
|
||||
deadline.id
|
||||
)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
payload,
|
||||
'tax_deadline',
|
||||
deadline.id,
|
||||
daysUntil
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
sent++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send invoice reminder notifications.
|
||||
* Checks for invoices due in 3 days, today, or overdue by 3/7 days.
|
||||
*/
|
||||
export async function sendInvoiceNotifications(
|
||||
supabase: SupabaseClient
|
||||
): Promise<{ sent: number; skipped: number }> {
|
||||
let sent = 0
|
||||
let skipped = 0
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const todayStr = today.toISOString().split('T')[0]
|
||||
|
||||
const in3Days = new Date(today)
|
||||
in3Days.setDate(in3Days.getDate() + 3)
|
||||
const in3DaysStr = in3Days.toISOString().split('T')[0]
|
||||
|
||||
const daysAgo3 = new Date(today)
|
||||
daysAgo3.setDate(daysAgo3.getDate() - 3)
|
||||
const daysAgo3Str = daysAgo3.toISOString().split('T')[0]
|
||||
|
||||
const daysAgo7 = new Date(today)
|
||||
daysAgo7.setDate(daysAgo7.getDate() - 7)
|
||||
const daysAgo7Str = daysAgo7.toISOString().split('T')[0]
|
||||
|
||||
const { data: invoices } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, user_id, invoice_number, total, due_date, customer:customers(name)')
|
||||
.in('status', ['sent', 'overdue'])
|
||||
.in('due_date', [in3DaysStr, todayStr, daysAgo3Str, daysAgo7Str])
|
||||
|
||||
if (!invoices || invoices.length === 0) {
|
||||
return { sent: 0, skipped: 0 }
|
||||
}
|
||||
|
||||
// Group by user
|
||||
const userInvoices = new Map<string, typeof invoices>()
|
||||
for (const invoice of invoices) {
|
||||
const list = userInvoices.get(invoice.user_id) || []
|
||||
list.push(invoice)
|
||||
userInvoices.set(invoice.user_id, list)
|
||||
}
|
||||
|
||||
for (const [userId, userInvs] of userInvoices) {
|
||||
// Check user-level invoice_reminders_enabled
|
||||
const { data: settings } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('invoice_reminders_enabled')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
if (settings && !settings.invoice_reminders_enabled) {
|
||||
skipped += userInvs.length
|
||||
continue
|
||||
}
|
||||
|
||||
for (const invoice of userInvs) {
|
||||
const dueDate = new Date(invoice.due_date)
|
||||
const daysUntil = Math.ceil(
|
||||
(dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
const isOverdue = daysUntil < 0
|
||||
const notificationType: NotificationType = isOverdue ? 'invoice_overdue' : 'invoice_due'
|
||||
|
||||
const customer = invoice.customer as unknown as { name: string } | null
|
||||
const customerName = customer?.name || 'Okänd kund'
|
||||
|
||||
const payload = isOverdue
|
||||
? createInvoiceOverduePayload(
|
||||
invoice.invoice_number,
|
||||
customerName,
|
||||
invoice.total,
|
||||
invoice.due_date,
|
||||
invoice.id
|
||||
)
|
||||
: createInvoiceDuePayload(
|
||||
invoice.invoice_number,
|
||||
customerName,
|
||||
invoice.total,
|
||||
invoice.due_date,
|
||||
invoice.id
|
||||
)
|
||||
|
||||
const result = await sendNotificationToUser(
|
||||
supabase,
|
||||
userId,
|
||||
payload,
|
||||
notificationType,
|
||||
invoice.id,
|
||||
Math.abs(daysUntil)
|
||||
)
|
||||
|
||||
if (result.sent) {
|
||||
sent++
|
||||
} else {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { sent, skipped }
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Unified push notification send pipeline.
|
||||
*
|
||||
* Both event handlers and the cron scheduler call `sendNotificationToUser()`.
|
||||
* This module owns the full chain:
|
||||
* settings check -> quiet hours -> duplicate check -> send -> log -> cleanup
|
||||
*/
|
||||
|
||||
import webpush from 'web-push'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { NotificationSettings, NotificationType } from '@/types'
|
||||
|
||||
// ============================================================
|
||||
// VAPID configuration
|
||||
// ============================================================
|
||||
|
||||
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY
|
||||
const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY
|
||||
const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:support@erp-base.se'
|
||||
|
||||
if (vapidPublicKey && vapidPrivateKey) {
|
||||
webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Types
|
||||
// ============================================================
|
||||
|
||||
export interface PushSubscriptionKeys {
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
}
|
||||
|
||||
export interface NotificationPayload {
|
||||
title: string
|
||||
body: string
|
||||
icon?: string
|
||||
badge?: string
|
||||
tag?: string
|
||||
data?: {
|
||||
url?: string
|
||||
type?: string
|
||||
id?: string
|
||||
}
|
||||
actions?: Array<{
|
||||
action: string
|
||||
title: string
|
||||
icon?: string
|
||||
}>
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Public API
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Get the public VAPID key for client-side subscription.
|
||||
*/
|
||||
export function getVapidPublicKey(): string | null {
|
||||
return vapidPublicKey || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to a specific user.
|
||||
*
|
||||
* Performs the full pipeline:
|
||||
* 1. Check push_enabled in notification_settings
|
||||
* 2. Check quiet hours
|
||||
* 3. Check duplicate via notification_log
|
||||
* 4. Get user's push subscriptions
|
||||
* 5. Send via web-push
|
||||
* 6. Log to notification_log
|
||||
* 7. Disable gone subscriptions (410)
|
||||
*
|
||||
* Returns { sent: boolean, reason?: string }
|
||||
*/
|
||||
export async function sendNotificationToUser(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
payload: NotificationPayload,
|
||||
notificationType: NotificationType,
|
||||
referenceId: string,
|
||||
daysBefore: number = 0
|
||||
): Promise<{ sent: boolean; reason?: string }> {
|
||||
try {
|
||||
// 1. Check notification settings
|
||||
const settings = await getUserNotificationSettings(supabase, userId)
|
||||
|
||||
if (settings && !settings.push_enabled) {
|
||||
return { sent: false, reason: 'push_disabled' }
|
||||
}
|
||||
|
||||
// 2. Check quiet hours
|
||||
if (settings && isWithinQuietHours(settings)) {
|
||||
return { sent: false, reason: 'quiet_hours' }
|
||||
}
|
||||
|
||||
// 3. Check duplicate
|
||||
const alreadySent = await wasNotificationSent(
|
||||
supabase,
|
||||
userId,
|
||||
notificationType,
|
||||
referenceId,
|
||||
daysBefore
|
||||
)
|
||||
if (alreadySent) {
|
||||
return { sent: false, reason: 'duplicate' }
|
||||
}
|
||||
|
||||
// 4. Get subscriptions
|
||||
const subscriptions = await getUserSubscriptions(supabase, userId)
|
||||
if (subscriptions.length === 0) {
|
||||
return { sent: false, reason: 'no_subscriptions' }
|
||||
}
|
||||
|
||||
// 5. Send
|
||||
const result = await sendPushToMultiple(subscriptions, payload)
|
||||
|
||||
// 6. Log
|
||||
await logNotification(supabase, userId, notificationType, referenceId, daysBefore)
|
||||
|
||||
// 7. Cleanup gone subscriptions
|
||||
if (result.goneSubscriptions.length > 0) {
|
||||
await disableGoneSubscriptions(supabase, result.goneSubscriptions)
|
||||
}
|
||||
|
||||
return result.successful > 0
|
||||
? { sent: true }
|
||||
: { sent: false, reason: 'send_failed' }
|
||||
} catch (error) {
|
||||
console.error(`[push-notifications] sendNotificationToUser failed for ${userId}:`, error)
|
||||
return { sent: false, reason: 'error' }
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Internal helpers
|
||||
// ============================================================
|
||||
|
||||
async function sendPushNotification(
|
||||
subscription: PushSubscriptionKeys,
|
||||
payload: NotificationPayload
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!vapidPublicKey || !vapidPrivateKey) {
|
||||
console.error('VAPID keys not configured')
|
||||
return { success: false, error: 'VAPID keys not configured' }
|
||||
}
|
||||
|
||||
const pushSubscription = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
await webpush.sendNotification(pushSubscription, JSON.stringify(payload), {
|
||||
TTL: 86400,
|
||||
urgency: 'normal',
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error: unknown) {
|
||||
const err = error as { statusCode?: number; message?: string }
|
||||
if (err.statusCode === 410) {
|
||||
return { success: false, error: 'subscription_gone' }
|
||||
}
|
||||
console.error('Push notification error:', err)
|
||||
return { success: false, error: err.message || 'Unknown error' }
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPushToMultiple(
|
||||
subscriptions: PushSubscriptionKeys[],
|
||||
payload: NotificationPayload
|
||||
): Promise<{ successful: number; failed: number; goneSubscriptions: string[] }> {
|
||||
let successful = 0
|
||||
let failed = 0
|
||||
const goneSubscriptions: string[] = []
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map((sub) => sendPushNotification(sub, payload))
|
||||
)
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled' && result.value.success) {
|
||||
successful++
|
||||
} else {
|
||||
failed++
|
||||
if (result.status === 'fulfilled' && result.value.error === 'subscription_gone') {
|
||||
goneSubscriptions.push(subscriptions[index].endpoint)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { successful, failed, goneSubscriptions }
|
||||
}
|
||||
|
||||
export function isWithinQuietHours(
|
||||
settings: NotificationSettings,
|
||||
now: Date = new Date()
|
||||
): boolean {
|
||||
const swedenTime = new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'Europe/Stockholm',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(now)
|
||||
|
||||
const [hours, minutes] = swedenTime.split(':').map(Number)
|
||||
const currentMinutes = hours * 60 + minutes
|
||||
|
||||
const [startHours, startMinutes] = settings.quiet_start.split(':').map(Number)
|
||||
const [endHours, endMinutes] = settings.quiet_end.split(':').map(Number)
|
||||
|
||||
const quietStartMinutes = startHours * 60 + startMinutes
|
||||
const quietEndMinutes = endHours * 60 + endMinutes
|
||||
|
||||
if (quietStartMinutes > quietEndMinutes) {
|
||||
return currentMinutes >= quietStartMinutes || currentMinutes < quietEndMinutes
|
||||
}
|
||||
|
||||
return currentMinutes >= quietStartMinutes && currentMinutes < quietEndMinutes
|
||||
}
|
||||
|
||||
async function wasNotificationSent(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
notificationType: NotificationType,
|
||||
referenceId: string,
|
||||
daysBefore: number
|
||||
): Promise<boolean> {
|
||||
const { data } = await supabase
|
||||
.from('notification_log')
|
||||
.select('id')
|
||||
.eq('user_id', userId)
|
||||
.eq('notification_type', notificationType)
|
||||
.eq('reference_id', referenceId)
|
||||
.eq('days_before', daysBefore)
|
||||
.single()
|
||||
|
||||
return !!data
|
||||
}
|
||||
|
||||
async function logNotification(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
notificationType: NotificationType,
|
||||
referenceId: string,
|
||||
daysBefore: number
|
||||
): Promise<void> {
|
||||
await supabase.from('notification_log').insert({
|
||||
user_id: userId,
|
||||
notification_type: notificationType,
|
||||
reference_id: referenceId,
|
||||
days_before: daysBefore,
|
||||
delivery_status: 'sent',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getUserSubscriptions(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<PushSubscriptionKeys[]> {
|
||||
const { data } = await supabase
|
||||
.from('push_subscriptions')
|
||||
.select('endpoint, p256dh, auth')
|
||||
.eq('user_id', userId)
|
||||
.eq('is_active', true)
|
||||
|
||||
return data || []
|
||||
}
|
||||
|
||||
export async function getUserNotificationSettings(
|
||||
supabase: SupabaseClient,
|
||||
userId: string
|
||||
): Promise<NotificationSettings | null> {
|
||||
const { data } = await supabase
|
||||
.from('notification_settings')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.single()
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async function disableGoneSubscriptions(
|
||||
supabase: SupabaseClient,
|
||||
endpoints: string[]
|
||||
): Promise<void> {
|
||||
if (endpoints.length === 0) return
|
||||
|
||||
await supabase
|
||||
.from('push_subscriptions')
|
||||
.update({ is_active: false })
|
||||
.in('endpoint', endpoints)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Notification payload constructors for all push notification types.
|
||||
*
|
||||
* Centralises every notification's title/body/icon/action in one place.
|
||||
* Both event handlers and the cron scheduler import from here.
|
||||
*/
|
||||
|
||||
import type { NotificationPayload } from './notification-sender'
|
||||
|
||||
// ============================================================
|
||||
// Helpers
|
||||
// ============================================================
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('sv-SE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Event-driven payloads
|
||||
// ============================================================
|
||||
|
||||
export function createPeriodLockedPayload(
|
||||
periodName: string,
|
||||
periodId: string
|
||||
): NotificationPayload {
|
||||
return {
|
||||
title: 'Period låst',
|
||||
body: `${periodName} har låsts`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `period-locked-${periodId}`,
|
||||
data: {
|
||||
url: '/periods',
|
||||
type: 'period_locked',
|
||||
id: periodId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createYearClosedPayload(
|
||||
periodName: string,
|
||||
periodId: string
|
||||
): NotificationPayload {
|
||||
return {
|
||||
title: 'Årsbokslut klart',
|
||||
body: `Årsbokslut klart för ${periodName}`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `year-closed-${periodId}`,
|
||||
data: {
|
||||
url: '/periods',
|
||||
type: 'period_year_closed',
|
||||
id: periodId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createInvoiceSentPayload(
|
||||
invoiceNumber: string,
|
||||
invoiceId: string
|
||||
): NotificationPayload {
|
||||
return {
|
||||
title: 'Faktura skickad',
|
||||
body: `Faktura ${invoiceNumber} skickad`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `invoice-sent-${invoiceId}`,
|
||||
data: {
|
||||
url: `/invoices/${invoiceId}`,
|
||||
type: 'invoice_sent',
|
||||
id: invoiceId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createReceiptExtractedPayload(
|
||||
merchantName: string | null,
|
||||
receiptId: string
|
||||
): NotificationPayload {
|
||||
const merchant = merchantName || 'Okänd butik'
|
||||
return {
|
||||
title: 'Kvitto analyserat',
|
||||
body: `Kvitto analyserat: ${merchant}`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `receipt-extracted-${receiptId}`,
|
||||
data: {
|
||||
url: '/receipts',
|
||||
type: 'receipt_extracted',
|
||||
id: receiptId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createReceiptMatchedPayload(
|
||||
receiptId: string,
|
||||
transactionId: string
|
||||
): NotificationPayload {
|
||||
return {
|
||||
title: 'Kvitto matchat',
|
||||
body: 'Kvitto matchat mot transaktion',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `receipt-matched-${receiptId}`,
|
||||
data: {
|
||||
url: '/receipts',
|
||||
type: 'receipt_matched',
|
||||
id: receiptId,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Cron-based payloads (moved from lib/push/web-push.ts)
|
||||
// ============================================================
|
||||
|
||||
export function createTaxDeadlinePayload(
|
||||
title: string,
|
||||
dueDate: string,
|
||||
daysUntil: number,
|
||||
deadlineId: string
|
||||
): NotificationPayload {
|
||||
const urgencyText =
|
||||
daysUntil === 0 ? 'IDAG!' : daysUntil === 1 ? 'imorgon' : `om ${daysUntil} dagar`
|
||||
|
||||
return {
|
||||
title: `${title}`,
|
||||
body: `Deadline ${urgencyText} (${formatDate(dueDate)})`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `tax-deadline-${deadlineId}`,
|
||||
data: {
|
||||
url: '/calendar',
|
||||
type: 'tax_deadline',
|
||||
id: deadlineId,
|
||||
},
|
||||
actions: [
|
||||
{ action: 'view', title: 'Visa' },
|
||||
{ action: 'dismiss', title: 'Avfärda' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function createInvoiceOverduePayload(
|
||||
invoiceNumber: string,
|
||||
customerName: string,
|
||||
amount: number,
|
||||
dueDate: string,
|
||||
invoiceId: string
|
||||
): NotificationPayload {
|
||||
return {
|
||||
title: `Obetald faktura #${invoiceNumber}`,
|
||||
body: `${customerName} - ${amount.toLocaleString('sv-SE')} kr (förföll ${formatDate(dueDate)})`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `invoice-${invoiceId}`,
|
||||
data: {
|
||||
url: `/invoices/${invoiceId}`,
|
||||
type: 'invoice_overdue',
|
||||
id: invoiceId,
|
||||
},
|
||||
actions: [{ action: 'view', title: 'Visa faktura' }],
|
||||
}
|
||||
}
|
||||
|
||||
export function createInvoiceDuePayload(
|
||||
invoiceNumber: string,
|
||||
customerName: string,
|
||||
amount: number,
|
||||
dueDate: string,
|
||||
invoiceId: string
|
||||
): NotificationPayload {
|
||||
return {
|
||||
title: `Faktura #${invoiceNumber} förfaller`,
|
||||
body: `${customerName} - ${amount.toLocaleString('sv-SE')} kr (${formatDate(dueDate)})`,
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/badge-72.png',
|
||||
tag: `invoice-${invoiceId}`,
|
||||
data: {
|
||||
url: `/invoices/${invoiceId}`,
|
||||
type: 'invoice_due',
|
||||
id: invoiceId,
|
||||
},
|
||||
actions: [{ action: 'view', title: 'Visa faktura' }],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Push subscription for Web Push API
|
||||
export interface PushSubscription {
|
||||
id: string
|
||||
user_id: string
|
||||
endpoint: string
|
||||
p256dh: string
|
||||
auth: string
|
||||
user_agent: string | null
|
||||
is_active: boolean
|
||||
last_used_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Notification settings per user
|
||||
export interface NotificationSettings {
|
||||
id: string
|
||||
user_id: string
|
||||
tax_deadlines_enabled: boolean
|
||||
invoice_reminders_enabled: boolean
|
||||
quiet_start: string // time format "HH:MM"
|
||||
quiet_end: string // time format "HH:MM"
|
||||
email_enabled: boolean
|
||||
push_enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// Notification type for logging
|
||||
export type NotificationType =
|
||||
| 'tax_deadline'
|
||||
| 'invoice_due'
|
||||
| 'invoice_overdue'
|
||||
| 'period_locked'
|
||||
| 'period_year_closed'
|
||||
| 'receipt_extracted'
|
||||
| 'receipt_matched'
|
||||
| 'invoice_sent'
|
||||
|
||||
// Notification log entry
|
||||
export interface NotificationLog {
|
||||
id: string
|
||||
user_id: string
|
||||
notification_type: NotificationType
|
||||
reference_id: string
|
||||
days_before: number
|
||||
sent_at: string
|
||||
delivery_status: 'sent' | 'delivered' | 'failed'
|
||||
}
|
||||
Reference in New Issue
Block a user