Display upgrade plan button if limit is reached (#10175)

This commit is contained in:
Artyom Savchenko
2025-10-31 11:01:14 +07:00
committed by GitHub
parent 0ee98916ba
commit 6a5a7ea650
26 changed files with 459 additions and 21 deletions
+3 -2
View File
@@ -5199,7 +5199,7 @@ packages:
version: 0.0.0
'@rush-temp/model-billing@file:projects/model-billing.tgz':
resolution: {integrity: sha512-1eQLuznSm1zZvdCWlGy3hA8xIwRz8RheuLbgmTh/amnobGuia/8ycCJEiPykrsvoLJSiVHrKyaXyySM5+r5jPg==, tarball: file:projects/model-billing.tgz}
resolution: {integrity: sha512-gXVvJywcc4AxaQFQNIy3i/YTJpxtJ7Bo+RSWx4Kn8lVMVSHQ1hK8Crr81hKwP0gNslAXV+TOH+xWRn5WY/xntA==, tarball: file:projects/model-billing.tgz}
version: 0.0.0
'@rush-temp/model-bitrix@file:projects/model-bitrix.tgz':
@@ -5599,7 +5599,7 @@ packages:
version: 0.0.0
'@rush-temp/pod-billing@file:projects/pod-billing.tgz':
resolution: {integrity: sha512-8NzYAsIUJIXwLfBF2mhvK1N70CrgXR/zN4Ppn0YiKrCAPWUaEg6mxz47qvAKyoYZcNvnzGr/6jQbR/Iv7kPEvA==, tarball: file:projects/pod-billing.tgz}
resolution: {integrity: sha512-JaJcuMheg+Um4mTADRrB3cYwpb/XFg9rZQCuh1CAWdZDsKh6g8EoeImFZtD1TeiKXB2wkT+mdbFN0dXYR65LXg==, tarball: file:projects/pod-billing.tgz}
version: 0.0.0
'@rush-temp/pod-calendar-mailer@file:projects/pod-calendar-mailer.tgz':
@@ -28046,6 +28046,7 @@ snapshots:
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3)
cors: 2.8.5
cross-env: 7.0.3
dotenv: 16.0.3
esbuild: 0.25.11
eslint: 8.57.1
+3 -1
View File
@@ -37,8 +37,10 @@
"@hcengineering/core": "^0.7.20",
"@hcengineering/model": "^0.7.17",
"@hcengineering/model-core": "^0.7.0",
"@hcengineering/model-presentation": "^0.7.0",
"@hcengineering/setting": "^0.7.0",
"@hcengineering/billing": "^0.7.0",
"@hcengineering/platform": "^0.7.17"
"@hcengineering/platform": "^0.7.17",
"@hcengineering/workbench": "^0.7.0"
}
}
+7
View File
@@ -19,6 +19,8 @@ import { type IntlString } from '@hcengineering/platform'
import setting from '@hcengineering/setting'
import billing, { type Tier } from '@hcengineering/billing'
import { AccountRole, DOMAIN_MODEL } from '@hcengineering/core'
import presentation from '@hcengineering/model-presentation'
import workbench from '@hcengineering/workbench'
export { billingId } from '@hcengineering/billing'
export { billing as default }
@@ -107,4 +109,9 @@ export function createModel (builder: Builder): void {
},
billing.tier.Legendary
)
builder.createDoc(presentation.class.ComponentPointExtension, core.space.Model, {
extension: workbench.extensions.WorkbenchExtensions,
component: billing.component.WorkbenchExtension
})
}
+18
View File
@@ -138,6 +138,15 @@
--theme-button-container-color: #25262A;
--theme-button-attention-gradient: linear-gradient(135deg, #7C3AED, #A855F7);
--theme-button-attention-border: #A855F7;
--theme-button-attention-hover-gradient: linear-gradient(135deg, #8B5CF6, #7C3AED);
--theme-button-attention-hover-border: #7C3AED;
--theme-button-attention-active-gradient: linear-gradient(135deg, #6D28D9, #7C3AED);
--theme-button-attention-hover-shadow: 0 8px 24px rgba(124, 58, 237, 0.4);
--theme-button-attention-active-shadow: 0 4px 12px rgba(124, 58, 237, 0.6);
--theme-button-attention-focus-shadow: 0 0 0 3px rgba(124, 58, 237, 0.3);
--theme-refinput-divider: rgba(255, 255, 255, .07);
--theme-refinput-border: rgba(255, 255, 255, .1);
@@ -449,6 +458,15 @@
--theme-button-container-color: #F1F1F1;
--theme-button-attention-gradient: linear-gradient(135deg, #EC4899, #F97316);
--theme-button-attention-border: #F97316;
--theme-button-attention-hover-gradient: linear-gradient(135deg, #F97316, #EC4899);
--theme-button-attention-hover-border: #EC4899;
--theme-button-attention-active-gradient: linear-gradient(135deg, #DC2626, #F97316);
--theme-button-attention-hover-shadow: 0 8px 24px rgba(236, 72, 153, 0.3);
--theme-button-attention-active-shadow: 0 4px 12px rgba(236, 72, 153, 0.5);
--theme-button-attention-focus-shadow: 0 0 0 3px rgba(236, 72, 153, 0.2);
--theme-refinput-divider: rgba(0, 0, 0, .07);
--theme-refinput-border: rgba(0, 0, 0, .1);
+127
View File
@@ -169,6 +169,65 @@
}
}
&.attention {
position: relative;
overflow: hidden;
font-weight: 600;
border: 2px solid var(--theme-button-attention-border);
background: var(--theme-button-attention-gradient);
animation: attention-pulse 2s ease-in-out infinite;
.icon { color: var(--button-accent-IconColor); }
span { color: var(--button-accent-LabelColor); }
&::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: var(--theme-button-attention-shine);
transition: left 0.6s ease;
}
&:not(.disabled, :disabled):hover {
background: var(--theme-button-attention-hover-gradient);
border-color: var(--theme-button-attention-hover-border);
transform: translateY(-2px);
box-shadow: var(--theme-button-attention-hover-shadow);
&::before { left: 100%; }
}
&:not(.disabled, :disabled):active,
&.pressed:not(.disabled, :disabled) {
transform: translateY(0) scale(0.98);
box-shadow: var(--theme-button-attention-active-shadow);
}
&:not(.no-focus):focus {
box-shadow: var(--theme-button-attention-focus-shadow);
}
&:disabled:not(.loading),
&.disabled:not(.loading) {
background: var(--button-disabled-BackgroundColor);
border-color: var(--button-disabled-BackgroundColor);
animation: none;
transform: none;
box-shadow: none;
&::before { display: none; }
}
&.loading {
background: var(--theme-button-attention-active-gradient);
span { color: var(--button-accent-LabelColor); }
}
}
& > * { pointer-events: none; }
}
@@ -532,6 +591,63 @@
background-color: var(--negative-button-disabled);
}
}
&.attention {
position: relative;
overflow: hidden;
font-weight: 600;
color: var(--primary-button-color);
background: var(--theme-button-attention-gradient);
border: 2px solid var(--theme-button-attention-border);
animation: attention-pulse 2s ease-in-out infinite;
.btn-icon,
.btn-right-icon { color: var(--primary-button-color); }
&::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: var(--theme-button-attention-shine);
transition: left 0.6s ease;
}
&:hover {
background: var(--theme-button-attention-hover-gradient);
border-color: var(--theme-button-attention-hover-border);
transform: translateY(-2px);
box-shadow: var(--theme-button-attention-hover-shadow);
&::before { left: 100%; }
}
&:active,
&.pressed,
&.pressed:hover {
transform: translateY(0) scale(0.98);
box-shadow: var(--theme-button-attention-active-shadow);
}
&:not(.no-focus):focus {
box-shadow: var(--theme-button-attention-focus-shadow);
}
&:disabled {
color: var(--primary-button-disabled-color);
background: var(--primary-button-disabled);
border-color: var(--primary-button-disabled);
animation: none;
transform: none;
box-shadow: none;
&::before { display: none; }
.btn-icon,
.btn-right-icon { color: var(--primary-button-disabled-color); }
}
}
&.contrast {
padding: .75rem 1rem;
font-weight: 500;
@@ -902,4 +1018,15 @@
content: none;
}
}
@keyframes attention-pulse {
0%, 100% {
box-shadow: var(--theme-button-attention-active-shadow), 0 0 0 0 var(--theme-button-attention-border);
transform: scale(1);
}
50% {
box-shadow: var(--theme-button-attention-hover-shadow), 0 0 0 4px rgba(124, 58, 237, 0.1);
transform: scale(1.02);
}
}
}
+1 -1
View File
@@ -79,7 +79,7 @@
label === undefined &&
$$slots.content === undefined &&
(icon !== undefined || iconRight !== undefined || $$slots.icon || $$slots.iconRight)
$: primary = ['primary', 'secondary', 'positive', 'negative'].some((p) => p === kind)
$: primary = ['primary', 'secondary', 'positive', 'negative', 'attention'].some((p) => p === kind)
$: devSize = $deviceInfo.size
$: adaptive = adaptiveShrink !== null ? checkAdaptiveMatching(devSize, adaptiveShrink) : false
@@ -174,6 +174,10 @@
max-height: unset;
border-radius: 0;
}
&.extendedHeight {
max-height: 90vh;
}
}
.shadow {
+1
View File
@@ -174,6 +174,7 @@ export type ButtonKind =
| 'list-header'
| 'contrast'
| 'stepper'
| 'attention'
export type ButtonSize = 'inline' | 'x-small' | 'small' | 'medium' | 'large' | 'x-large'
export type ButtonShape =
| 'rectangle'
+4 -1
View File
@@ -53,6 +53,9 @@
"StorageUsage": "Úložiště",
"TrafficUsage": "Video/audio provoz",
"Usage": "Využití",
"Of": "z"
"Of": "z",
"UpgradePlan": "Upgradovat plán",
"LimitReached": "Dosažen limit pracovního prostoru, některé funkce mohou být zakázány. Upgradujte pro obnovení plného přístupu.",
"AskBillingAdmin": "Kontaktujte prosím svého správce fakturace pro upgrade plánu."
}
}
+4 -1
View File
@@ -53,6 +53,9 @@
"StorageUsage": "Speicher",
"TrafficUsage": "Video/Audio-Traffic",
"Usage": "Nutzung",
"Of": "von"
"Of": "von",
"UpgradePlan": "Plan upgraden",
"LimitReached": "Arbeitsbereich-Limit erreicht, einige Funktionen könnten deaktiviert sein. Upgraden Sie für vollen Zugriff.",
"AskBillingAdmin": "Bitte kontaktieren Sie Ihren Abrechnungsadministrator, um den Plan zu upgraden."
}
}
+4 -1
View File
@@ -53,6 +53,9 @@
"StorageUsage": "Storage",
"TrafficUsage": "Video/Audio traffic",
"Usage": "Usage",
"Of": "of"
"Of": "of",
"UpgradePlan": "Upgrade Plan",
"LimitReached": "Workspace limit reached, some functionality may be disabled. Upgrade to restore full access.",
"AskBillingAdmin": "Please contact your billing administrator to upgrade the plan."
}
}
+4 -1
View File
@@ -53,6 +53,9 @@
"StorageUsage": "Almacenamiento",
"TrafficUsage": "Tráfico de vídeo/audio",
"Usage": "Uso",
"Of": "de"
"Of": "de",
"UpgradePlan": "Actualizar Plan",
"LimitReached": "Límite del espacio de trabajo alcanzado, algunas funciones pueden estar deshabilitadas. Actualiza para restaurar acceso completo.",
"AskBillingAdmin": "Por favor, contacta a tu administrador de facturación para actualizar el plan."
}
}
+4 -1
View File
@@ -53,6 +53,9 @@
"StorageUsage": "Stockage",
"TrafficUsage": "Trafic vidéo/audio",
"Usage": "Utilisation",
"Of": "de"
"Of": "de",
"UpgradePlan": "Mettre à niveau le plan",
"LimitReached": "Limite de l'espace de travail atteinte, certaines fonctionnalités peuvent être désactivées. Mettez à niveau pour restaurer l'accès complet.",
"AskBillingAdmin": "Veuillez contacter votre administrateur de facturation pour mettre à niveau le plan."
}
}
+4 -1
View File
@@ -53,6 +53,9 @@
"StorageUsage": "Archiviazione",
"TrafficUsage": "Traffico video/audio",
"Usage": "Utilizzo",
"Of": "di"
"Of": "di",
"UpgradePlan": "Aggiorna Piano",
"LimitReached": "Limite workspace raggiunto, alcune funzionalità potrebbero essere disabilitate. Aggiorna per ripristinare l'accesso completo.",
"AskBillingAdmin": "Contatta il tuo amministratore di fatturazione per aggiornare il piano."
}
}
+4 -1
View File
@@ -53,6 +53,9 @@
"StorageUsage": "ストレージ",
"TrafficUsage": "ビデオ/オーディオトラフィック",
"Usage": "使用状況",
"Of": "/"
"Of": "/",
"UpgradePlan": "プランをアップグレード",
"LimitReached": "ワークスペースの制限に達しました。一部の機能が無効になる場合があります。アップグレードして完全アクセスを復元してください。",
"AskBillingAdmin": "プランをアップグレードするには、請求管理者にお問い合わせください。"
}
}
+4 -1
View File
@@ -53,6 +53,9 @@
"StorageUsage": "Armazenamento",
"TrafficUsage": "Tráfego de vídeo/áudio",
"Usage": "Uso",
"Of": "de"
"Of": "de",
"UpgradePlan": "Atualizar Plano",
"LimitReached": "Limite do workspace atingido, algumas funcionalidades podem estar desabilitadas. Atualize para restaurar acesso completo.",
"AskBillingAdmin": "Por favor, contate seu administrador de faturamento para atualizar o plano."
}
}
+4 -1
View File
@@ -53,6 +53,9 @@
"StorageUsage": "Хранилище",
"TrafficUsage": "Видео/аудио трафик",
"Usage": "Использование",
"Of": "из"
"Of": "из",
"UpgradePlan": "Обновить план",
"LimitReached": "Достигнут лимит рабочего пространства, некоторые функции могут быть отключены. Обновите план для восстановления полного доступа.",
"AskBillingAdmin": "Пожалуйста, свяжитесь с вашим администратором биллинга для обновления плана."
}
}
+4 -1
View File
@@ -53,6 +53,9 @@
"StorageUsage": "Depolama",
"TrafficUsage": "Video/ses trafiği",
"Usage": "Kullanım",
"Of": "/"
"Of": "/",
"UpgradePlan": "Planı Yükselt",
"LimitReached": "Çalışma alanı limiti aşıldı, bazı işlevler devre dışı bırakılabilir. Tam erişimi geri yüklemek için yükseltin.",
"AskBillingAdmin": "Lütfen planı yükseltmek için faturalama yöneticinizle iletişime geçin."
}
}
+4 -1
View File
@@ -53,6 +53,9 @@
"StorageUsage": "存储",
"TrafficUsage": "视频/音频流量",
"Usage": "使用情况",
"Of": "/"
"Of": "/",
"UpgradePlan": "升级计划",
"LimitReached": "已达到工作空间限制,某些功能可能被禁用。请升级以恢复完全访问权限。",
"AskBillingAdmin": "请联系您的计费管理员以升级计划。"
}
}
@@ -0,0 +1,23 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { ModernDialog } from '@hcengineering/ui'
import billing from '../plugin'
import Subscriptions from './Subscriptions.svelte'
</script>
<ModernDialog width="100%" label={billing.string.UpgradePlan} withoutFooter={true} className="extendedHeight" on:close>
<Subscriptions />
</ModernDialog>
@@ -0,0 +1,75 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { Button } from '@hcengineering/ui'
import { onMount, onDestroy } from 'svelte'
import billing from '../plugin'
import { upgradePlan, isLimitExceeded } from '../utils'
export let disabled: boolean = false
export let size: 'medium' | 'small' = 'small'
let limitExceeded = false
let pollInterval: number | undefined
const POLL_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes in milliseconds
// TODO: Store flag or recommended plan in workspace info
async function checkLimits (): Promise<void> {
try {
limitExceeded = await isLimitExceeded()
} catch (error) {
console.error('Error checking limits:', error)
limitExceeded = false
}
}
async function handleClick (): Promise<void> {
if (!disabled) {
void upgradePlan()
}
}
onMount(() => {
// Initial check
void checkLimits()
// Set up polling every 10 minutes
pollInterval = setInterval(() => {
void checkLimits()
}, POLL_INTERVAL_MS)
})
onDestroy(() => {
if (pollInterval !== undefined) {
clearInterval(pollInterval)
}
})
</script>
{#if limitExceeded}
<div class="px-2">
<Button
label={billing.string.UpgradePlan}
showTooltip={{
label: billing.string.LimitReached
}}
kind="attention"
{size}
{disabled}
on:click={handleClick}
/>
</div>
{/if}
@@ -0,0 +1,32 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { onMount } from 'svelte'
import { getMetadata } from '@hcengineering/platform'
import { pushRootBarComponent } from '@hcengineering/ui'
import presentation from '@hcengineering/presentation'
import billing from '../plugin'
onMount(() => {
const paymentUrl = getMetadata(presentation.metadata.PaymentUrl)
if (paymentUrl !== '') {
pushRootBarComponent('right', billing.component.UpgradeButtonExt, 10)
}
})
</script>
<div id="recorder-workbench-ext" class="hidden" />
+5 -1
View File
@@ -15,9 +15,13 @@
import type { Resources } from '@hcengineering/platform'
import Settings from './components/Settings.svelte'
import UpgradeButton from './components/UpgradeButton.svelte'
import WorkbenchExtension from './components/WorkbenchExtension.svelte'
export default async (): Promise<Resources> => ({
component: {
Settings
Settings,
UpgradeButtonExt: UpgradeButton,
WorkbenchExtension
}
})
+107 -2
View File
@@ -14,11 +14,20 @@
//
import login from '@hcengineering/login'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import presentation, { MessageBox, getClient } from '@hcengineering/presentation'
import billing from '@hcengineering/billing'
import { getClient as getAccountClientRaw, type AccountClient } from '@hcengineering/account-client'
import {
getClient as getAccountClientRaw,
type AccountClient,
type SubscriptionData
} from '@hcengineering/account-client'
import { getClient as getBillingClientRaw, type BillingClient } from '@hcengineering/billing-client'
import { getClient as getPaymentClientRaw, type PaymentClient } from '@hcengineering/payment-client'
import { AccountRole, getCurrentAccount, hasAccountRole } from '@hcengineering/core'
import { showPopup } from '@hcengineering/ui'
import { type Tier } from '@hcengineering/billing'
import SubscriptionsModal from './components/SubscriptionsModal.svelte'
export function getAccountClient (): AccountClient | null {
const accountsUrl = getMetadata(login.metadata.AccountsUrl) ?? ''
@@ -48,3 +57,99 @@ export function getPaymentClient (): PaymentClient | null {
return getPaymentClientRaw(paymentUrl, token)
}
export async function isLimitExceeded (): Promise<boolean> {
try {
const accountClient = getAccountClient()
if (accountClient == null) return false
const workspaceInfo = await accountClient.getWorkspaceInfo(false)
const usageInfo = workspaceInfo.usageInfo ?? null
if (usageInfo === null) {
return false
}
const subscription = await getCurrentSubscription(accountClient)
if (subscription == null) {
return true
}
const tier = await getTierByPlan(subscription.plan)
if (tier == null) {
return true
}
return checkUsageAgainstLimits(usageInfo, tier)
} catch (error) {
console.error('Error checking usage limits:', error)
return false
}
}
async function getTierByPlan (plan: string): Promise<Tier | null> {
try {
const client = getClient()
const tiers = client.getModel().findAllSync(billing.class.Tier, {})
return (
tiers.find((tier) => {
const tierPlan = getTierPlan(tier._id)
return tierPlan === plan
}) ?? null
)
} catch (error) {
console.error('Error fetching tier by plan:', error)
return null
}
}
function getTierPlan (tierId: string): string {
const parts = tierId.split(':')
return parts.length >= 3 ? parts[2].toLowerCase() : ''
}
function checkUsageAgainstLimits (usageInfo: any, tier: Tier): boolean {
const storageUsedBytes = usageInfo.usage.storageBytes ?? 0
const trafficUsedBytes = usageInfo.usage.livekitTrafficBytes ?? 0
const storageLimitBytes = tier.storageLimitGB * 1000 * 1000 * 1000
const trafficLimitBytes = tier.trafficLimitGB * 1000 * 1000 * 1000
return storageUsedBytes > storageLimitBytes || trafficUsedBytes > trafficLimitBytes
}
export async function getCurrentSubscription (accountClient: AccountClient): Promise<SubscriptionData | undefined> {
const subscriptions = await accountClient.getSubscriptions()
return subscriptions.find((p) => p.type === 'tier')
}
export async function upgradePlan (): Promise<void> {
try {
const accountClient = getAccountClient()
if (accountClient == null) return
const currentAccount = getCurrentAccount()
if (currentAccount == null) {
return
}
const workspaceInfo = await accountClient.getWorkspaceInfo(false)
const isBillingAccount =
workspaceInfo.billingAccount != null
? workspaceInfo.billingAccount === currentAccount.uuid
: hasAccountRole(currentAccount, AccountRole.Owner)
if (isBillingAccount) {
showPopup(SubscriptionsModal, {})
} else {
showPopup(MessageBox, {
label: billing.string.UpgradePlan,
message: billing.string.AskBillingAdmin,
params: { canSubmit: false }
})
}
} catch (error) {
console.error('Failed to show upgrade plan modal:', error)
}
}
+7 -2
View File
@@ -28,7 +28,9 @@ export const billingPlugin = plugin(billingId, {
BillingURL: '' as Metadata<string>
},
component: {
Settings: '' as AnyComponent
Settings: '' as AnyComponent,
WorkbenchExtension: '' as AnyComponent,
UpgradeButtonExt: '' as AnyComponent
},
string: {
Billing: '' as IntlString,
@@ -50,7 +52,10 @@ export const billingPlugin = plugin(billingId, {
Epic: '' as IntlString,
EpicDescription: '' as IntlString,
Legendary: '' as IntlString,
LegendaryDescription: '' as IntlString
LegendaryDescription: '' as IntlString,
UpgradePlan: '' as IntlString,
LimitReached: '' as IntlString,
AskBillingAdmin: '' as IntlString
},
icon: {
Billing: '' as Asset,
+2 -1
View File
@@ -54,7 +54,8 @@
"typescript": "^5.9.3",
"@types/morgan": "~1.9.9",
"@types/on-headers": "^1.0.2",
"@types/uuid": "^8.3.1"
"@types/uuid": "^8.3.1",
"cross-env": "~7.0.3"
},
"dependencies": {
"@hcengineering/analytics": "^0.7.17",