0aecf5b41c
Remove influencer-specific features (campaigns, TikTok, gifts, shadow ledger, contracts, briefings) and consolidate into a clean ERP foundation with core bookkeeping, invoicing, receipts, tax reporting, and calendar functionality. Reorganize database migrations into a clean numbered sequence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
204 lines
4.8 KiB
TypeScript
204 lines
4.8 KiB
TypeScript
/**
|
|
* Web Push configuration and sending utilities
|
|
*/
|
|
|
|
import webpush from 'web-push'
|
|
|
|
// Configure web-push with VAPID keys
|
|
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)
|
|
}
|
|
|
|
/**
|
|
* Push subscription keys from the browser
|
|
*/
|
|
export interface PushSubscriptionKeys {
|
|
endpoint: string
|
|
p256dh: string
|
|
auth: string
|
|
}
|
|
|
|
/**
|
|
* Notification payload
|
|
*/
|
|
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
|
|
}>
|
|
}
|
|
|
|
/**
|
|
* Send a push notification to a subscription
|
|
*/
|
|
export 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, // 24 hours
|
|
urgency: 'normal',
|
|
}
|
|
)
|
|
return { success: true }
|
|
} catch (error: unknown) {
|
|
const err = error as { statusCode?: number; message?: string }
|
|
|
|
// Handle subscription gone (user unsubscribed)
|
|
if (err.statusCode === 410) {
|
|
return { success: false, error: 'subscription_gone' }
|
|
}
|
|
|
|
console.error('Push notification error:', err)
|
|
return { success: false, error: err.message || 'Unknown error' }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send push notifications to multiple subscriptions
|
|
*/
|
|
export 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 }
|
|
}
|
|
|
|
/**
|
|
* Get the public VAPID key for client-side subscription
|
|
*/
|
|
export function getVapidPublicKey(): string | null {
|
|
return vapidPublicKey || null
|
|
}
|
|
|
|
/**
|
|
* Create notification payload for tax deadline
|
|
*/
|
|
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' },
|
|
],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create notification payload for invoice reminder
|
|
*/
|
|
export function createInvoicePayload(
|
|
invoiceNumber: string,
|
|
customerName: string,
|
|
amount: number,
|
|
dueDate: string,
|
|
isOverdue: boolean,
|
|
invoiceId: string
|
|
): NotificationPayload {
|
|
const title = isOverdue
|
|
? `⚠️ Obetald faktura #${invoiceNumber}`
|
|
: `💰 Faktura #${invoiceNumber} förfaller`
|
|
|
|
const body = isOverdue
|
|
? `${customerName} - ${amount.toLocaleString('sv-SE')} kr (förföll ${formatDate(dueDate)})`
|
|
: `${customerName} - ${amount.toLocaleString('sv-SE')} kr (${formatDate(dueDate)})`
|
|
|
|
return {
|
|
title,
|
|
body,
|
|
icon: '/icons/icon-192.png',
|
|
badge: '/icons/badge-72.png',
|
|
tag: `invoice-${invoiceId}`,
|
|
data: {
|
|
url: `/invoices/${invoiceId}`,
|
|
type: isOverdue ? 'invoice_overdue' : 'invoice_due',
|
|
id: invoiceId,
|
|
},
|
|
actions: [
|
|
{ action: 'view', title: 'Visa faktura' },
|
|
],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format date for notifications
|
|
*/
|
|
function formatDate(dateStr: string): string {
|
|
const date = new Date(dateStr)
|
|
return date.toLocaleDateString('sv-SE', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
})
|
|
}
|