chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n namespaces and 4 unused dependencies; fold byte-identical helper copies into one canonical home each (lib/utils chunk/sleep/utcDateStamp, lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format, lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body + v1ValidationError rolled out to ~55 v1 routes, booking-template schemas). No behaviour change: v1 bodies and status codes, MCP tool schemas, DB writes and money math are untouched. Naive ore rounding was deliberately not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list of things left alone on purpose. tsc, lint, 19588 unit tests and check:guards green; antipattern baseline ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(transactions): import RawTransaction from @/types after the ingest re-export removal CI's type ratchet (check:types, full tsconfig) caught the one test file that still imported the type through lib/transactions/ingest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
18cbc4c30a
commit
f266c386f3
@@ -8,13 +8,9 @@
|
||||
import type {
|
||||
ArcimProvider,
|
||||
ConsentRecord,
|
||||
OtcResponse,
|
||||
PaginatedResponse,
|
||||
CompanyInformationDto,
|
||||
CustomerDto,
|
||||
SupplierDto,
|
||||
SalesInvoiceDto,
|
||||
SupplierInvoiceDto,
|
||||
} from '../types'
|
||||
|
||||
function getBaseUrl(): string {
|
||||
@@ -105,70 +101,6 @@ export async function getConsent(consentId: string): Promise<ConsentRecord> {
|
||||
return request<ConsentRecord>(`/api/v1/consents/${consentId}`)
|
||||
}
|
||||
|
||||
export async function generateOtc(
|
||||
consentId: string,
|
||||
expiresInMinutes: number = 60
|
||||
): Promise<OtcResponse> {
|
||||
return request<OtcResponse>(`/api/v1/consents/${consentId}/otc`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ expiresInMinutes }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteConsent(consentId: string): Promise<void> {
|
||||
await request(`/api/v1/consents/${consentId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ── OAuth helpers ───────────────────────────────────────────────────
|
||||
|
||||
export async function getAuthUrl(
|
||||
provider: ArcimProvider,
|
||||
state?: string,
|
||||
redirectUri?: string
|
||||
): Promise<{ url: string }> {
|
||||
const params = new URLSearchParams()
|
||||
if (state) params.set('state', state)
|
||||
if (redirectUri) params.set('redirectUri', redirectUri)
|
||||
const qs = params.toString()
|
||||
return request<{ url: string }>(`/api/v1/auth/${provider}/url${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function exchangeAuthToken(
|
||||
consentId: string,
|
||||
provider: ArcimProvider,
|
||||
otcCode: string,
|
||||
oauthCode: string,
|
||||
redirectUri?: string
|
||||
): Promise<{ success: boolean; consentId: string }> {
|
||||
return request(`/api/v1/auth/${provider}/callback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: oauthCode,
|
||||
consentId,
|
||||
otcCode,
|
||||
...(redirectUri ? { redirectUri } : {}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Token-based auth (Bokio, Björn Lundén, Briox) ──────────────────
|
||||
|
||||
export async function submitProviderToken(
|
||||
consentId: string,
|
||||
provider: ArcimProvider,
|
||||
apiToken: string,
|
||||
companyId?: string
|
||||
): Promise<{ success: boolean; consentId: string }> {
|
||||
return request(`/api/v1/auth/${provider}/callback`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: apiToken,
|
||||
consentId,
|
||||
...(companyId ? { companyId } : {}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Resource fetching (paginated) ───────────────────────────────────
|
||||
|
||||
async function fetchAllPages<T>(
|
||||
@@ -215,24 +147,6 @@ export async function fetchCustomers(consentId: string): Promise<CustomerDto[]>
|
||||
return fetchAllPages<CustomerDto>(consentId, 'customers')
|
||||
}
|
||||
|
||||
export async function fetchSuppliers(consentId: string): Promise<SupplierDto[]> {
|
||||
return fetchAllPages<SupplierDto>(consentId, 'suppliers')
|
||||
}
|
||||
|
||||
export async function fetchSalesInvoices(
|
||||
consentId: string,
|
||||
params?: Record<string, string>
|
||||
): Promise<SalesInvoiceDto[]> {
|
||||
return fetchAllPages<SalesInvoiceDto>(consentId, 'salesinvoices', params)
|
||||
}
|
||||
|
||||
export async function fetchSupplierInvoices(
|
||||
consentId: string,
|
||||
params?: Record<string, string>
|
||||
): Promise<SupplierInvoiceDto[]> {
|
||||
return fetchAllPages<SupplierInvoiceDto>(consentId, 'supplierinvoices', params)
|
||||
}
|
||||
|
||||
// The gateway SIE export path (fetchSIEExport/SIEExportFile) was deliberately
|
||||
// removed: it returned SIE as a pre-decoded string, and the gateway's decode of
|
||||
// CP437 bytes as windows-1252 caused the 2026-03-17 mojibake incident. Provider
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* entities completes in a handful of Supabase requests per step.
|
||||
*/
|
||||
|
||||
import { chunk } from '@/lib/utils'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { MigrationProgress, MigrationResults, MigrationStepError, SkipReasons } from '../types'
|
||||
import type { ProviderName } from '@/lib/providers/types'
|
||||
@@ -90,12 +91,6 @@ function emitProgress(options: MigrationOptions, progress: MigrationProgress) {
|
||||
options.onProgress?.(progress)
|
||||
}
|
||||
|
||||
function chunk<T>(arr: T[], size: number): T[][] {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Codes that doom every remaining provider call in this run: dead/expired
|
||||
* grant, missing integration license, API module not activated. Continuing
|
||||
|
||||
@@ -99,13 +99,7 @@ export class ProviderCompanyMismatchError extends Error {
|
||||
|
||||
// Re-export data fetching functions from the provider layer
|
||||
export { resolveConsent } from '@/lib/providers/resolve-consent'
|
||||
export {
|
||||
fetchCompanyInfoDirect,
|
||||
fetchCustomersDirect,
|
||||
fetchSuppliersDirect,
|
||||
fetchSalesInvoicesDirect,
|
||||
fetchSupplierInvoicesDirect,
|
||||
} from '@/lib/providers/provider-data-fetcher'
|
||||
export { fetchCompanyInfoDirect } from '@/lib/providers/provider-data-fetcher'
|
||||
|
||||
// ── Consent lifecycle (direct Supabase) ─────────────────────────────
|
||||
|
||||
|
||||
@@ -7,30 +7,17 @@
|
||||
|
||||
import type { HydrationReport } from '@/lib/providers/provider-data-fetcher'
|
||||
|
||||
// Re-export canonical DTOs used by entity-mapper and migration-orchestrator
|
||||
// Re-export the canonical DTOs used by arcim-client (entity-mapper imports
|
||||
// its DTOs straight from '@/lib/providers/dto')
|
||||
export type {
|
||||
AmountType,
|
||||
PostalAddress,
|
||||
Contact,
|
||||
PartyIdentification,
|
||||
PartyLegalEntity,
|
||||
PartyDto,
|
||||
PaginatedResponse,
|
||||
TaxSubtotalDto,
|
||||
TaxTotalDto,
|
||||
LegalMonetaryTotalDto,
|
||||
PaymentStatusDto,
|
||||
CompanyInformationDto,
|
||||
CustomerDto,
|
||||
SupplierDto,
|
||||
InvoiceStatusCode,
|
||||
SalesInvoiceLineDto,
|
||||
SalesInvoiceDto,
|
||||
SupplierInvoiceLineDto,
|
||||
SupplierInvoiceDto,
|
||||
} from '@/lib/providers/dto'
|
||||
|
||||
export type { CustomerType as ArcimCustomerType } from '@/lib/providers/dto'
|
||||
|
||||
// ── Supported providers ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -90,14 +90,6 @@ export interface KontrolleraSvar {
|
||||
|
||||
// ---- inlamning (v2.1) ------------------------------------------------------
|
||||
|
||||
export interface InlamningBegaran {
|
||||
undertecknare: string
|
||||
epostadresser: string[]
|
||||
kvittensepostadresser?: string[]
|
||||
notifieringEpostadresser?: string[]
|
||||
handling: { fil: string; typ: HandlingTyp }
|
||||
}
|
||||
|
||||
export interface InlamningSvar {
|
||||
orgnr: string
|
||||
avsandare: string
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Invoice } from '@/types'
|
||||
import { calculatePeriodSummary, SWEDISH_MONTHS } from '@/lib/calendar/utils'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { TrendingUp, Clock, CheckCircle, AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface PaymentSummaryCardProps {
|
||||
invoices: Invoice[]
|
||||
year: number
|
||||
month: number
|
||||
}
|
||||
|
||||
export function PaymentSummaryCard({ invoices, year, month }: PaymentSummaryCardProps) {
|
||||
const summary = calculatePeriodSummary(invoices)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-lg">
|
||||
Sammanfattning {SWEDISH_MONTHS[month]} {year}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Expected */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Förväntat</p>
|
||||
<p className="text-sm font-medium">{summary.pendingCount} fakturor</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg font-semibold">{formatCurrency(summary.totalExpected)}</p>
|
||||
</div>
|
||||
|
||||
{/* Overdue */}
|
||||
{summary.overdueCount > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
|
||||
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Förfallen</p>
|
||||
<p className="text-sm font-medium text-destructive">
|
||||
{summary.overdueCount} fakturor
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-destructive">
|
||||
{formatCurrency(summary.totalOverdue)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paid */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
|
||||
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Betald</p>
|
||||
<p className="text-sm font-medium">{summary.paidCount} fakturor</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-success">
|
||||
{formatCurrency(summary.totalPaid)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pending */}
|
||||
{summary.pendingCount > 0 && summary.overdueCount < summary.pendingCount && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-lg bg-muted flex items-center justify-center">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Väntande</p>
|
||||
<p className="text-sm font-medium">
|
||||
{summary.pendingCount - summary.overdueCount} fakturor
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg font-semibold">
|
||||
{formatCurrency(summary.totalExpected - summary.totalOverdue)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Foreign-currency invoices without a stored SEK conversion are
|
||||
excluded from the sums above rather than silently mixed in. */}
|
||||
{summary.unconvertedCount > 0 && (
|
||||
<p className="text-xs text-warning">
|
||||
{summary.unconvertedCount === 1
|
||||
? '1 faktura i utländsk valuta utan växelkurs ingår inte i beloppen.'
|
||||
: `${summary.unconvertedCount} fakturor i utländsk valuta utan växelkurs ingår inte i beloppen.`}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { resolveMemberEmail } from '@/lib/notifications/member-email'
|
||||
import { escapeHtml } from '@/lib/email/user-text'
|
||||
|
||||
const log = createLogger('cloud-backup-alert')
|
||||
|
||||
@@ -127,11 +128,3 @@ async function fetchCompanyName(
|
||||
return (data?.company_name as string) || 'ditt företag'
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ import type {
|
||||
CloudLastSync,
|
||||
} from '../types'
|
||||
|
||||
export { ROOT_FOLDER_NAME } from './folder-names'
|
||||
|
||||
/**
|
||||
* Google Drive's storage keys, re-exported for the call sites that predate
|
||||
* multi-provider support. Provider-aware code reads `provider.keys` instead:
|
||||
@@ -27,7 +25,6 @@ export { ROOT_FOLDER_NAME } from './folder-names'
|
||||
*/
|
||||
export const CONNECTION_KEY = 'google_drive_connection'
|
||||
export const LAST_SYNC_KEY = 'google_drive_last_sync'
|
||||
export const SCHEDULE_KEY = 'google_drive_schedule'
|
||||
/**
|
||||
* Per-FILE ceiling, not per-backup: the backup splits into one archive per
|
||||
* räkenskapsår plus Grunddata.zip, and uploads are resumable/chunked. The
|
||||
|
||||
@@ -75,9 +75,6 @@ export interface CloudFileState {
|
||||
uploaded_at: string
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link CloudFileState}. Kept for existing call sites. */
|
||||
export type DriveFileState = CloudFileState
|
||||
|
||||
/**
|
||||
* Last-sync snapshot stored under key `google_drive_last_sync` /
|
||||
* `dropbox_last_sync`.
|
||||
|
||||
@@ -145,15 +145,6 @@ export interface TransactionsResponse {
|
||||
*/
|
||||
export type TransactionsFetchStrategy = 'default' | 'longest'
|
||||
|
||||
// Legacy types for backward compatibility
|
||||
export interface Bank {
|
||||
id: string
|
||||
name: string
|
||||
bic?: string
|
||||
countries: string[]
|
||||
logo_url?: string
|
||||
}
|
||||
|
||||
export interface BankTransaction {
|
||||
id: string
|
||||
date: string
|
||||
@@ -456,32 +447,6 @@ export async function getPreferredAuthMethod(
|
||||
return method?.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of supported banks (legacy format for backward compatibility)
|
||||
*/
|
||||
export async function getSupportedBanks(): Promise<Bank[]> {
|
||||
try {
|
||||
const aspsps = await getASPSPs('SE')
|
||||
|
||||
return aspsps.map((aspsp) => ({
|
||||
id: `${aspsp.name.toLowerCase().replace(/\s+/g, '-')}-se`,
|
||||
name: aspsp.name,
|
||||
bic: aspsp.bic,
|
||||
countries: [aspsp.country],
|
||||
logo_url: aspsp.logo,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error fetching banks:', error)
|
||||
// Return fallback list
|
||||
return [
|
||||
{ id: 'nordea-se', name: 'Nordea', bic: 'NDEASESS', countries: ['SE'] },
|
||||
{ id: 'seb-se', name: 'SEB', bic: 'ESSESESS', countries: ['SE'] },
|
||||
{ id: 'swedbank-se', name: 'Swedbank', bic: 'SWEDSESS', countries: ['SE'] },
|
||||
{ id: 'handelsbanken-se', name: 'Handelsbanken', bic: 'HANDSESS', countries: ['SE'] },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start bank authorization flow
|
||||
*
|
||||
@@ -1171,19 +1136,6 @@ export function convertTransaction(tx: Transaction, accountCurrency: string): Ba
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get transactions in legacy format
|
||||
*/
|
||||
export async function getTransactions(
|
||||
accountUid: string,
|
||||
fromDate?: string,
|
||||
toDate?: string,
|
||||
accountCurrency: string = 'SEK'
|
||||
): Promise<BankTransaction[]> {
|
||||
const transactions = await getAllTransactions(accountUid, fromDate, toDate)
|
||||
return transactions.map(tx => convertTransaction(tx, accountCurrency))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current configuration targets the sandbox API
|
||||
*/
|
||||
|
||||
@@ -7,8 +7,4 @@
|
||||
* it here so its own imports and tests are unchanged. The re-import direction
|
||||
* (extension -> lib) is the allowed one.
|
||||
*/
|
||||
export {
|
||||
generateJWT,
|
||||
getAuthorizationHeader,
|
||||
_resetTokenCache,
|
||||
} from '@/lib/connect/upstreams/enable-banking-jwt'
|
||||
export { getAuthorizationHeader, _resetTokenCache } from '@/lib/connect/upstreams/enable-banking-jwt'
|
||||
|
||||
@@ -42,18 +42,6 @@ export interface StoredAccount {
|
||||
deselected_elsewhere?: boolean
|
||||
}
|
||||
|
||||
// Re-export API types from the client
|
||||
export type {
|
||||
ASPSP,
|
||||
AuthMethod,
|
||||
AuthResponse,
|
||||
SessionResponse,
|
||||
AccountInfo,
|
||||
Balance,
|
||||
BalanceResponse,
|
||||
Transaction as EnableBankingTransaction,
|
||||
TransactionsResponse,
|
||||
TransactionsFetchStrategy,
|
||||
Bank,
|
||||
BankTransaction,
|
||||
} from './lib/api-client'
|
||||
// Re-exported from the client for lib/sync.ts; every other api-client type
|
||||
// is imported from './lib/api-client' directly.
|
||||
export type { TransactionsFetchStrategy } from './lib/api-client'
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Resend } from 'resend'
|
||||
import type { EmailReceivedEvent, GetReceivingEmailResponseSuccess, WebhookEventPayload } from 'resend'
|
||||
|
||||
export type ResendInboundEvent = EmailReceivedEvent
|
||||
|
||||
export type ResendReceivedEmail = GetReceivingEmailResponseSuccess
|
||||
|
||||
export interface ResendAttachmentDownload {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { matchSupplierId } from '@/lib/suppliers/match-supplier'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
import { PDFDocument } from 'pdf-lib'
|
||||
import path from 'node:path'
|
||||
import { escapeHtml } from '@/lib/email/user-text'
|
||||
|
||||
// Verdicts decidable before touching the file. `ai_unconfigured` is the
|
||||
// deployment-level "no AI backend" state (self-host without a key), distinct
|
||||
@@ -139,15 +140,6 @@ export const UPLOAD_ALLOWED_MIME_TYPES = new Set([
|
||||
// inbound webhook, where the preview surfaces render it fully sandboxed.
|
||||
export const EMAIL_ALLOWED_MIME_TYPES = new Set([...UPLOAD_ALLOWED_MIME_TYPES, 'text/html'])
|
||||
|
||||
function escapeHtml(raw: string): string {
|
||||
return raw
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap HTML in a minimal document shell unless it already is a full document.
|
||||
* Mail bodies are usually fragments (a bare <div>/<table>); the document
|
||||
|
||||
@@ -34,13 +34,13 @@ export function isOptionalCompanyTool(toolName: string): boolean {
|
||||
return OPTIONAL_COMPANY_TOOLS.has(toolName)
|
||||
}
|
||||
|
||||
export const COMPANY_ID_INPUT_PROPERTY = {
|
||||
const COMPANY_ID_INPUT_PROPERTY = {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'Target company ID. Omit for default.',
|
||||
} as const
|
||||
|
||||
export interface McpCompanyContext {
|
||||
interface McpCompanyContext {
|
||||
companyId: string
|
||||
role: CompanyRole
|
||||
isDefault: boolean
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,11 +26,6 @@ export const workflowSkills: Skill[] = [
|
||||
onboardingSkill,
|
||||
]
|
||||
|
||||
/** @deprecated Use `workflowSkills` for the static set, or `loadAllSkills(supabase)`
|
||||
* for the unified list (workflows + atoms). Kept for backwards compatibility
|
||||
* with prior imports. */
|
||||
export const skills = workflowSkills
|
||||
|
||||
/**
|
||||
* Resolve a skill by slug. Checks the static workflow array first (synchronous,
|
||||
* always available), then falls back to the registry-backed atom set
|
||||
|
||||
@@ -1,386 +0,0 @@
|
||||
'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, Mail, 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)
|
||||
|
||||
// Notification settings are per user, not per company: quiet hours and the
|
||||
// per-category toggles belong to the person, and push_subscriptions are
|
||||
// keyed on a device endpoint. Neither table carries a company_id.
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
setIsLoading(false)
|
||||
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/ext/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/ext/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/ext/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>
|
||||
|
||||
{/* Email digest */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
E-post
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Sammanfattningar via e-post
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="email-digest">Nytt att bokföra</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Dagligt mejl när nya banktransaktioner eller underlag har kommit in
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="email-digest"
|
||||
checked={settings.email_digest_enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting('email_digest_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
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
'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/ext/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/ext/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
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// Re-export from canonical source: kept for backwards compatibility with extension-internal imports
|
||||
export type { PushSubscription, NotificationSettings, NotificationType, NotificationLog } from '@/types'
|
||||
@@ -1,5 +1,6 @@
|
||||
import { isUnsafeUrlError, safeFetch } from '@/lib/http/safe-fetch'
|
||||
import type { ShopifyOrder, ShopifyShopInfo } from '../types'
|
||||
import { sleep } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Minimal Shopify GraphQL Admin API client for the order feed.
|
||||
@@ -141,10 +142,6 @@ function asTerminalGuardError(err: unknown): ShopifyApiError | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function postJson(url: string, body: unknown, headers: Record<string, string>) {
|
||||
// safeFetch: public address only (checked now, not at connect time), no
|
||||
// redirects. A 3xx from the host is a failure, never a hop.
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
* because component logic has no tests in this repo.
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import type { ActionFailure } from '@/lib/browser/action-failure'
|
||||
import {
|
||||
panelRequest,
|
||||
type PanelRequestOptions,
|
||||
type PanelRequestResult,
|
||||
} from '@/lib/browser/panel-request'
|
||||
|
||||
/**
|
||||
* Deadline for the quick calls (status, toggle, disconnect). Connect probes
|
||||
@@ -27,77 +29,14 @@ export const SHOPIFY_CONNECT_TIMEOUT_MS = 120_000
|
||||
*/
|
||||
export const SHOPIFY_SYNC_TIMEOUT_MS = 310_000
|
||||
|
||||
export type ShopifyRequestResult<T> =
|
||||
/** 2xx. `data` is null when the body was not readable JSON. */
|
||||
| { ok: true; data: T | null }
|
||||
| ActionFailure
|
||||
export type ShopifyRequestResult<T> = PanelRequestResult<T>
|
||||
export type ShopifyRequestOptions = PanelRequestOptions
|
||||
|
||||
export interface ShopifyRequestOptions {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'DELETE'
|
||||
body?: unknown
|
||||
locale?: ErrorLocale
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** The one sentence for a non-2xx; route copy wins over the generic map. */
|
||||
export function serverErrorMessage(
|
||||
body: unknown,
|
||||
status: number,
|
||||
locale: ErrorLocale,
|
||||
): string {
|
||||
if (isRecord(body)) {
|
||||
if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) {
|
||||
return body.error_en.trim()
|
||||
}
|
||||
if (typeof body.error === 'string' && body.error.trim()) {
|
||||
return body.error.trim()
|
||||
}
|
||||
}
|
||||
return getErrorMessage(body, { statusCode: status, locale })
|
||||
}
|
||||
export { serverErrorMessage } from '@/lib/browser/panel-request'
|
||||
|
||||
/** Call one of the panel's endpoints and report exactly why it failed. */
|
||||
export async function shopifyRequest<T>({
|
||||
url,
|
||||
method = 'POST',
|
||||
body,
|
||||
locale = 'sv',
|
||||
timeoutMs = SHOPIFY_ACTION_TIMEOUT_MS,
|
||||
}: ShopifyRequestOptions): Promise<ShopifyRequestResult<T>> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
url,
|
||||
body === undefined
|
||||
? { method }
|
||||
: {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
{ timeoutMs, description: `${method} ${url}` },
|
||||
)
|
||||
|
||||
const payload = await res.json().catch(() => null)
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: res.status,
|
||||
message: serverErrorMessage(payload, res.status, locale),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, data: payload as T | null }
|
||||
} catch (err) {
|
||||
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
|
||||
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
|
||||
}
|
||||
export function shopifyRequest<T>(options: ShopifyRequestOptions): Promise<ShopifyRequestResult<T>> {
|
||||
return panelRequest<T>({ timeoutMs: SHOPIFY_ACTION_TIMEOUT_MS, ...options })
|
||||
}
|
||||
|
||||
/** Success body of POST /api/extensions/ext/shopify/sync. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sleep } from '@/lib/utils'
|
||||
import crypto from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
@@ -2927,8 +2928,6 @@ function skatteverketEnabled(): boolean {
|
||||
return process.env.SKATTEVERKET_ENABLED === 'true'
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
const EXTENSION_DISABLED_RESULT: Extract<SkvSubmitResult, { ok: false }> = {
|
||||
ok: false,
|
||||
code: 'EXTENSION_DISABLED',
|
||||
|
||||
@@ -313,8 +313,3 @@ export async function findContestedOrgNumbers(): Promise<Set<string>> {
|
||||
export async function isOrgNumberContested(orgNumber: string): Promise<boolean> {
|
||||
return (await findContestedOrgNumbers()).has(orgNumber)
|
||||
}
|
||||
|
||||
/** Test hook: reset the memoized service client. */
|
||||
export function __resetConnectionStoreForTests(): void {
|
||||
_serviceClient = null
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getEmailService } from '@/lib/email/service'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { resolveMemberEmail } from '@/lib/notifications/member-email'
|
||||
import { escapeHtml } from '@/lib/email/user-text'
|
||||
|
||||
const log = createLogger('skv-kvittens-notification')
|
||||
|
||||
@@ -173,11 +174,3 @@ async function releaseClaim(
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getPrimary as getPrimaryCashAccount } from '@/lib/cash-accounts/service
|
||||
import type {
|
||||
CreateJournalEntryInput,
|
||||
CreateJournalEntryLineInput,
|
||||
EntityType,
|
||||
JournalEntry,
|
||||
} from '@/types'
|
||||
import type {
|
||||
@@ -43,7 +44,7 @@ import type {
|
||||
const PRIMARY_SEK_SENTINEL = '__PRIMARY_SEK__'
|
||||
const PRIMARY_SEK_FALLBACK = '1930'
|
||||
|
||||
export type EntityType = 'enskild_firma' | 'aktiebolag'
|
||||
export type { EntityType } from '@/types'
|
||||
|
||||
interface SkattekontoRuleRow {
|
||||
id: string
|
||||
|
||||
@@ -68,12 +68,6 @@ export interface SkatteverketInlamnatResponse {
|
||||
signerare?: string // Personnummer of signer
|
||||
}
|
||||
|
||||
/** Response from fetching decisions */
|
||||
export interface SkatteverketBeslutatResponse {
|
||||
beslutsdatum?: string
|
||||
momsBeslut?: SkatteverketMomsuppgift
|
||||
}
|
||||
|
||||
/** Stored token pair (decrypted form) */
|
||||
export interface SkatteverketTokens {
|
||||
access_token: string
|
||||
@@ -83,13 +77,6 @@ export interface SkatteverketTokens {
|
||||
scope: string
|
||||
}
|
||||
|
||||
/** Declaration submission status tracking */
|
||||
export type DeclarationStatus =
|
||||
| 'draft_saved'
|
||||
| 'draft_locked'
|
||||
| 'signed'
|
||||
| 'decided'
|
||||
|
||||
// ── AGI (Arbetsgivardeklaration) types ──────────────────────────
|
||||
//
|
||||
// Field shapes mirror the Skatteverket RAMLs in dev_docs/:
|
||||
@@ -232,20 +219,6 @@ export interface SkatteverketAGIKontrollsvarFel {
|
||||
felmeddelande?: string
|
||||
}
|
||||
|
||||
export interface SkatteverketSubmission {
|
||||
id: string
|
||||
user_id: string
|
||||
redovisare: string // 12-digit org/personnummer
|
||||
redovisningsperiod: string // YYYYMM
|
||||
status: DeclarationStatus
|
||||
kvittensnummer: string | null
|
||||
signeringslank: string | null
|
||||
kontrollresultat: SkatteverketKontrollResultat | null
|
||||
momsuppgift: SkatteverketMomsuppgift
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// ── Skattekonto (tax account) types ────────────────────────────
|
||||
//
|
||||
// Field names match Skatteverket's Skattekonto API v2.1.0 JSON schema.
|
||||
@@ -323,8 +296,6 @@ export interface SkatteverketFel {
|
||||
// code continues to import from this module for backwards compatibility.
|
||||
export type {
|
||||
StoredSkattekontoTransaction,
|
||||
SkattekontoMatchSuggestion,
|
||||
SkattekontoBookingSuggestion,
|
||||
SkattekontoTransactionWithSuggestion,
|
||||
} from '@/types/skatteverket'
|
||||
|
||||
|
||||
@@ -22,9 +22,11 @@
|
||||
* one is ever rendered. Every outcome below therefore resolves to one sentence.
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import type { ActionFailure } from '@/lib/browser/action-failure'
|
||||
import {
|
||||
panelRequest,
|
||||
type PanelRequestOptions,
|
||||
type PanelRequestResult,
|
||||
} from '@/lib/browser/panel-request'
|
||||
|
||||
/**
|
||||
* Deadline for the panel's quick calls: the status read, the connect handshake,
|
||||
@@ -50,107 +52,14 @@ export const STRIPE_ACTION_TIMEOUT_MS = 15_000
|
||||
*/
|
||||
export const STRIPE_SYNC_TIMEOUT_MS = 310_000
|
||||
|
||||
export type StripeRequestResult<T> =
|
||||
/** 2xx. `data` is null when the body was not readable JSON. */
|
||||
| { ok: true; data: T | null }
|
||||
| ActionFailure
|
||||
export type StripeRequestResult<T> = PanelRequestResult<T>
|
||||
export type StripeRequestOptions = PanelRequestOptions
|
||||
|
||||
export interface StripeRequestOptions {
|
||||
url: string
|
||||
/** Defaults to POST: three of the four calls are mutations. */
|
||||
method?: 'GET' | 'POST' | 'DELETE'
|
||||
/** JSON request body. Omitted entirely for the routes that ignore it. */
|
||||
body?: unknown
|
||||
/** UI locale, so a server error is reported in the language the user reads. */
|
||||
locale?: ErrorLocale
|
||||
timeoutMs?: number
|
||||
}
|
||||
export { serverErrorMessage } from '@/lib/browser/panel-request'
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* The one sentence for a non-2xx.
|
||||
*
|
||||
* The Stripe extension routes (extensions/general/stripe/index.ts) refuse with a
|
||||
* hand-written Swedish sentence in `{ error }`, and that sentence is the most
|
||||
* specific thing anyone can say: "Inget anslutet Stripe-konto." tells the user
|
||||
* to reconnect, where the status map's "Resursen kunde inte hittas." tells them
|
||||
* nothing. `getErrorMessage` keeps such a sentence only when it happens to carry
|
||||
* one of its Swedish trigger words, which this route copy does not, so the
|
||||
* string is preferred explicitly.
|
||||
*
|
||||
* `error_en` is honoured first for an English UI because the shared capability
|
||||
* guard emits both (`capabilityBlockedResponse` in
|
||||
* lib/entitlements/has-capability.ts), and `getErrorMessage` only reads
|
||||
* `message_en` inside a structured envelope, not a top-level `error_en`.
|
||||
*
|
||||
* Everything else is `getErrorMessage`'s: a structured envelope, an HTML 502
|
||||
* from the platform, a body that never parsed.
|
||||
*/
|
||||
export function serverErrorMessage(
|
||||
body: unknown,
|
||||
status: number,
|
||||
locale: ErrorLocale,
|
||||
): string {
|
||||
if (isRecord(body)) {
|
||||
if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) {
|
||||
return body.error_en.trim()
|
||||
}
|
||||
if (typeof body.error === 'string' && body.error.trim()) {
|
||||
return body.error.trim()
|
||||
}
|
||||
}
|
||||
return getErrorMessage(body, { statusCode: status, locale })
|
||||
}
|
||||
|
||||
/**
|
||||
* Call one of the panel's endpoints and report exactly why it failed.
|
||||
*
|
||||
* Never throws: every arm resolves to a member of the union, so the call site
|
||||
* cannot have a silent path again by forgetting a `catch`.
|
||||
*/
|
||||
export async function stripeRequest<T>({
|
||||
url,
|
||||
method = 'POST',
|
||||
body,
|
||||
locale = 'sv',
|
||||
timeoutMs = STRIPE_ACTION_TIMEOUT_MS,
|
||||
}: StripeRequestOptions): Promise<StripeRequestResult<T>> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
url,
|
||||
body === undefined
|
||||
? { method }
|
||||
: {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
{ timeoutMs, description: `${method} ${url}` },
|
||||
)
|
||||
|
||||
// Read the body on both arms: the failure arm needs the route's own
|
||||
// sentence, the success arm needs the sync counts. A body that is not JSON
|
||||
// (an HTML error page, an empty 502, a response truncated mid-stream) leaves
|
||||
// null, and neither arm then claims anything it cannot support.
|
||||
const payload = await res.json().catch(() => null)
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: res.status,
|
||||
message: serverErrorMessage(payload, res.status, locale),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, data: payload as T | null }
|
||||
} catch (err) {
|
||||
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
|
||||
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
|
||||
}
|
||||
/** Call one of the panel's endpoints and report exactly why it failed. */
|
||||
export function stripeRequest<T>(options: StripeRequestOptions): Promise<StripeRequestResult<T>> {
|
||||
return panelRequest<T>({ timeoutMs: STRIPE_ACTION_TIMEOUT_MS, ...options })
|
||||
}
|
||||
|
||||
/** Success body of POST /api/extensions/ext/stripe/sync. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { chunk as chunked } from '@/lib/utils'
|
||||
import type Stripe from 'stripe'
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import { getStripe } from '@/lib/stripe/client'
|
||||
@@ -478,12 +479,6 @@ async function linkBookedPayouts(
|
||||
return linked
|
||||
}
|
||||
|
||||
function chunked<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
|
||||
return out
|
||||
}
|
||||
|
||||
export async function syncStripeBalanceTransactions(
|
||||
supabase: SupabaseClient,
|
||||
connection: StripeConnection,
|
||||
|
||||
@@ -134,26 +134,3 @@ export interface EnrichmentData {
|
||||
spar?: SparData
|
||||
companyRoles?: CompanyRole[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Complete endpoint request/response (Accounted internal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BankIdCompleteRequest {
|
||||
sessionId: string
|
||||
mode: 'login' | 'signup'
|
||||
email?: string
|
||||
}
|
||||
|
||||
export interface BankIdCompleteResponse {
|
||||
tokenHash: string
|
||||
type: string
|
||||
isNewUser: boolean
|
||||
enrichmentData?: EnrichmentData
|
||||
}
|
||||
|
||||
export interface BankIdCompleteErrorResponse {
|
||||
error: 'no_account' | 'already_linked' | 'account_exists' | 'session_invalid' | 'session_expired'
|
||||
givenName?: string
|
||||
surname?: string
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
TICCompanyPurpose,
|
||||
TICDocument,
|
||||
TICFiscalYear,
|
||||
TICAccountingPeriod,
|
||||
TICPayrollSummary,
|
||||
TICSignatory,
|
||||
TICRepresentatives,
|
||||
@@ -251,17 +250,6 @@ export async function getFiscalYears(companyId: number): Promise<TICFiscalYear[]
|
||||
return ticApiFetch<TICFiscalYear[]>(`/companies/${companyId}/fiscal-years`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get accounting-period change history for a company. v2 endpoint with
|
||||
* no v1 equivalent: surfaces "this company has shifted its year-end"
|
||||
* during onboarding/customer-setup.
|
||||
*/
|
||||
export async function getAccountingPeriods(
|
||||
companyId: number
|
||||
): Promise<TICAccountingPeriod[] | null> {
|
||||
return ticApiFetch<TICAccountingPeriod[]>(`/companies/${companyId}/accounting-periods`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payroll summary for a company. v2 endpoint: restructured from
|
||||
* v1's `/se/payroll`, returns `{ payroll2, payrolls }` where `payroll2`
|
||||
|
||||
@@ -264,21 +264,6 @@ export interface TICFiscalYear {
|
||||
lastUpdatedAtUtc?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 `/companies/{id}/accounting-periods` returns
|
||||
* `CompanyAccountingPeriod_Dto[]`: history of period-end changes
|
||||
* (e.g. shifted year-end). Useful as a "this company changed its books"
|
||||
* indicator during onboarding.
|
||||
*/
|
||||
export interface TICAccountingPeriod {
|
||||
companyAccountingPeriodId?: number
|
||||
companyId?: number
|
||||
endingDatePriorToChange?: string | null
|
||||
endingDateAfterChange?: string | null
|
||||
firstSeenAtUtc?: string | null
|
||||
lastUpdatedAtUtc?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 `/companies/{id}/payrolls` returns a wrapper with two arrays.
|
||||
* `payroll2` is the modern per-period breakdown with deviation vs the
|
||||
|
||||
@@ -94,14 +94,7 @@ const STATE_FOR_QUESTION: Record<QuestionType, WhatsAppConversation['state']> =
|
||||
resend: 'awaiting_resend',
|
||||
}
|
||||
|
||||
// Injectable sleep so tests never wait on real debounce windows.
|
||||
let sleepFn: (ms: number) => Promise<void> = (ms) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/** Test-only: replace (or restore) the debounce sleep implementation. */
|
||||
export function __setSleepForTests(fn: ((ms: number) => Promise<void>) | null): void {
|
||||
sleepFn = fn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
|
||||
}
|
||||
const sleepFn = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
function fallbackFilename(mime: string): string {
|
||||
const ext = EXTENSION_FOR_MIME[mime] ?? 'bin'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sleep } from '@/lib/utils'
|
||||
import { isUnsafeUrlError, safeFetch } from '@/lib/http/safe-fetch'
|
||||
import type { WooOrder, WooRefund, WooStoreInfo } from '../types'
|
||||
|
||||
@@ -133,10 +134,6 @@ function storeOriginOf(creds: WooCredentials): string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function buildUrl(
|
||||
creds: WooCredentials,
|
||||
path: string,
|
||||
|
||||
@@ -6,9 +6,11 @@
|
||||
* tests in this repo.
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout, isTimeoutError } from '@/lib/http/fetch-with-timeout'
|
||||
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
|
||||
import type { ActionFailure } from '@/lib/browser/action-failure'
|
||||
import {
|
||||
panelRequest,
|
||||
type PanelRequestOptions,
|
||||
type PanelRequestResult,
|
||||
} from '@/lib/browser/panel-request'
|
||||
|
||||
/**
|
||||
* Deadline for the quick calls (status, toggle, disconnect). Connect and
|
||||
@@ -27,77 +29,14 @@ export const WOO_CONNECT_TIMEOUT_MS = 120_000
|
||||
*/
|
||||
export const WOO_SYNC_TIMEOUT_MS = 310_000
|
||||
|
||||
export type WooRequestResult<T> =
|
||||
/** 2xx. `data` is null when the body was not readable JSON. */
|
||||
| { ok: true; data: T | null }
|
||||
| ActionFailure
|
||||
export type WooRequestResult<T> = PanelRequestResult<T>
|
||||
export type WooRequestOptions = PanelRequestOptions
|
||||
|
||||
export interface WooRequestOptions {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'DELETE'
|
||||
body?: unknown
|
||||
locale?: ErrorLocale
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** The one sentence for a non-2xx; route copy wins over the generic map. */
|
||||
export function serverErrorMessage(
|
||||
body: unknown,
|
||||
status: number,
|
||||
locale: ErrorLocale,
|
||||
): string {
|
||||
if (isRecord(body)) {
|
||||
if (locale === 'en' && typeof body.error_en === 'string' && body.error_en.trim()) {
|
||||
return body.error_en.trim()
|
||||
}
|
||||
if (typeof body.error === 'string' && body.error.trim()) {
|
||||
return body.error.trim()
|
||||
}
|
||||
}
|
||||
return getErrorMessage(body, { statusCode: status, locale })
|
||||
}
|
||||
export { serverErrorMessage } from '@/lib/browser/panel-request'
|
||||
|
||||
/** Call one of the panel's endpoints and report exactly why it failed. */
|
||||
export async function wooRequest<T>({
|
||||
url,
|
||||
method = 'POST',
|
||||
body,
|
||||
locale = 'sv',
|
||||
timeoutMs = WOO_ACTION_TIMEOUT_MS,
|
||||
}: WooRequestOptions): Promise<WooRequestResult<T>> {
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
url,
|
||||
body === undefined
|
||||
? { method }
|
||||
: {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
{ timeoutMs, description: `${method} ${url}` },
|
||||
)
|
||||
|
||||
const payload = await res.json().catch(() => null)
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'server',
|
||||
status: res.status,
|
||||
message: serverErrorMessage(payload, res.status, locale),
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, data: payload as T | null }
|
||||
} catch (err) {
|
||||
if (isTimeoutError(err)) return { ok: false, reason: 'timeout' }
|
||||
return { ok: false, reason: 'network', message: getErrorMessage(err, { locale }) }
|
||||
}
|
||||
export function wooRequest<T>(options: WooRequestOptions): Promise<WooRequestResult<T>> {
|
||||
return panelRequest<T>({ timeoutMs: WOO_ACTION_TIMEOUT_MS, ...options })
|
||||
}
|
||||
|
||||
/** Success body of POST /api/extensions/ext/woocommerce/sync. */
|
||||
|
||||
Reference in New Issue
Block a user