From ce7674920e945039d596e38dfa948bd1f9d365e6 Mon Sep 17 00:00:00 2001 From: Bill Yang Date: Sun, 17 May 2026 01:18:03 -0400 Subject: [PATCH] react native support --- client/messages/cs.json | 20 +- client/messages/de.json | 20 +- client/messages/en.json | 20 +- client/messages/es.json | 20 +- client/messages/fr.json | 20 +- client/messages/it.json | 20 +- client/messages/ja.json | 20 +- client/messages/ko.json | 20 +- client/messages/pl.json | 20 +- client/messages/pt.json | 20 +- client/messages/uk.json | 20 +- client/messages/zh.json | 20 +- client/src/api/admin/endpoints/adminSites.ts | 1 + client/src/api/admin/endpoints/sites.ts | 6 + .../app/[site]/components/Sidebar/Sidebar.tsx | 9 +- .../components/Sidebar/SiteSelector.tsx | 2 +- client/src/app/components/AddSite.tsx | 73 +- .../components/SiteSettings/GeneralTab.tsx | 21 +- .../components/SiteSettings/ScriptBuilder.tsx | 64 +- .../components/SiteSettings/SiteSettings.tsx | 11 +- .../components/SiteSettings/TrackingTab.tsx | 195 +- docs/content/docs/sdks/meta.json | 3 +- docs/content/docs/sdks/react-native.mdx | 49 + react-native/README.md | 43 + react-native/index.d.ts | 75 + react-native/index.js | 427 +++ react-native/package.json | 24 + server/drizzle/0006_site_type.sql | 6 + server/drizzle/meta/0006_snapshot.json | 2724 +++++++++++++++++ server/drizzle/meta/_journal.json | 9 +- server/src/api/admin/getAdminSites.ts | 8 +- server/src/api/sites/addSite.ts | 24 +- server/src/api/sites/getSite.ts | 3 +- server/src/api/sites/getSitesFromOrg.ts | 24 +- server/src/api/sites/getTrackingConfig.ts | 5 +- server/src/api/sites/updateSiteConfig.ts | 52 +- server/src/db/postgres/schema.ts | 67 +- server/src/lib/siteConfig.ts | 3 + .../src/services/tracker/identifyService.ts | 31 +- server/src/services/tracker/trackEvent.ts | 20 +- server/src/services/tracker/utils.ts | 20 +- server/src/services/userId/userIdService.ts | 7 + 42 files changed, 3982 insertions(+), 264 deletions(-) create mode 100644 docs/content/docs/sdks/react-native.mdx create mode 100644 react-native/README.md create mode 100644 react-native/index.d.ts create mode 100644 react-native/index.js create mode 100644 react-native/package.json create mode 100644 server/drizzle/0006_site_type.sql create mode 100644 server/drizzle/meta/0006_snapshot.json diff --git a/client/messages/cs.json b/client/messages/cs.json index f91b30c15..4d888fe29 100644 --- a/client/messages/cs.json +++ b/client/messages/cs.json @@ -65,6 +65,7 @@ "tL4WC1": "Načítám další...", "HPx76n": "Všechny položky načteny", "QkTyho": "Postranní panel Rybbit", + "erYzJH": "", "bnhHP6": "Webová analytika", "EFTSMc": "Hlavní", "EYNe7E": "Globus", @@ -83,7 +84,7 @@ "D3idYv": "Nastavení", "yMG/h7": "Nastavení webu", "yBqtD8": "{count} relací (24h)", - "MzI/h2": "Přidat web", + "FIKvUB": "", "Yxwqz8": "Žádný web nevybrán", "Gpwv5L": "PDF report stažen", "zJPmwX": "Nepodařilo se vygenerovat PDF report", @@ -632,17 +633,21 @@ "rDEYPT": "Vyber organizaci, ze které chceš odebrat {email}. Ztratí přístup ke všem zdrojům v dané organizaci.", "3Vr8ps": "Administrátorský panel", "KI5ndC": "Neplatný formát domény. Musí být platná doména jako example.com nebo sub.example.com", + "KI57sV": "", "cGNlRB": "Upgraduj na Pro pro přidání více webů", "UpxSPM": "Pro přidání webů musíš mít aktivní předplatné", "Ymfrjv": "Dosáhl jsi limitu {limit} webů. Upgraduj pro přidání dalších webů", - "m5aC6r": "Sleduj analytiku nového webu ve své organizaci", + "AZEGID": "", + "JkLHGw": "", + "CXI0EW": "", "oAFgOq": "Doména", + "VQxsRK": "", "3ARIAn": "Zobrazovaný název (výchozí je doména)", "fcj5H7": "Veřejná analytika", "qQ21OF": "Když je zapnuto, kdokoli může zobrazit analytiku bez přihlášení", "u/sVSP": "Zapnout solení ID uživatelů", "yGxRRq": "Zvýšení soukromí pomocí denně rotujících solí pro ID uživatelů", - "9ozcJb": "Chyba při přidávání webu", + "W35dz6": "", "u6kSOz": "Líbí se ti Rybbit? Zvaž sponzorování projektu!", "IDklh3": "Sponzoruj nás", "c/KktL": "Zdroje", @@ -689,6 +694,7 @@ "97KPX7": "Žádné štítky nenalezeny.", "8Nc/jD": "Zatím žádné weby", "nhaiN+": "Přidej svůj první web a začni sledovat analytiku", + "MzI/h2": "Přidat web", "/dxF+k": "Heslo musí mít alespoň 8 znaků", "6bxWvN": "Heslo úspěšně obnoveno", "yAAjri": "Zadej OTP kód", @@ -1096,7 +1102,9 @@ "dcLGy5": "", "Eq9gxg": "", "l3J5rs": "", + "2YpSsF": "", "VKppxp": "Doména nemůže být prázdná", + "HtTwhf": "", "69jzMr": "Doména úspěšně aktualizována", "gwATc0": "Nepodařilo se aktualizovat doménu", "mNRvgY": "Web úspěšně smazán", @@ -1117,6 +1125,7 @@ "lnIbba": "Sledování IP adresy vypnuto", "DQO/Q8": "Název webu", "7IM0G6": "Zobrazovaný název tohoto webu", + "zoRFJV": "", "I7l2wK": "Doména používaná pro sledování", "asd4Px": "Soukromí a zabezpečení", "gI1OXG": "Smazat web", @@ -1171,6 +1180,8 @@ "biJyKp": "Vyloučení provozu z konkrétních IP adres nebo rozsahů. Podporuje jednotlivé IP (192.168.1.1), CIDR notaci (192.168.1.0/24) a rozsahy (192.168.1.1-192.168.1.10).", "tH8S0d": "Přidat IP", "pTHVrx": "Maximálně 100 IP výjimek povoleno", + "pm+3nT": "", + "0Q1jqR": "", "WFlQVv": "Sledovací skript", "EGPr1w": "Přidej tento skript do {headTag} svého webu", "NPL5hM": "Vzory přeskočení", @@ -1209,7 +1220,9 @@ "YluXPL": "Zahrnout parametry URL do sledování stránek", "kehGIT": "Sledování URL parametrů zapnuto", "LIX+6S": "Sledování URL parametrů vypnuto", + "8s+sTd": "", "fWblDI": "Úvodní zobrazení stránky", + "495/Ut": "", "ciDaNU": "Automaticky zaznamenat první zobrazení stránky při načtení skriptu", "KzO2sB": "Sledování úvodního zobrazení stránky zapnuto", "FcSY6x": "Sledování úvodního zobrazení stránky vypnuto", @@ -1217,6 +1230,7 @@ "mFg4Ag": "Sledování odchozích odkazů zapnuto", "sbjdWu": "Sledování odchozích odkazů vypnuto", "5zGub0": "Sledování chyb", + "lxHUeO": "", "4nfQk6": "Zachytávej JavaScript chyby a výjimky z tvého webu", "7LjSrf": "Sledování chyb vypnuto", "u013P/": "Automaticky sleduj kliknutí na všechna tlačítka", diff --git a/client/messages/de.json b/client/messages/de.json index 92c4b9ff8..a8c6c04d3 100644 --- a/client/messages/de.json +++ b/client/messages/de.json @@ -65,6 +65,7 @@ "tL4WC1": "Lade mehr...", "HPx76n": "Alle Einträge geladen", "QkTyho": "Rybbit-Seitenleiste", + "erYzJH": "", "bnhHP6": "Webanalyse", "EFTSMc": "Übersicht", "EYNe7E": "Globus", @@ -83,7 +84,7 @@ "D3idYv": "Einstellungen", "yMG/h7": "Website-Einstellungen", "yBqtD8": "{count} Sitzungen (24h)", - "MzI/h2": "Website hinzufügen", + "FIKvUB": "", "Yxwqz8": "Keine Website ausgewählt", "Gpwv5L": "PDF-Bericht heruntergeladen", "zJPmwX": "PDF-Bericht konnte nicht erstellt werden", @@ -632,17 +633,21 @@ "rDEYPT": "Wählen Sie die Organisation, aus der {email} entfernt werden soll. Der Zugriff auf alle Ressourcen dieser Organisation geht verloren.", "3Vr8ps": "Admin-Dashboard", "KI5ndC": "Ungültiges Domainformat. Muss eine gültige Domain wie beispiel.de oder sub.beispiel.de sein", + "KI57sV": "", "cGNlRB": "Upgrade auf Pro, um weitere Websites hinzuzufügen", "UpxSPM": "Sie benötigen ein aktives Abonnement, um Websites hinzuzufügen", "Ymfrjv": "Sie haben das Limit von {limit} Websites erreicht. Upgraden Sie, um weitere hinzuzufügen", - "m5aC6r": "Verfolgen Sie Analysen für eine neue Website in Ihrer Organisation", + "AZEGID": "", + "JkLHGw": "", + "CXI0EW": "", "oAFgOq": "Domain", + "VQxsRK": "", "3ARIAn": "Anzeigename (Standard ist Domain)", "fcj5H7": "Öffentliche Analysen", "qQ21OF": "Wenn aktiviert, kann jeder die Analysen ohne Anmeldung einsehen", "u/sVSP": "Benutzer-ID-Salting aktivieren", "yGxRRq": "Verbessern Sie den Datenschutz mit täglich rotierenden Salts für Benutzer-IDs", - "9ozcJb": "Fehler beim Hinzufügen der Website", + "W35dz6": "", "u6kSOz": "Gefällt Ihnen Rybbit? Erwägen Sie, das Projekt zu sponsern!", "IDklh3": "Sponsern Sie uns", "c/KktL": "Ressourcen", @@ -689,6 +694,7 @@ "97KPX7": "Keine Tags gefunden.", "8Nc/jD": "Noch keine Websites", "nhaiN+": "Fügen Sie Ihre erste Website hinzu, um mit der Analyse zu beginnen", + "MzI/h2": "Website hinzufügen", "/dxF+k": "Das Passwort muss mindestens 8 Zeichen lang sein", "6bxWvN": "Passwort erfolgreich zurückgesetzt", "yAAjri": "OTP-Code eingeben", @@ -1096,7 +1102,9 @@ "dcLGy5": "Zeigt die Top 5 Länder an, die Ihre Website besuchen.", "Eq9gxg": "Breite (px)", "l3J5rs": "Iframe-Breite. Verwenden Sie max-width: 100% für responsive Layouts.", + "2YpSsF": "", "VKppxp": "Domain darf nicht leer sein", + "HtTwhf": "", "69jzMr": "Domain erfolgreich aktualisiert", "gwATc0": "Domain konnte nicht aktualisiert werden", "mNRvgY": "Website erfolgreich gelöscht", @@ -1117,6 +1125,7 @@ "lnIbba": "IP-Adress-Tracking deaktiviert", "DQO/Q8": "Website-Name", "7IM0G6": "Der Anzeigename für diese Website", + "zoRFJV": "", "I7l2wK": "Die für das Tracking verwendete Domain", "asd4Px": "Datenschutz & Sicherheit", "gI1OXG": "Website löschen", @@ -1171,6 +1180,8 @@ "biJyKp": "Schließen Sie Traffic von bestimmten IP-Adressen oder -Bereichen aus. Unterstützt einzelne IPs (192.168.1.1), CIDR-Notation (192.168.1.0/24) und Bereiche (192.168.1.1-192.168.1.10).", "tH8S0d": "IP hinzufügen", "pTHVrx": "Maximal 100 IP-Ausschlüsse erlaubt", + "pm+3nT": "", + "0Q1jqR": "", "WFlQVv": "Tracking-Skript", "EGPr1w": "Fügen Sie dieses Skript zum {headTag} Ihrer Website hinzu", "NPL5hM": "Ausschlussmuster", @@ -1209,7 +1220,9 @@ "YluXPL": "Abfrageparameter im Seiten-Tracking einbeziehen", "kehGIT": "URL-Parameter-Tracking aktiviert", "LIX+6S": "URL-Parameter-Tracking deaktiviert", + "8s+sTd": "", "fWblDI": "Erster Seitenaufruf", + "495/Ut": "", "ciDaNU": "Automatisch den ersten Seitenaufruf beim Laden des Skripts erfassen", "KzO2sB": "Tracking des ersten Seitenaufrufs aktiviert", "FcSY6x": "Tracking des ersten Seitenaufrufs deaktiviert", @@ -1217,6 +1230,7 @@ "mFg4Ag": "Ausgehendes Tracking aktiviert", "sbjdWu": "Ausgehendes Tracking deaktiviert", "5zGub0": "Fehlerverfolgung", + "lxHUeO": "", "4nfQk6": "JavaScript-Fehler und Ausnahmen von Ihrer Website erfassen", "7LjSrf": "Fehlerverfolgung deaktiviert", "u013P/": "Automatisch Klicks auf alle Buttons verfolgen", diff --git a/client/messages/en.json b/client/messages/en.json index 1dd5f3bb3..8b484b46f 100644 --- a/client/messages/en.json +++ b/client/messages/en.json @@ -65,6 +65,7 @@ "tL4WC1": "Loading more...", "HPx76n": "All items loaded", "QkTyho": "Rybbit Sidebar", + "erYzJH": "App Analytics", "bnhHP6": "Web Analytics", "EFTSMc": "Main", "EYNe7E": "Globe", @@ -83,7 +84,7 @@ "D3idYv": "Settings", "yMG/h7": "Site Settings", "yBqtD8": "{count} sessions (24h)", - "MzI/h2": "Add Website", + "FIKvUB": "Add Site", "Yxwqz8": "No site selected", "Gpwv5L": "PDF report downloaded", "zJPmwX": "Failed to generate PDF report", @@ -632,17 +633,21 @@ "rDEYPT": "Select the organization to remove {email} from. They will lose access to all resources in that organization.", "3Vr8ps": "Admin Dashboard", "KI5ndC": "Invalid domain format. Must be a valid domain like example.com or sub.example.com", + "KI57sV": "Invalid app identifier. Use a bundle/package identifier like com.example.app", "cGNlRB": "Upgrade to Pro to add more websites", "UpxSPM": "You need to be on an active subscription to add websites", "Ymfrjv": "You have reached the limit of {limit} websites. Upgrade to add more websites", - "m5aC6r": "Track analytics for a new website in your organization", + "AZEGID": "Track analytics for a new website or React Native app in your organization", + "JkLHGw": "Website", + "CXI0EW": "React Native App", "oAFgOq": "Domain", + "VQxsRK": "App Identifier", "3ARIAn": "Display name (defaults to domain)", "fcj5H7": "Public Analytics", "qQ21OF": "When enabled, anyone can view analytics without logging in", "u/sVSP": "Enable User ID Salting", "yGxRRq": "Enhance privacy with daily rotating salts for user IDs", - "9ozcJb": "Error Adding Website", + "W35dz6": "Error Adding Site", "u6kSOz": "Liking Rybbit? Consider sponsoring the project!", "IDklh3": "Sponsor us", "c/KktL": "Resources", @@ -689,6 +694,7 @@ "97KPX7": "No tags found.", "8Nc/jD": "No websites yet", "nhaiN+": "Add your first website to start tracking analytics", + "MzI/h2": "Add Website", "/dxF+k": "Password must be at least 8 characters long", "6bxWvN": "Password Reset Successful", "yAAjri": "Enter OTP Code", @@ -1096,7 +1102,9 @@ "dcLGy5": "Display the top 5 countries visiting your site.", "Eq9gxg": "Width (px)", "l3J5rs": "Iframe width. Use max-width: 100% for responsive layouts.", + "2YpSsF": "App identifier cannot be empty", "VKppxp": "Domain cannot be empty", + "HtTwhf": "App identifier updated successfully", "69jzMr": "Domain updated successfully", "gwATc0": "Failed to update domain", "mNRvgY": "Site deleted successfully", @@ -1117,6 +1125,7 @@ "lnIbba": "IP address tracking disabled", "DQO/Q8": "Site Name", "7IM0G6": "The display name for this site", + "zoRFJV": "The bundle or package identifier used for tracking", "I7l2wK": "The domain used for tracking", "asd4Px": "Privacy & Security", "gI1OXG": "Delete Site", @@ -1171,6 +1180,8 @@ "biJyKp": "Exclude traffic from specific IP addresses or ranges. Supports single IPs (192.168.1.1), CIDR notation (192.168.1.0/24), and ranges (192.168.1.1-192.168.1.10).", "tH8S0d": "Add IP", "pTHVrx": "Maximum 100 IP exclusions allowed", + "pm+3nT": "React Native SDK", + "0Q1jqR": "Install the React Native package and initialize it in your app entry point", "WFlQVv": "Tracking Script", "EGPr1w": "Add this script to the {headTag} of your website", "NPL5hM": "Skip Patterns", @@ -1209,7 +1220,9 @@ "YluXPL": "Include query string parameters in page tracking", "kehGIT": "URL parameters tracking enabled", "LIX+6S": "URL parameters tracking disabled", + "8s+sTd": "Initial Screen View", "fWblDI": "Initial Page View", + "495/Ut": "Automatically track the initial screen passed to the React Native SDK", "ciDaNU": "Automatically track the first page view when the script loads", "KzO2sB": "Initial page view tracking enabled", "FcSY6x": "Initial page view tracking disabled", @@ -1217,6 +1230,7 @@ "mFg4Ag": "Outbound tracking enabled", "sbjdWu": "Outbound tracking disabled", "5zGub0": "Error Tracking", + "lxHUeO": "Allow error events sent by the React Native SDK", "4nfQk6": "Capture JavaScript errors and exceptions from your site", "7LjSrf": "Error tracking disabled", "u013P/": "Automatically track clicks on all buttons", diff --git a/client/messages/es.json b/client/messages/es.json index 82254309b..ed478bf5a 100644 --- a/client/messages/es.json +++ b/client/messages/es.json @@ -65,6 +65,7 @@ "tL4WC1": "Cargando más...", "HPx76n": "Todos los elementos cargados", "QkTyho": "Barra lateral de Rybbit", + "erYzJH": "", "bnhHP6": "Analíticas Web", "EFTSMc": "Principal", "EYNe7E": "Globo", @@ -83,7 +84,7 @@ "D3idYv": "Configuraciones", "yMG/h7": "Configuración del sitio", "yBqtD8": "{count} sesiones (24h)", - "MzI/h2": "Añadir sitio web", + "FIKvUB": "", "Yxwqz8": "Ningún sitio seleccionado", "Gpwv5L": "Informe PDF descargado", "zJPmwX": "Error al generar informe PDF", @@ -632,17 +633,21 @@ "rDEYPT": "Selecciona la organización de la que eliminar a {email}. Perderán acceso a todos los recursos en esa organización.", "3Vr8ps": "Panel de administración", "KI5ndC": "Formato de dominio no válido. Debe ser un dominio válido como example.com o sub.example.com", + "KI57sV": "", "cGNlRB": "Actualiza a Pro para añadir más sitios web", "UpxSPM": "Necesitas tener una suscripción activa para añadir sitios web", "Ymfrjv": "Has alcanzado el límite de {limit} sitios web. Actualiza para añadir más sitios web", - "m5aC6r": "Rastrea analíticas para un nuevo sitio web en tu organización", + "AZEGID": "", + "JkLHGw": "", + "CXI0EW": "", "oAFgOq": "Dominio", + "VQxsRK": "", "3ARIAn": "Nombre de visualización (predeterminado: dominio)", "fcj5H7": "Analíticas públicas", "qQ21OF": "Cuando está habilitado, cualquiera puede ver las analíticas sin iniciar sesión", "u/sVSP": "Activar Salting de ID de Usuario", "yGxRRq": "Mejora la privacidad con sales rotativas diarias para los ID de usuario", - "9ozcJb": "Error al añadir el sitio web", + "W35dz6": "", "u6kSOz": "¿Te gusta Rybbit? ¡Considera patrocinar el proyecto!", "IDklh3": "Patrocínanos", "c/KktL": "Recursos", @@ -689,6 +694,7 @@ "97KPX7": "No se encontraron etiquetas.", "8Nc/jD": "No hay sitios web aún", "nhaiN+": "Añade tu primer sitio web para empezar a rastrear analíticas", + "MzI/h2": "Añadir sitio web", "/dxF+k": "La contraseña debe tener al menos 8 caracteres", "6bxWvN": "Restablecimiento de contraseña exitoso", "yAAjri": "Introduce el código OTP", @@ -1096,7 +1102,9 @@ "dcLGy5": "Muestra los 5 principales países que visitan tu sitio.", "Eq9gxg": "Ancho (px)", "l3J5rs": "Ancho del iframe. Usa max-width: 100% para diseños responsivos.", + "2YpSsF": "", "VKppxp": "El dominio no puede estar vacío", + "HtTwhf": "", "69jzMr": "Dominio actualizado correctamente", "gwATc0": "Error al actualizar el dominio", "mNRvgY": "Sitio eliminado correctamente", @@ -1117,6 +1125,7 @@ "lnIbba": "Rastreo de dirección IP desactivado", "DQO/Q8": "Nombre del sitio", "7IM0G6": "El nombre de visualización de este sitio", + "zoRFJV": "", "I7l2wK": "El dominio utilizado para el seguimiento", "asd4Px": "Privacidad y Seguridad", "gI1OXG": "Eliminar Sitio", @@ -1171,6 +1180,8 @@ "biJyKp": "Excluye tráfico de direcciones o rangos de IP específicos. Soporta IPs individuales (192.168.1.1), notación CIDR (192.168.1.0/24) y rangos (192.168.1.1-192.168.1.10).", "tH8S0d": "Añadir IP", "pTHVrx": "Máximo 100 exclusiones de IP permitidas", + "pm+3nT": "", + "0Q1jqR": "", "WFlQVv": "Script de seguimiento", "EGPr1w": "Añade este script a la etiqueta {headTag} de tu sitio web", "NPL5hM": "Omitir patrones", @@ -1209,7 +1220,9 @@ "YluXPL": "Incluye parámetros de la cadena de consulta en el rastreo de páginas", "kehGIT": "Rastreo de parámetros de URL activado", "LIX+6S": "Rastreo de parámetros de URL desactivado", + "8s+sTd": "", "fWblDI": "Vista de Página Inicial", + "495/Ut": "", "ciDaNU": "Rastrea automáticamente la primera visita a la página cuando se carga el script", "KzO2sB": "Rastreo de vista de página inicial activado", "FcSY6x": "Rastreo de vista de página inicial desactivado", @@ -1217,6 +1230,7 @@ "mFg4Ag": "Rastreo de enlaces salientes activado", "sbjdWu": "Rastreo de enlaces salientes desactivado", "5zGub0": "Rastreo de Errores", + "lxHUeO": "", "4nfQk6": "Captura errores y excepciones de JavaScript de tu sitio", "7LjSrf": "Rastreo de errores desactivado", "u013P/": "Rastrea automáticamente clics en todos los botones", diff --git a/client/messages/fr.json b/client/messages/fr.json index a70896b7b..f7e6b3462 100644 --- a/client/messages/fr.json +++ b/client/messages/fr.json @@ -65,6 +65,7 @@ "tL4WC1": "Chargement en cours...", "HPx76n": "Tous les éléments chargés", "QkTyho": "Barre latérale Rybbit", + "erYzJH": "", "bnhHP6": "Analyse web", "EFTSMc": "Principal", "EYNe7E": "Globe", @@ -83,7 +84,7 @@ "D3idYv": "Paramètres", "yMG/h7": "Paramètres du site", "yBqtD8": "{count} sessions (24h)", - "MzI/h2": "Ajouter un site web", + "FIKvUB": "", "Yxwqz8": "Aucun site sélectionné", "Gpwv5L": "Rapport PDF téléchargé", "zJPmwX": "Échec de la génération du rapport PDF", @@ -632,17 +633,21 @@ "rDEYPT": "Sélectionnez l'organisation d'où retirer {email}. L'accès à toutes les ressources de cette organisation sera perdu.", "3Vr8ps": "Tableau de bord admin", "KI5ndC": "Format de domaine invalide. Doit être un domaine valide comme exemple.com ou sub.exemple.com", + "KI57sV": "", "cGNlRB": "Passez à Pro pour ajouter plus de sites web", "UpxSPM": "Vous devez avoir un abonnement actif pour ajouter des sites web", "Ymfrjv": "Vous avez atteint la limite de {limit} sites web. Passez à un plan supérieur pour en ajouter plus", - "m5aC6r": "Suivez les analyses d'un nouveau site web dans votre organisation", + "AZEGID": "", + "JkLHGw": "", + "CXI0EW": "", "oAFgOq": "Domaine", + "VQxsRK": "", "3ARIAn": "Nom d'affichage (par défaut : domaine)", "fcj5H7": "Analyses publiques", "qQ21OF": "Lorsque activé, n'importe qui peut voir les analyses sans se connecter", "u/sVSP": "Activer le salage des ID utilisateur", "yGxRRq": "Améliorez la confidentialité avec des sels rotatifs quotidiens pour les ID utilisateur", - "9ozcJb": "Erreur lors de l'ajout du site web", + "W35dz6": "", "u6kSOz": "Vous aimez Rybbit ? Pensez à sponsoriser le projet !", "IDklh3": "Sponsorisez-nous", "c/KktL": "Ressources", @@ -689,6 +694,7 @@ "97KPX7": "Aucun tag trouvé.", "8Nc/jD": "Aucun site web pour le moment", "nhaiN+": "Ajoutez votre premier site web pour commencer à suivre les analyses", + "MzI/h2": "Ajouter un site web", "/dxF+k": "Le mot de passe doit contenir au moins 8 caractères", "6bxWvN": "Mot de passe réinitialisé avec succès", "yAAjri": "Saisir le code OTP", @@ -1096,7 +1102,9 @@ "dcLGy5": "Affiche les 5 premiers pays visitant votre site.", "Eq9gxg": "Largeur (px)", "l3J5rs": "Largeur de l'iframe. Utilisez max-width: 100% pour les mises en page responsives.", + "2YpSsF": "", "VKppxp": "Le domaine ne peut pas être vide", + "HtTwhf": "", "69jzMr": "Domaine mis à jour avec succès", "gwATc0": "Échec de la mise à jour du domaine", "mNRvgY": "Site supprimé avec succès", @@ -1117,6 +1125,7 @@ "lnIbba": "Suivi des adresses IP désactivé", "DQO/Q8": "Nom du site", "7IM0G6": "Le nom d'affichage de ce site", + "zoRFJV": "", "I7l2wK": "Le domaine utilisé pour le suivi", "asd4Px": "Confidentialité et sécurité", "gI1OXG": "Supprimer le site", @@ -1171,6 +1180,8 @@ "biJyKp": "Excluez le trafic d'adresses IP ou de plages spécifiques. Prend en charge les IP individuelles (192.168.1.1), la notation CIDR (192.168.1.0/24) et les plages (192.168.1.1-192.168.1.10).", "tH8S0d": "Ajouter une IP", "pTHVrx": "Maximum 100 exclusions IP autorisées", + "pm+3nT": "", + "0Q1jqR": "", "WFlQVv": "Script de suivi", "EGPr1w": "Ajoutez ce script au {headTag} de votre site web", "NPL5hM": "Modèles d'exclusion", @@ -1209,7 +1220,9 @@ "YluXPL": "Inclure les paramètres de requête dans le suivi des pages", "kehGIT": "Suivi des paramètres URL activé", "LIX+6S": "Suivi des paramètres URL désactivé", + "8s+sTd": "", "fWblDI": "Première page vue", + "495/Ut": "", "ciDaNU": "Enregistrer automatiquement la première page vue au chargement du script", "KzO2sB": "Suivi de la première page vue activé", "FcSY6x": "Suivi de la première page vue désactivé", @@ -1217,6 +1230,7 @@ "mFg4Ag": "Suivi des liens sortants activé", "sbjdWu": "Suivi des liens sortants désactivé", "5zGub0": "Suivi des erreurs", + "lxHUeO": "", "4nfQk6": "Capturer les erreurs JavaScript et les exceptions de votre site", "7LjSrf": "Suivi des erreurs désactivé", "u013P/": "Suivre automatiquement les clics sur tous les boutons", diff --git a/client/messages/it.json b/client/messages/it.json index 97e443b26..96643a70e 100644 --- a/client/messages/it.json +++ b/client/messages/it.json @@ -65,6 +65,7 @@ "tL4WC1": "Caricamento in corso...", "HPx76n": "Tutti gli elementi caricati", "QkTyho": "Barra Laterale Rybbit", + "erYzJH": "", "bnhHP6": "Analisi Web", "EFTSMc": "Principale", "EYNe7E": "Globo", @@ -83,7 +84,7 @@ "D3idYv": "Impostazioni", "yMG/h7": "Impostazioni Sito", "yBqtD8": "{count} sessioni (24h)", - "MzI/h2": "Aggiungi Sito Web", + "FIKvUB": "", "Yxwqz8": "Nessun sito selezionato", "Gpwv5L": "Report PDF scaricato", "zJPmwX": "Impossibile generare il report PDF", @@ -632,17 +633,21 @@ "rDEYPT": "Seleziona l'organizzazione da cui rimuovere {email}. Perderà l'accesso a tutte le risorse in quell'organizzazione.", "3Vr8ps": "Dashboard Amministratore", "KI5ndC": "Formato dominio non valido. Deve essere un dominio valido come example.com o sub.example.com", + "KI57sV": "", "cGNlRB": "Passa a Pro per aggiungere più siti web", "UpxSPM": "Devi avere un abbonamento attivo per aggiungere siti web", "Ymfrjv": "Hai raggiunto il limite di {limit} siti web. Aggiorna per aggiungere più siti web", - "m5aC6r": "Traccia le analisi per un nuovo sito web nella tua organizzazione", + "AZEGID": "", + "JkLHGw": "", + "CXI0EW": "", "oAFgOq": "Dominio", + "VQxsRK": "", "3ARIAn": "Nome visualizzato (predefinito: dominio)", "fcj5H7": "Analisi Pubbliche", "qQ21OF": "Se abilitato, chiunque può visualizzare le analisi senza accedere", "u/sVSP": "Abilita Salting ID Utente", "yGxRRq": "Migliora la privacy con salting a rotazione giornaliera per gli ID utente", - "9ozcJb": "Errore Aggiunta Sito Web", + "W35dz6": "", "u6kSOz": "Ti piace Rybbit? Considera di sponsorizzare il progetto!", "IDklh3": "Sponsorizzaci", "c/KktL": "Risorse", @@ -689,6 +694,7 @@ "97KPX7": "Nessun tag trovato.", "8Nc/jD": "Ancora nessun sito web", "nhaiN+": "Aggiungi il tuo primo sito web per iniziare a tracciare le analisi", + "MzI/h2": "Aggiungi Sito Web", "/dxF+k": "La password deve essere lunga almeno 8 caratteri", "6bxWvN": "Reimpostazione Password Avvenuta con Successo", "yAAjri": "Inserisci Codice OTP", @@ -1096,7 +1102,9 @@ "dcLGy5": "Visualizza i 5 paesi principali che visitano il tuo sito.", "Eq9gxg": "Larghezza (px)", "l3J5rs": "Larghezza iframe. Usa max-width: 100% per layout responsivi.", + "2YpSsF": "", "VKppxp": "Il dominio non può essere vuoto", + "HtTwhf": "", "69jzMr": "Dominio aggiornato con successo", "gwATc0": "Impossibile aggiornare il dominio", "mNRvgY": "Sito eliminato con successo", @@ -1117,6 +1125,7 @@ "lnIbba": "Tracciamento indirizzo IP disabilitato", "DQO/Q8": "Nome del sito", "7IM0G6": "Il nome visualizzato per questo sito", + "zoRFJV": "", "I7l2wK": "Il dominio utilizzato per il tracciamento", "asd4Px": "Privacy & Sicurezza", "gI1OXG": "Elimina Sito", @@ -1171,6 +1180,8 @@ "biJyKp": "Escludi traffico da indirizzi o intervalli IP specifici. Supporta IP singoli (192.168.1.1), notazione CIDR (192.168.1.0/24) e intervalli (192.168.1.1-192.168.1.10).", "tH8S0d": "Aggiungi IP", "pTHVrx": "Massimo 100 esclusioni IP consentite", + "pm+3nT": "", + "0Q1jqR": "", "WFlQVv": "Script di Tracciamento", "EGPr1w": "Aggiungi questo script al {headTag} del tuo sito web", "NPL5hM": "Salta Modelli", @@ -1209,7 +1220,9 @@ "YluXPL": "Includi i parametri della query string nel tracciamento della pagina", "kehGIT": "Tracciamento parametri URL abilitato", "LIX+6S": "Tracciamento parametri URL disabilitato", + "8s+sTd": "", "fWblDI": "Visualizzazione Pagina Iniziale", + "495/Ut": "", "ciDaNU": "Traccia automaticamente la prima visualizzazione di pagina al caricamento dello script", "KzO2sB": "Tracciamento visualizzazione pagina iniziale abilitato", "FcSY6x": "Tracciamento visualizzazione pagina iniziale disabilitato", @@ -1217,6 +1230,7 @@ "mFg4Ag": "Tracciamento link in uscita abilitato", "sbjdWu": "Tracciamento link in uscita disabilitato", "5zGub0": "Tracciamento Errori", + "lxHUeO": "", "4nfQk6": "Cattura errori ed eccezioni JavaScript dal tuo sito", "7LjSrf": "Tracciamento errori disabilitato", "u013P/": "Traccia automaticamente i clic su tutti i pulsanti", diff --git a/client/messages/ja.json b/client/messages/ja.json index cdde65649..3ae3175f3 100644 --- a/client/messages/ja.json +++ b/client/messages/ja.json @@ -65,6 +65,7 @@ "tL4WC1": "さらに読み込み中...", "HPx76n": "すべて読み込み済み", "QkTyho": "Rybbitサイドバー", + "erYzJH": "", "bnhHP6": "ウェブアナリティクス", "EFTSMc": "メイン", "EYNe7E": "グローブ", @@ -83,7 +84,7 @@ "D3idYv": "設定", "yMG/h7": "サイト設定", "yBqtD8": "{count}セッション (24時間)", - "MzI/h2": "ウェブサイトを追加", + "FIKvUB": "", "Yxwqz8": "サイトが選択されていません", "Gpwv5L": "PDFレポートをダウンロードしました", "zJPmwX": "PDFレポートの生成に失敗しました", @@ -632,17 +633,21 @@ "rDEYPT": "{email}を削除する組織を選択してください。その組織のすべてのリソースへのアクセスが失われます。", "3Vr8ps": "管理者ダッシュボード", "KI5ndC": "無効なドメイン形式です。example.comまたはsub.example.comのような有効なドメインを入力してください", + "KI57sV": "", "cGNlRB": "Proにアップグレードしてウェブサイトを追加", "UpxSPM": "ウェブサイトを追加するにはアクティブなサブスクリプションが必要です", "Ymfrjv": "ウェブサイトの上限{limit}に達しました。アップグレードしてウェブサイトを追加してください", - "m5aC6r": "組織で新しいウェブサイトのアナリティクスを追跡", + "AZEGID": "", + "JkLHGw": "", + "CXI0EW": "", "oAFgOq": "ドメイン", + "VQxsRK": "", "3ARIAn": "表示名(デフォルトはドメイン)", "fcj5H7": "公開アナリティクス", "qQ21OF": "有効にすると、ログインなしで誰でもアナリティクスを閲覧できます", "u/sVSP": "ユーザーIDソルトを有効化", "yGxRRq": "日次ローテーションソルトでユーザーIDのプライバシーを強化", - "9ozcJb": "ウェブサイト追加エラー", + "W35dz6": "", "u6kSOz": "Rybbitを気に入っていただけましたか?プロジェクトのスポンサーをご検討ください!", "IDklh3": "スポンサーになる", "c/KktL": "リソース", @@ -689,6 +694,7 @@ "97KPX7": "タグが見つかりません。", "8Nc/jD": "ウェブサイトがまだありません", "nhaiN+": "最初のウェブサイトを追加してアナリティクスの追跡を開始しましょう", + "MzI/h2": "ウェブサイトを追加", "/dxF+k": "パスワードは8文字以上である必要があります", "6bxWvN": "パスワードのリセットに成功しました", "yAAjri": "OTPコードを入力", @@ -1096,7 +1102,9 @@ "dcLGy5": "サイトを訪問している上位5か国を表示します。", "Eq9gxg": "幅 (px)", "l3J5rs": "iframeの幅。レスポンシブレイアウトにはmax-width: 100%を使用してください。", + "2YpSsF": "", "VKppxp": "ドメインを空にすることはできません", + "HtTwhf": "", "69jzMr": "ドメインを更新しました", "gwATc0": "ドメインの更新に失敗しました", "mNRvgY": "サイトを削除しました", @@ -1117,6 +1125,7 @@ "lnIbba": "IPアドレス追跡を無効にしました", "DQO/Q8": "サイト名", "7IM0G6": "このサイトの表示名", + "zoRFJV": "", "I7l2wK": "トラッキングに使用するドメイン", "asd4Px": "プライバシーとセキュリティ", "gI1OXG": "サイトを削除", @@ -1171,6 +1180,8 @@ "biJyKp": "特定のIPアドレスまたは範囲からのトラフィックを除外します。単一IP(192.168.1.1)、CIDR表記(192.168.1.0/24)、範囲(192.168.1.1-192.168.1.10)をサポートしています。", "tH8S0d": "IPを追加", "pTHVrx": "IP除外は最大100件まで", + "pm+3nT": "", + "0Q1jqR": "", "WFlQVv": "トラッキングスクリプト", "EGPr1w": "ウェブサイトの{headTag}にこのスクリプトを追加", "NPL5hM": "スキップパターン", @@ -1209,7 +1220,9 @@ "YluXPL": "ページ追跡にクエリ文字列パラメータを含める", "kehGIT": "URLパラメータ追跡を有効にしました", "LIX+6S": "URLパラメータ追跡を無効にしました", + "8s+sTd": "", "fWblDI": "初回ページビュー", + "495/Ut": "", "ciDaNU": "スクリプト読み込み時に最初のページビューを自動追跡", "KzO2sB": "初回ページビュー追跡を有効にしました", "FcSY6x": "初回ページビュー追跡を無効にしました", @@ -1217,6 +1230,7 @@ "mFg4Ag": "外部リンク追跡を有効にしました", "sbjdWu": "外部リンク追跡を無効にしました", "5zGub0": "エラートラッキング", + "lxHUeO": "", "4nfQk6": "サイトのJavaScriptエラーと例外をキャプチャ", "7LjSrf": "エラートラッキングを無効にしました", "u013P/": "すべてのボタンのクリックを自動追跡", diff --git a/client/messages/ko.json b/client/messages/ko.json index f0d728ff6..76dff4d9b 100644 --- a/client/messages/ko.json +++ b/client/messages/ko.json @@ -65,6 +65,7 @@ "tL4WC1": "더 불러오는 중...", "HPx76n": "모든 항목 로드됨", "QkTyho": "Rybbit 사이드바", + "erYzJH": "", "bnhHP6": "웹 분석", "EFTSMc": "메인", "EYNe7E": "글로브", @@ -83,7 +84,7 @@ "D3idYv": "설정", "yMG/h7": "사이트 설정", "yBqtD8": "{count}개 세션 (24시간)", - "MzI/h2": "웹사이트 추가", + "FIKvUB": "", "Yxwqz8": "사이트 미선택", "Gpwv5L": "PDF 보고서 다운로드 완료", "zJPmwX": "PDF 보고서 생성 실패", @@ -632,17 +633,21 @@ "rDEYPT": "{email}을(를) 제거할 조직을 선택하세요. 해당 조직의 모든 리소스에 대한 액세스를 잃게 됩니다.", "3Vr8ps": "관리자 대시보드", "KI5ndC": "잘못된 도메인 형식입니다. example.com 또는 sub.example.com과 같은 유효한 도메인이어야 합니다", + "KI57sV": "", "cGNlRB": "Pro로 업그레이드하여 더 많은 웹사이트를 추가하세요", "UpxSPM": "웹사이트를 추가하려면 활성 구독이 필요합니다", "Ymfrjv": "웹사이트 한도 {limit}개에 도달했습니다. 업그레이드하여 더 많은 웹사이트를 추가하세요", - "m5aC6r": "조직에서 새 웹사이트의 분석을 추적합니다", + "AZEGID": "", + "JkLHGw": "", + "CXI0EW": "", "oAFgOq": "도메인", + "VQxsRK": "", "3ARIAn": "표시 이름 (기본값: 도메인)", "fcj5H7": "공개 분석", "qQ21OF": "활성화하면 로그인 없이 누구나 분석을 볼 수 있습니다", "u/sVSP": "사용자 ID 솔팅 활성화", "yGxRRq": "일일 회전 솔트로 사용자 ID 개인정보 보호 강화", - "9ozcJb": "웹사이트 추가 오류", + "W35dz6": "", "u6kSOz": "Rybbit이 마음에 드셨나요? 프로젝트 후원을 고려해 주세요!", "IDklh3": "후원하기", "c/KktL": "리소스", @@ -689,6 +694,7 @@ "97KPX7": "태그를 찾을 수 없습니다.", "8Nc/jD": "아직 웹사이트가 없습니다", "nhaiN+": "첫 번째 웹사이트를 추가하여 분석 추적을 시작하세요", + "MzI/h2": "웹사이트 추가", "/dxF+k": "비밀번호는 8자 이상이어야 합니다", "6bxWvN": "비밀번호 재설정 성공", "yAAjri": "OTP 코드 입력", @@ -1096,7 +1102,9 @@ "dcLGy5": "사이트를 방문하는 상위 5개 국가를 표시합니다.", "Eq9gxg": "너비 (px)", "l3J5rs": "Iframe 너비. 반응형 레이아웃에는 max-width: 100%를 사용하세요.", + "2YpSsF": "", "VKppxp": "도메인은 비워둘 수 없습니다", + "HtTwhf": "", "69jzMr": "도메인이 성공적으로 업데이트되었습니다", "gwATc0": "도메인 업데이트 실패", "mNRvgY": "사이트가 성공적으로 삭제되었습니다", @@ -1117,6 +1125,7 @@ "lnIbba": "IP 주소 추적 비활성화됨", "DQO/Q8": "사이트 이름", "7IM0G6": "이 사이트의 표시 이름", + "zoRFJV": "", "I7l2wK": "추적에 사용되는 도메인", "asd4Px": "개인정보 및 보안", "gI1OXG": "사이트 삭제", @@ -1171,6 +1180,8 @@ "biJyKp": "특정 IP 주소 또는 범위의 트래픽을 제외합니다. 단일 IP (192.168.1.1), CIDR 표기법 (192.168.1.0/24), 범위 (192.168.1.1-192.168.1.10)를 지원합니다.", "tH8S0d": "IP 추가", "pTHVrx": "최대 100개의 IP 제외가 허용됩니다", + "pm+3nT": "", + "0Q1jqR": "", "WFlQVv": "추적 스크립트", "EGPr1w": "웹사이트의 {headTag}에 이 스크립트를 추가하세요", "NPL5hM": "건너뛰기 패턴", @@ -1209,7 +1220,9 @@ "YluXPL": "페이지 추적에 쿼리 문자열 매개변수를 포함합니다", "kehGIT": "URL 매개변수 추적 활성화됨", "LIX+6S": "URL 매개변수 추적 비활성화됨", + "8s+sTd": "", "fWblDI": "초기 페이지뷰", + "495/Ut": "", "ciDaNU": "스크립트 로드 시 첫 번째 페이지뷰를 자동으로 추적합니다", "KzO2sB": "초기 페이지뷰 추적 활성화됨", "FcSY6x": "초기 페이지뷰 추적 비활성화됨", @@ -1217,6 +1230,7 @@ "mFg4Ag": "외부 링크 추적 활성화됨", "sbjdWu": "외부 링크 추적 비활성화됨", "5zGub0": "오류 추적", + "lxHUeO": "", "4nfQk6": "사이트에서 JavaScript 오류 및 예외를 캡처합니다", "7LjSrf": "오류 추적 비활성화됨", "u013P/": "모든 버튼 클릭을 자동으로 추적합니다", diff --git a/client/messages/pl.json b/client/messages/pl.json index 64a711414..a48fa0503 100644 --- a/client/messages/pl.json +++ b/client/messages/pl.json @@ -65,6 +65,7 @@ "tL4WC1": "Ładowanie kolejnych...", "HPx76n": "Wszystkie elementy załadowane", "QkTyho": "Panel boczny Rybbit", + "erYzJH": "", "bnhHP6": "Analityka internetowa", "EFTSMc": "Główne", "EYNe7E": "Globus", @@ -83,7 +84,7 @@ "D3idYv": "Ustawienia", "yMG/h7": "Ustawienia strony", "yBqtD8": "{count} sesji (24h)", - "MzI/h2": "Dodaj stronę", + "FIKvUB": "", "Yxwqz8": "Nie wybrano strony", "Gpwv5L": "Pobrano raport PDF", "zJPmwX": "Nie udało się wygenerować raportu PDF", @@ -632,17 +633,21 @@ "rDEYPT": "Wybierz organizację, z której chcesz usunąć {email}. Straci dostęp do wszystkich zasobów tej organizacji.", "3Vr8ps": "Panel administratora", "KI5ndC": "Nieprawidłowy format domeny. Musi być prawidłową domeną, np. example.com lub sub.example.com", + "KI57sV": "", "cGNlRB": "Ulepsz do Pro, aby dodać więcej stron", "UpxSPM": "Musisz mieć aktywną subskrypcję, aby dodawać strony", "Ymfrjv": "Osiągnięto limit {limit} stron. Ulepsz plan, aby dodać więcej stron", - "m5aC6r": "Śledź analitykę nowej strony w swojej organizacji", + "AZEGID": "", + "JkLHGw": "", + "CXI0EW": "", "oAFgOq": "Domena", + "VQxsRK": "", "3ARIAn": "Nazwa wyświetlana (domyślnie domena)", "fcj5H7": "Publiczna analityka", "qQ21OF": "Po włączeniu każdy może przeglądać analitykę bez logowania", "u/sVSP": "Włącz solenie ID użytkownika", "yGxRRq": "Zwiększ prywatność dzięki codziennie rotowanym solom dla ID użytkowników", - "9ozcJb": "Błąd dodawania strony", + "W35dz6": "", "u6kSOz": "Podoba Ci się Rybbit? Rozważ sponsorowanie projektu!", "IDklh3": "Sponsoruj nas", "c/KktL": "Zasoby", @@ -689,6 +694,7 @@ "97KPX7": "Nie znaleziono tagów.", "8Nc/jD": "Brak stron", "nhaiN+": "Dodaj swoją pierwszą stronę, aby rozpocząć śledzenie analityki", + "MzI/h2": "Dodaj stronę", "/dxF+k": "Hasło musi mieć co najmniej 8 znaków", "6bxWvN": "Hasło zresetowane pomyślnie", "yAAjri": "Wprowadź kod OTP", @@ -1096,7 +1102,9 @@ "dcLGy5": "Wyświetl 5 najczęstszych krajów odwiedzających Twoją stronę.", "Eq9gxg": "Szerokość (px)", "l3J5rs": "Szerokość iframe. Użyj max-width: 100% dla układów responsywnych.", + "2YpSsF": "", "VKppxp": "Domena nie może być pusta", + "HtTwhf": "", "69jzMr": "Domena zaktualizowana pomyślnie", "gwATc0": "Nie udało się zaktualizować domeny", "mNRvgY": "Strona usunięta pomyślnie", @@ -1117,6 +1125,7 @@ "lnIbba": "Śledzenie adresów IP wyłączone", "DQO/Q8": "Nazwa witryny", "7IM0G6": "Nazwa wyświetlana dla tej witryny", + "zoRFJV": "", "I7l2wK": "Domena używana do śledzenia", "asd4Px": "Prywatność i bezpieczeństwo", "gI1OXG": "Usuń stronę", @@ -1171,6 +1180,8 @@ "biJyKp": "Wyklucz ruch z określonych adresów IP lub zakresów. Obsługuje pojedyncze adresy IP (192.168.1.1), notację CIDR (192.168.1.0/24) i zakresy (192.168.1.1-192.168.1.10).", "tH8S0d": "Dodaj IP", "pTHVrx": "Maksymalnie 100 wykluczeń IP", + "pm+3nT": "", + "0Q1jqR": "", "WFlQVv": "Skrypt śledzenia", "EGPr1w": "Dodaj ten skrypt do {headTag} swojej strony", "NPL5hM": "Wzorce pomijania", @@ -1209,7 +1220,9 @@ "YluXPL": "Uwzględnij parametry zapytania w śledzeniu stron", "kehGIT": "Śledzenie parametrów URL włączone", "LIX+6S": "Śledzenie parametrów URL wyłączone", + "8s+sTd": "", "fWblDI": "Początkowa odsłona strony", + "495/Ut": "", "ciDaNU": "Automatycznie śledź pierwszą odsłonę strony po załadowaniu skryptu", "KzO2sB": "Śledzenie początkowej odsłony włączone", "FcSY6x": "Śledzenie początkowej odsłony wyłączone", @@ -1217,6 +1230,7 @@ "mFg4Ag": "Śledzenie linków wychodzących włączone", "sbjdWu": "Śledzenie linków wychodzących wyłączone", "5zGub0": "Śledzenie błędów", + "lxHUeO": "", "4nfQk6": "Przechwytuj błędy JavaScript i wyjątki ze swojej strony", "7LjSrf": "Śledzenie błędów wyłączone", "u013P/": "Automatycznie śledź kliknięcia we wszystkie przyciski", diff --git a/client/messages/pt.json b/client/messages/pt.json index 3af638476..ffad48ade 100644 --- a/client/messages/pt.json +++ b/client/messages/pt.json @@ -65,6 +65,7 @@ "tL4WC1": "Carregando mais...", "HPx76n": "Todos os itens carregados", "QkTyho": "Barra Lateral do Rybbit", + "erYzJH": "", "bnhHP6": "Análise Web", "EFTSMc": "Principal", "EYNe7E": "Globo", @@ -83,7 +84,7 @@ "D3idYv": "Configurações", "yMG/h7": "Configurações do Site", "yBqtD8": "{count} sessões (24h)", - "MzI/h2": "Adicionar Site", + "FIKvUB": "", "Yxwqz8": "Nenhum site selecionado", "Gpwv5L": "Relatório PDF baixado", "zJPmwX": "Falha ao gerar relatório PDF", @@ -632,17 +633,21 @@ "rDEYPT": "Selecione a organização da qual remover {email}. Ele perderá acesso a todos os recursos dessa organização.", "3Vr8ps": "Painel Administrativo", "KI5ndC": "Formato de domínio inválido. Deve ser um domínio válido como exemplo.com ou sub.exemplo.com", + "KI57sV": "", "cGNlRB": "Atualize para Pro para adicionar mais sites", "UpxSPM": "Você precisa ter uma assinatura ativa para adicionar sites", "Ymfrjv": "Você atingiu o limite de {limit} sites. Atualize para adicionar mais sites", - "m5aC6r": "Rastreie análises de um novo site na sua organização", + "AZEGID": "", + "JkLHGw": "", + "CXI0EW": "", "oAFgOq": "Domínio", + "VQxsRK": "", "3ARIAn": "Nome de exibição (padrão é o domínio)", "fcj5H7": "Análises Públicas", "qQ21OF": "Quando ativado, qualquer pessoa pode visualizar as análises sem fazer login", "u/sVSP": "Ativar Salting de ID de Usuário", "yGxRRq": "Aumente a privacidade com salts rotativos diários para IDs de usuário", - "9ozcJb": "Erro ao Adicionar Site", + "W35dz6": "", "u6kSOz": "Gostando do Rybbit? Considere patrocinar o projeto!", "IDklh3": "Patrocine-nos", "c/KktL": "Recursos", @@ -689,6 +694,7 @@ "97KPX7": "Nenhuma tag encontrada.", "8Nc/jD": "Nenhum site ainda", "nhaiN+": "Adicione seu primeiro site para começar a rastrear análises", + "MzI/h2": "Adicionar Site", "/dxF+k": "A senha deve ter pelo menos 8 caracteres", "6bxWvN": "Senha Redefinida com Sucesso", "yAAjri": "Digite o Código OTP", @@ -1096,7 +1102,9 @@ "dcLGy5": "Exibe os 5 principais países que visitam seu site.", "Eq9gxg": "Largura (px)", "l3J5rs": "Largura do iframe. Use max-width: 100% para layouts responsivos.", + "2YpSsF": "", "VKppxp": "O domínio não pode estar vazio", + "HtTwhf": "", "69jzMr": "Domínio atualizado com sucesso", "gwATc0": "Falha ao atualizar domínio", "mNRvgY": "Site excluído com sucesso", @@ -1117,6 +1125,7 @@ "lnIbba": "Rastreamento de endereço IP desativado", "DQO/Q8": "Nome do site", "7IM0G6": "O nome de exibição deste site", + "zoRFJV": "", "I7l2wK": "O domínio usado para rastreamento", "asd4Px": "Privacidade e Segurança", "gI1OXG": "Excluir Site", @@ -1171,6 +1180,8 @@ "biJyKp": "Exclua tráfego de endereços IP ou faixas específicas. Suporta IPs únicos (192.168.1.1), notação CIDR (192.168.1.0/24) e faixas (192.168.1.1-192.168.1.10).", "tH8S0d": "Adicionar IP", "pTHVrx": "Máximo de 100 exclusões de IP permitidas", + "pm+3nT": "", + "0Q1jqR": "", "WFlQVv": "Script de Rastreamento", "EGPr1w": "Adicione este script ao {headTag} do seu site", "NPL5hM": "Padrões de Exclusão", @@ -1209,7 +1220,9 @@ "YluXPL": "Incluir parâmetros de query string no rastreamento de página", "kehGIT": "Rastreamento de parâmetros de URL ativado", "LIX+6S": "Rastreamento de parâmetros de URL desativado", + "8s+sTd": "", "fWblDI": "Visualização de Página Inicial", + "495/Ut": "", "ciDaNU": "Rastreie automaticamente a primeira visualização de página quando o script carrega", "KzO2sB": "Rastreamento de visualização de página inicial ativado", "FcSY6x": "Rastreamento de visualização de página inicial desativado", @@ -1217,6 +1230,7 @@ "mFg4Ag": "Rastreamento de saída ativado", "sbjdWu": "Rastreamento de saída desativado", "5zGub0": "Rastreamento de Erros", + "lxHUeO": "", "4nfQk6": "Capture erros e exceções JavaScript do seu site", "7LjSrf": "Rastreamento de erros desativado", "u013P/": "Rastreie automaticamente cliques em todos os botões", diff --git a/client/messages/uk.json b/client/messages/uk.json index 1c2033d78..8cd22f520 100644 --- a/client/messages/uk.json +++ b/client/messages/uk.json @@ -65,6 +65,7 @@ "tL4WC1": "Завантаження...", "HPx76n": "Всі елементи завантажено", "QkTyho": "Бічна панель Rybbit", + "erYzJH": "", "bnhHP6": "Вебаналітика", "EFTSMc": "Головна", "EYNe7E": "Глобус", @@ -83,7 +84,7 @@ "D3idYv": "Налаштування", "yMG/h7": "Налаштування сайту", "yBqtD8": "{count} сеансів (24 год)", - "MzI/h2": "Додати вебсайт", + "FIKvUB": "", "Yxwqz8": "Сайт не вибрано", "Gpwv5L": "Звіт у PDF завантажено", "zJPmwX": "Не вдалося згенерувати звіт у PDF", @@ -632,17 +633,21 @@ "rDEYPT": "Виберіть організацію, з якої потрібно видалити {email}. Вони втратять доступ до всіх ресурсів у цій організації.", "3Vr8ps": "Панель адміністратора", "KI5ndC": "Недійсний формат домену. Має бути дійсний домен, наприклад, example.com або sub.example.com", + "KI57sV": "", "cGNlRB": "Перейдіть на тариф Pro, щоб додати більше вебсайтів", "UpxSPM": "Вам потрібна активна підписка, щоб додавати вебсайти", "Ymfrjv": "Ви досягли ліміту у {limit} вебсайтів. Оновіть тарифний план, щоб додати більше", - "m5aC6r": "Відстежуйте аналітику для нового вебсайту у вашій організації", + "AZEGID": "", + "JkLHGw": "", + "CXI0EW": "", "oAFgOq": "Домен", + "VQxsRK": "", "3ARIAn": "Відображувана назва (за замовчуванням — домен)", "fcj5H7": "Публічна аналітика", "qQ21OF": "Коли увімкнено, будь-хто зможе переглядати аналітику без входу в систему", "u/sVSP": "Увімкнути додавання солі до ID користувачів (Salting)", "yGxRRq": "Підвищує конфіденційність за допомогою щоденної ротації солі для ідентифікаторів користувачів", - "9ozcJb": "Помилка додавання вебсайту", + "W35dz6": "", "u6kSOz": "Подобається Rybbit? Розгляньте можливість стати спонсором проєкту!", "IDklh3": "Спонсорувати", "c/KktL": "Ресурси", @@ -689,6 +694,7 @@ "97KPX7": "Тегів не знайдено.", "8Nc/jD": "Вебсайтів ще немає", "nhaiN+": "Додайте свій перший вебсайт, щоб почати відстежувати аналітику", + "MzI/h2": "Додати вебсайт", "/dxF+k": "Пароль має містити щонайменше 8 символів", "6bxWvN": "Пароль успішно скинуто", "yAAjri": "Введіть код OTP", @@ -1096,7 +1102,9 @@ "dcLGy5": "", "Eq9gxg": "", "l3J5rs": "", + "2YpSsF": "", "VKppxp": "Домен не може бути порожнім", + "HtTwhf": "", "69jzMr": "Домен успішно оновлено", "gwATc0": "Не вдалося оновити домен", "mNRvgY": "Сайт успішно видалено", @@ -1117,6 +1125,7 @@ "lnIbba": "Відстеження IP-адреси вимкнено", "DQO/Q8": "Назва сайту", "7IM0G6": "Відображувана назва для цього сайту", + "zoRFJV": "", "I7l2wK": "Домен, що використовується для відстеження", "asd4Px": "Конфіденційність та безпека", "gI1OXG": "Видалити сайт", @@ -1171,6 +1180,8 @@ "biJyKp": "Виключіть трафік із певних IP-адрес або діапазонів. Підтримуються окремі IP (192.168.1.1), нотація CIDR (192.168.1.0/24) та діапазони (192.168.1.1-192.168.1.10).", "tH8S0d": "Додати IP", "pTHVrx": "Дозволено максимум 100 винятків IP", + "pm+3nT": "", + "0Q1jqR": "", "WFlQVv": "Скрипт відстеження", "EGPr1w": "Додайте цей скрипт у тег {headTag} вашого вебсайту", "NPL5hM": "Шаблони для пропуску", @@ -1209,7 +1220,9 @@ "YluXPL": "Включати параметри рядка запиту у відстеження сторінок", "kehGIT": "Відстеження параметрів URL увімкнено", "LIX+6S": "Відстеження параметрів URL вимкнено", + "8s+sTd": "", "fWblDI": "Початковий перегляд сторінки", + "495/Ut": "", "ciDaNU": "Автоматично відстежувати перший перегляд сторінки під час завантаження скрипта", "KzO2sB": "Відстеження початкового перегляду сторінки увімкнено", "FcSY6x": "Відстеження початкового перегляду сторінки вимкнено", @@ -1217,6 +1230,7 @@ "mFg4Ag": "Відстеження вихідних посилань увімкнено", "sbjdWu": "Відстеження вихідних посилань вимкнено", "5zGub0": "Відстеження помилок", + "lxHUeO": "", "4nfQk6": "Фіксуйте помилки JavaScript та винятки з вашого сайту", "7LjSrf": "Відстеження помилок вимкнено", "u013P/": "Автоматично відстежувати кліки по всіх кнопках", diff --git a/client/messages/zh.json b/client/messages/zh.json index 4d0722c76..d288dc968 100644 --- a/client/messages/zh.json +++ b/client/messages/zh.json @@ -65,6 +65,7 @@ "tL4WC1": "正在加载更多...", "HPx76n": "所有项目已加载", "QkTyho": "Rybbit 侧边栏", + "erYzJH": "", "bnhHP6": "网络分析", "EFTSMc": "主要", "EYNe7E": "地球", @@ -83,7 +84,7 @@ "D3idYv": "设置", "yMG/h7": "网站设置", "yBqtD8": "{count} 个会话 (24小时)", - "MzI/h2": "添加网站", + "FIKvUB": "", "Yxwqz8": "未选择网站", "Gpwv5L": "PDF 报告已下载", "zJPmwX": "生成 PDF 报告失败", @@ -632,17 +633,21 @@ "rDEYPT": "选择要从中移除 {email} 的组织。他们将失去对该组织中所有资源的访问权限。", "3Vr8ps": "管理仪表板", "KI5ndC": "无效的域名格式。必须是有效的域名,例如 example.com 或 sub.example.com", + "KI57sV": "", "cGNlRB": "升级至 Pro 以添加更多网站", "UpxSPM": "您需要有一个有效的订阅才能添加网站", "Ymfrjv": "您已达到 {limit} 个网站的上限。请升级以添加更多网站", - "m5aC6r": "为组织中的新网站跟踪分析数据", + "AZEGID": "", + "JkLHGw": "", + "CXI0EW": "", "oAFgOq": "域名", + "VQxsRK": "", "3ARIAn": "显示名称(默认为域名)", "fcj5H7": "公开分析", "qQ21OF": "启用后,任何人都可以查看分析数据而无需登录", "u/sVSP": "启用用户 ID 加盐", "yGxRRq": "通过每日轮换用户 ID 的盐值来增强隐私", - "9ozcJb": "添加网站出错", + "W35dz6": "", "u6kSOz": "喜欢 Rybbit 吗?考虑赞助这个项目!", "IDklh3": "赞助我们", "c/KktL": "资源", @@ -689,6 +694,7 @@ "97KPX7": "未找到标签。", "8Nc/jD": "还没有网站", "nhaiN+": "添加您的第一个网站以开始跟踪分析", + "MzI/h2": "添加网站", "/dxF+k": "密码长度必须至少为 8 个字符", "6bxWvN": "密码重置成功", "yAAjri": "输入 OTP 验证码", @@ -1096,7 +1102,9 @@ "dcLGy5": "显示访问您站点的前 5 个国家/地区。", "Eq9gxg": "宽度 (像素)", "l3J5rs": "Iframe 宽度。使用 max-width: 100% 实现响应式布局。", + "2YpSsF": "", "VKppxp": "域名不能为空", + "HtTwhf": "", "69jzMr": "域名更新成功", "gwATc0": "更新域名失败", "mNRvgY": "网站删除成功", @@ -1117,6 +1125,7 @@ "lnIbba": "IP 地址跟踪已禁用", "DQO/Q8": "网站名称", "7IM0G6": "此网站的显示名称", + "zoRFJV": "", "I7l2wK": "用于跟踪的域名", "asd4Px": "隐私与安全", "gI1OXG": "删除网站", @@ -1171,6 +1180,8 @@ "biJyKp": "排除来自特定 IP 地址或网段的流量。支持单个 IP (192.168.1.1)、CIDR 表示法 (192.168.1.0/24) 和范围 (192.168.1.1-192.168.1.10)。", "tH8S0d": "添加 IP", "pTHVrx": "最多允许 100 个 IP 排除项", + "pm+3nT": "", + "0Q1jqR": "", "WFlQVv": "跟踪脚本", "EGPr1w": "将此脚本添加到您网站的 {headTag} 中", "NPL5hM": "跳过模式", @@ -1209,7 +1220,9 @@ "YluXPL": "在页面跟踪中包含查询字符串参数", "kehGIT": "URL 参数跟踪已启用", "LIX+6S": "URL 参数跟踪已禁用", + "8s+sTd": "", "fWblDI": "初始页面浏览", + "495/Ut": "", "ciDaNU": "脚本加载时自动跟踪第一次页面浏览", "KzO2sB": "初始页面浏览跟踪已启用", "FcSY6x": "初始页面浏览跟踪已禁用", @@ -1217,6 +1230,7 @@ "mFg4Ag": "出站跟踪已启用", "sbjdWu": "出站跟踪已禁用", "5zGub0": "错误追踪", + "lxHUeO": "", "4nfQk6": "捕获网站中的 JavaScript 错误和异常", "7LjSrf": "错误追踪已禁用", "u013P/": "自动追踪所有按钮的点击", diff --git a/client/src/api/admin/endpoints/adminSites.ts b/client/src/api/admin/endpoints/adminSites.ts index 583ce0dd2..d5fd57973 100644 --- a/client/src/api/admin/endpoints/adminSites.ts +++ b/client/src/api/admin/endpoints/adminSites.ts @@ -3,6 +3,7 @@ import { authedFetch } from "../../utils"; export interface AdminSiteData { siteId: number; name: string; + type: "web" | "mobile" | null; domain: string; createdAt: string; public: boolean; diff --git a/client/src/api/admin/endpoints/sites.ts b/client/src/api/admin/endpoints/sites.ts index d278fd893..e045a3e35 100644 --- a/client/src/api/admin/endpoints/sites.ts +++ b/client/src/api/admin/endpoints/sites.ts @@ -4,6 +4,7 @@ export type SiteResponse = { id: string | null; siteId: number; name: string; + type: "web" | "mobile" | null; domain: string; createdAt: string; updatedAt: string; @@ -45,6 +46,7 @@ export type GetSitesFromOrgResponse = { id: string | null; siteId: number; name: string; + type: "web" | "mobile" | null; domain: string; createdAt: string; updatedAt: string; @@ -76,6 +78,7 @@ export function addSite( name: string, organizationId: string, settings?: { + type?: "web" | "mobile"; isPublic?: boolean; saltUserIds?: boolean; blockBots?: boolean; @@ -86,6 +89,7 @@ export function addSite( data: { domain, name, + type: settings?.type || "web", public: settings?.isPublic || false, saltUserIds: settings?.saltUserIds || false, blockBots: settings?.blockBots === undefined ? true : settings?.blockBots, @@ -107,6 +111,7 @@ export function updateSiteConfig( siteId: number, config: { name?: string; + type?: "web" | "mobile" | null; domain?: string; public?: boolean; embedEnabled?: boolean; @@ -121,6 +126,7 @@ export function updateSiteConfig( trackUrlParams?: boolean; trackInitialPageView?: boolean; trackSpaNavigation?: boolean; + trackIp?: boolean; trackButtonClicks?: boolean; trackCopy?: boolean; trackFormInteractions?: boolean; diff --git a/client/src/app/[site]/components/Sidebar/Sidebar.tsx b/client/src/app/[site]/components/Sidebar/Sidebar.tsx index 2cbe6493a..7593cf5ea 100644 --- a/client/src/app/[site]/components/Sidebar/Sidebar.tsx +++ b/client/src/app/[site]/components/Sidebar/Sidebar.tsx @@ -36,6 +36,7 @@ function SidebarContent() { const { embed, hideSidebar } = useEmbedPageOptions(); const { data: site } = useGetSite(Number(pathname.split("/")[1])); + const isMobileSite = site?.type === "mobile"; if (hideSidebar) return null; @@ -65,7 +66,9 @@ function SidebarContent() {
- {t("Web Analytics")} + + {isMobileSite ? t("App Analytics") : t("Web Analytics")} + } /> )} - {IS_CLOUD && ( + {IS_CLOUD && !isMobileSite && ( {t("Product Analytics")}
- {!subscription?.planName?.startsWith("appsumo") && !isSubscriptionLoading && ( + {!isMobileSite && !subscription?.planName?.startsWith("appsumo") && !isSubscriptionLoading && ( void }) { trigger={ } /> diff --git a/client/src/app/components/AddSite.tsx b/client/src/app/components/AddSite.tsx index c6c122226..6996e6f75 100644 --- a/client/src/app/components/AddSite.tsx +++ b/client/src/app/components/AddSite.tsx @@ -1,6 +1,6 @@ "use client"; import { Button } from "@/components/ui/button"; -import { AlertCircle, AppWindow, Plus } from "lucide-react"; +import { AlertCircle, AppWindow, Globe2, Plus, Smartphone } from "lucide-react"; import { useExtracted } from "next-intl"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -18,6 +18,7 @@ import { } from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; +import { RadioGroup, RadioGroupItem } from "../../components/ui/radio-group"; import { Switch } from "../../components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../components/ui/tooltip"; import { authClient } from "../../lib/auth"; @@ -26,6 +27,10 @@ import { resetStore, useStore } from "../../lib/store"; import { useStripeSubscription } from "../../lib/subscription/useStripeSubscription"; import { isValidDomain, normalizeDomain } from "../../lib/utils"; +type SiteType = "web" | "mobile"; + +const isValidAppIdentifier = (value: string) => /^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$/.test(value); + export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disabled?: boolean }) { const { setSite } = useStore(); const router = useRouter(); @@ -41,6 +46,7 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa const finalDisabled = disabled || isOverSiteLimit; const [open, setOpen] = useState(false); + const [siteType, setSiteType] = useState("web"); const [domain, setDomain] = useState(""); const [name, setName] = useState(""); const [isPublic, setIsPublic] = useState(false); @@ -56,15 +62,20 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa } // Validate before attempting to add - if (!isValidDomain(domain)) { + if (siteType === "web" && !isValidDomain(domain)) { setError(t("Invalid domain format. Must be a valid domain like example.com or sub.example.com")); return; } + if (siteType === "mobile" && !isValidAppIdentifier(domain)) { + setError(t("Invalid app identifier. Use a bundle/package identifier like com.example.app")); + return; + } try { - const normalizedDomain = normalizeDomain(domain); + const normalizedDomain = siteType === "web" ? normalizeDomain(domain) : domain.trim(); const siteName = name.trim() || normalizedDomain; const site = await addSite(normalizedDomain, siteName, activeOrganization.id, { + type: siteType, isPublic, saltUserIds, }); @@ -82,6 +93,7 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa }; const resetForm = () => { + setSiteType("web"); setDomain(""); setName(""); setError(""); @@ -89,7 +101,6 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa setSaltUserIds(false); }; - if (subscription?.status !== "active" && subscription?.status !== "trialing" && IS_CLOUD) { return ( @@ -97,13 +108,11 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa {trigger || ( )} - - {t("You need to be on an active subscription to add websites")} - + {t("You need to be on an active subscription to add websites")} ); } @@ -116,12 +125,14 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa {trigger || ( )} - {t("You have reached the limit of {limit} websites. Upgrade to add more websites", { limit: String(siteLimit) })} + {t("You have reached the limit of {limit} websites. Upgrade to add more websites", { + limit: String(siteLimit), + })} ); @@ -142,7 +153,7 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa {trigger || ( )} @@ -150,21 +161,49 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa - {t("Add Website")} + {t("Add Site")} - {t("Track analytics for a new website in your organization")} + + {t("Track analytics for a new website or React Native app in your organization")} +
+ setSiteType(value as SiteType)} + className="grid grid-cols-2 gap-3" + > + + + +
setDomain(e.target.value.toLowerCase())} - placeholder="example.com or sub.example.com" + onChange={e => { + const value = e.target.value.trim(); + setDomain(siteType === "web" ? value.toLowerCase() : value); + }} + placeholder={siteType === "web" ? "example.com or sub.example.com" : "com.example.app"} />
@@ -208,7 +247,7 @@ export function AddSite({ trigger, disabled }: { trigger?: React.ReactNode; disa {error && ( - {t("Error Adding Website")} + {t("Error Adding Site")} {error} )} diff --git a/client/src/components/SiteSettings/GeneralTab.tsx b/client/src/components/SiteSettings/GeneralTab.tsx index 8c1bb6156..c5b0cdbcb 100644 --- a/client/src/components/SiteSettings/GeneralTab.tsx +++ b/client/src/components/SiteSettings/GeneralTab.tsx @@ -49,6 +49,8 @@ export function GeneralTab({ siteMetadata, disabled = false, onClose, onPublicCh const t = useExtracted(); const { refetch } = useGetSitesFromOrg(siteMetadata?.organizationId ?? ""); const router = useRouter(); + const isMobileSite = siteMetadata.type === "mobile"; + const identifierLabel = isMobileSite ? t("App Identifier") : t("Domain"); const [newName, setNewName] = useState(siteMetadata.name); const [isChangingName, setIsChangingName] = useState(false); @@ -118,15 +120,15 @@ export function GeneralTab({ siteMetadata, disabled = false, onClose, onPublicCh const handleDomainChange = async () => { if (!newDomain) { - toast.error(t("Domain cannot be empty")); + toast.error(isMobileSite ? t("App identifier cannot be empty") : t("Domain cannot be empty")); return; } try { setIsChangingDomain(true); - const normalizedDomain = normalizeDomain(newDomain); + const normalizedDomain = isMobileSite ? newDomain.trim() : normalizeDomain(newDomain); await updateSiteConfig(siteMetadata.siteId, { domain: normalizedDomain }); - toast.success(t("Domain updated successfully")); + toast.success(isMobileSite ? t("App identifier updated successfully") : t("Domain updated successfully")); router.refresh(); refetch(); } catch (error) { @@ -213,14 +215,19 @@ export function GeneralTab({ siteMetadata, disabled = false, onClose, onPublicCh
-

{t("Domain")}

-

{t("The domain used for tracking")}

+

{identifierLabel}

+

+ {isMobileSite ? t("The bundle or package identifier used for tracking") : t("The domain used for tracking")} +

setNewDomain(e.target.value.toLowerCase())} - placeholder="example.com" + onChange={e => { + const value = e.target.value.trim(); + setNewDomain(isMobileSite ? value : value.toLowerCase()); + }} + placeholder={isMobileSite ? "com.example.app" : "example.com"} />
diff --git a/client/src/components/SiteSettings/SiteSettings.tsx b/client/src/components/SiteSettings/SiteSettings.tsx index fd36b7bca..6b024579e 100644 --- a/client/src/components/SiteSettings/SiteSettings.tsx +++ b/client/src/components/SiteSettings/SiteSettings.tsx @@ -114,13 +114,14 @@ function SiteSettingsInner({ siteMetadata, trigger }: { siteMetadata: SiteRespon } const currentSiteMetadata = { ...siteMetadata, public: sitePublic }; + const isMobileSite = currentSiteMetadata.type === "mobile"; const tabs: { key: TabKey; label: string; icon: React.ComponentType<{ className?: string }>; hidden?: boolean }[] = [ { key: "general", label: t("General"), icon: Settings }, { key: "tracking", label: t("Tracking"), icon: SlidersHorizontal }, { key: "exclusions", label: t("Exclusions"), icon: Ban }, { key: "integrations", label: t("Integrations"), icon: Plug, hidden: !IS_CLOUD }, - { key: "script", label: t("Tracking Script"), icon: Code }, + { key: "script", label: isMobileSite ? t("React Native SDK") : t("Tracking Script"), icon: Code }, { key: "widget-embeds", label: t("Widget Embeds"), icon: LayoutTemplate }, { key: "dashboard-embed", label: t("Dashboard Embed"), icon: LayoutDashboard }, { key: "import", label: t("Import"), icon: Download }, @@ -196,7 +197,13 @@ function SiteSettingsInner({ siteMetadata, trigger }: { siteMetadata: SiteRespon {activeTab === "tracking" && } {activeTab === "exclusions" && } {activeTab === "integrations" && IS_CLOUD && } - {activeTab === "script" && } + {activeTab === "script" && ( + + )} {activeTab === "widget-embeds" && ( )} diff --git a/client/src/components/SiteSettings/TrackingTab.tsx b/client/src/components/SiteSettings/TrackingTab.tsx index 017f6fd15..e14a9fff7 100644 --- a/client/src/components/SiteSettings/TrackingTab.tsx +++ b/client/src/components/SiteSettings/TrackingTab.tsx @@ -33,6 +33,7 @@ interface ToggleConfig { export function TrackingTab({ siteMetadata, disabled = false }: TrackingTabProps) { const t = useExtracted(); const { refetch } = useGetSitesFromOrg(siteMetadata?.organizationId ?? ""); + const isMobileSite = siteMetadata.type === "mobile"; const [toggleStates, setToggleStates] = useState({ sessionReplay: siteMetadata.sessionReplay || false, @@ -92,58 +93,64 @@ export function TrackingTab({ siteMetadata, disabled = false }: TrackingTabProps IS_CLOUD; const analyticsToggles: ToggleConfig[] = [ - ...(!subscription?.planName?.startsWith("appsumo") && !isSubscriptionLoading + ...(!isMobileSite && !subscription?.planName?.startsWith("appsumo") && !isSubscriptionLoading ? [ - { - id: "sessionReplay", - label: t("Session Replay"), - description: t("Record and replay user sessions to understand user behavior"), - value: toggleStates.sessionReplay, - key: "sessionReplay", - enabledMessage: t("Session replay enabled"), - disabledMessage: t("Session replay disabled"), - disabled: sessionReplayDisabled, - badge: Pro, - } as ToggleConfig, - ] + { + id: "sessionReplay", + label: t("Session Replay"), + description: t("Record and replay user sessions to understand user behavior"), + value: toggleStates.sessionReplay, + key: "sessionReplay", + enabledMessage: t("Session replay enabled"), + disabledMessage: t("Session replay disabled"), + disabled: sessionReplayDisabled, + badge: Pro, + } as ToggleConfig, + ] : []), - ...(IS_CLOUD + ...(IS_CLOUD && !isMobileSite ? [ - { - id: "webVitals", - label: t("Web Vitals"), - description: t("Track Core Web Vitals metrics (LCP, CLS, INP, FCP, TTFB)"), - value: toggleStates.webVitals, - key: "webVitals" as keyof SiteResponse, - enabledMessage: t("Web Vitals enabled"), - disabledMessage: t("Web Vitals disabled"), - disabled: standardFeaturesDisabled, - badge: Standard, - } as ToggleConfig, - ] + { + id: "webVitals", + label: t("Web Vitals"), + description: t("Track Core Web Vitals metrics (LCP, CLS, INP, FCP, TTFB)"), + value: toggleStates.webVitals, + key: "webVitals" as keyof SiteResponse, + enabledMessage: t("Web Vitals enabled"), + disabledMessage: t("Web Vitals disabled"), + disabled: standardFeaturesDisabled, + badge: Standard, + } as ToggleConfig, + ] + : []), + ...(!isMobileSite + ? [ + { + id: "trackSpaNavigation", + label: t("SPA Navigation"), + description: t("Automatically track navigation in single-page applications"), + value: toggleStates.trackSpaNavigation, + key: "trackSpaNavigation", + enabledMessage: t("SPA navigation tracking enabled"), + disabledMessage: t("SPA navigation tracking disabled"), + } as ToggleConfig, + { + id: "trackUrlParams", + label: t("URL Parameters"), + description: t("Include query string parameters in page tracking"), + value: toggleStates.trackUrlParams, + key: "trackUrlParams", + enabledMessage: t("URL parameters tracking enabled"), + disabledMessage: t("URL parameters tracking disabled"), + } as ToggleConfig, + ] : []), - { - id: "trackSpaNavigation", - label: t("SPA Navigation"), - description: t("Automatically track navigation in single-page applications"), - value: toggleStates.trackSpaNavigation, - key: "trackSpaNavigation", - enabledMessage: t("SPA navigation tracking enabled"), - disabledMessage: t("SPA navigation tracking disabled"), - }, - { - id: "trackUrlParams", - label: t("URL Parameters"), - description: t("Include query string parameters in page tracking"), - value: toggleStates.trackUrlParams, - key: "trackUrlParams", - enabledMessage: t("URL parameters tracking enabled"), - disabledMessage: t("URL parameters tracking disabled"), - }, { id: "trackInitialPageView", - label: t("Initial Page View"), - description: t("Automatically track the first page view when the script loads"), + label: isMobileSite ? t("Initial Screen View") : t("Initial Page View"), + description: isMobileSite + ? t("Automatically track the initial screen passed to the React Native SDK") + : t("Automatically track the first page view when the script loads"), value: toggleStates.trackInitialPageView, key: "trackInitialPageView", enabledMessage: t("Initial page view tracking enabled"), @@ -152,19 +159,25 @@ export function TrackingTab({ siteMetadata, disabled = false }: TrackingTabProps ]; const autoCaptureToggles: ToggleConfig[] = [ - { - id: "trackOutbound", - label: t("Outbound Links"), - description: t("Track when users click on external links"), - value: toggleStates.trackOutbound, - key: "trackOutbound", - enabledMessage: t("Outbound tracking enabled"), - disabledMessage: t("Outbound tracking disabled"), - }, + ...(!isMobileSite + ? [ + { + id: "trackOutbound", + label: t("Outbound Links"), + description: t("Track when users click on external links"), + value: toggleStates.trackOutbound, + key: "trackOutbound", + enabledMessage: t("Outbound tracking enabled"), + disabledMessage: t("Outbound tracking disabled"), + } as ToggleConfig, + ] + : []), { id: "trackErrors", label: t("Error Tracking"), - description: t("Capture JavaScript errors and exceptions from your site"), + description: isMobileSite + ? t("Allow error events sent by the React Native SDK") + : t("Capture JavaScript errors and exceptions from your site"), value: toggleStates.trackErrors, key: "trackErrors", enabledMessage: t("Error tracking enabled"), @@ -172,39 +185,43 @@ export function TrackingTab({ siteMetadata, disabled = false }: TrackingTabProps disabled: standardFeaturesDisabled, badge: Standard, }, - { - id: "trackButtonClicks", - label: t("Button Clicks"), - description: t("Automatically track clicks on all buttons"), - value: toggleStates.trackButtonClicks, - key: "trackButtonClicks", - enabledMessage: t("Button click tracking enabled"), - disabledMessage: t("Button click tracking disabled"), - disabled: standardFeaturesDisabled, - badge: Standard, - }, - { - id: "trackCopy", - label: t("Copy Events"), - description: t("Track when users copy text from your site"), - value: toggleStates.trackCopy, - key: "trackCopy", - enabledMessage: t("Copy tracking enabled"), - disabledMessage: t("Copy tracking disabled"), - disabled: standardFeaturesDisabled, - badge: Standard, - }, - { - id: "trackFormInteractions", - label: t("Form Interactions"), - description: t("Automatically track form submissions and input/select changes"), - value: toggleStates.trackFormInteractions, - key: "trackFormInteractions", - enabledMessage: t("Form interaction tracking enabled"), - disabledMessage: t("Form interaction tracking disabled"), - disabled: standardFeaturesDisabled, - badge: Standard, - }, + ...(!isMobileSite + ? [ + { + id: "trackButtonClicks", + label: t("Button Clicks"), + description: t("Automatically track clicks on all buttons"), + value: toggleStates.trackButtonClicks, + key: "trackButtonClicks", + enabledMessage: t("Button click tracking enabled"), + disabledMessage: t("Button click tracking disabled"), + disabled: standardFeaturesDisabled, + badge: Standard, + } as ToggleConfig, + { + id: "trackCopy", + label: t("Copy Events"), + description: t("Track when users copy text from your site"), + value: toggleStates.trackCopy, + key: "trackCopy", + enabledMessage: t("Copy tracking enabled"), + disabledMessage: t("Copy tracking disabled"), + disabled: standardFeaturesDisabled, + badge: Standard, + } as ToggleConfig, + { + id: "trackFormInteractions", + label: t("Form Interactions"), + description: t("Automatically track form submissions and input/select changes"), + value: toggleStates.trackFormInteractions, + key: "trackFormInteractions", + enabledMessage: t("Form interaction tracking enabled"), + disabledMessage: t("Form interaction tracking disabled"), + disabled: standardFeaturesDisabled, + badge: Standard, + } as ToggleConfig, + ] + : []), ]; const renderToggleSection = (toggles: ToggleConfig[], title: string) => ( diff --git a/docs/content/docs/sdks/meta.json b/docs/content/docs/sdks/meta.json index 7bf725092..546713caf 100644 --- a/docs/content/docs/sdks/meta.json +++ b/docs/content/docs/sdks/meta.json @@ -2,6 +2,7 @@ "title": "SDKs", "pages": [ "web", + "react-native", "node" ] -} \ No newline at end of file +} diff --git a/docs/content/docs/sdks/react-native.mdx b/docs/content/docs/sdks/react-native.mdx new file mode 100644 index 000000000..72dbdadec --- /dev/null +++ b/docs/content/docs/sdks/react-native.mdx @@ -0,0 +1,49 @@ +--- +title: React Native +description: Official React Native SDK for Rybbit Analytics +--- + +## Installation + +```bash +npm install @rybbit/react-native @react-native-async-storage/async-storage +``` + +## Initialization + +```tsx +import AsyncStorage from "@react-native-async-storage/async-storage"; +import rybbit from "@rybbit/react-native"; + +await rybbit.init({ + analyticsHost: "https://app.rybbit.io/api", + siteId: "1", + appIdentifier: "com.example.app", + storage: AsyncStorage, + initialScreenName: "Home", +}); +``` + +## Track Events + +```tsx +await rybbit.event("signup_started", { plan: "pro" }); +await rybbit.identify("user_123", { plan: "pro" }); +await rybbit.error(error); +``` + +## React Navigation + +```tsx +const navigationTracker = rybbit.createNavigationTracker(); + + navigationTracker.onReady(navigationRef.current)} + onStateChange={() => navigationTracker.onStateChange(navigationRef.current)} +> + {/* screens */} + +``` + +Session replay and Web Vitals are web-only features and are not available for React Native apps. diff --git a/react-native/README.md b/react-native/README.md new file mode 100644 index 000000000..80ec1ee6e --- /dev/null +++ b/react-native/README.md @@ -0,0 +1,43 @@ +# @rybbit/react-native + +React Native analytics SDK for Rybbit. + +## Install + +```sh +npm install @rybbit/react-native @react-native-async-storage/async-storage +``` + +## Usage + +```ts +import AsyncStorage from "@react-native-async-storage/async-storage"; +import rybbit from "@rybbit/react-native"; + +await rybbit.init({ + analyticsHost: "https://app.rybbit.io/api", + siteId: "your-site-id", + appIdentifier: "com.example.app", + storage: AsyncStorage, + initialScreenName: "Home", +}); + +await rybbit.event("signup_started", { plan: "pro" }); +await rybbit.identify("user_123", { plan: "pro" }); +``` + +## React Navigation + +```tsx +const navigationTracker = rybbit.createNavigationTracker(); + + navigationTracker.onReady(navigationRef.current)} + onStateChange={() => navigationTracker.onStateChange(navigationRef.current)} +> + {/* screens */} +; +``` + +The SDK uses a generated anonymous install ID stored through the provided storage adapter. Pass AsyncStorage or a compatible storage object for persistence across app launches. diff --git a/react-native/index.d.ts b/react-native/index.d.ts new file mode 100644 index 000000000..30939d029 --- /dev/null +++ b/react-native/index.d.ts @@ -0,0 +1,75 @@ +export type RybbitStorage = { + getItem(key: string): Promise | string | null; + setItem(key: string, value: string): Promise | void; + removeItem(key: string): Promise | void; +}; + +export type RybbitConfig = { + analyticsHost: string; + siteId: string | number; + appIdentifier?: string; + bundleId?: string; + appVersion?: string; + tag?: string; + storage?: RybbitStorage; + storageKeyPrefix?: string; + debug?: boolean; + autoTrackAppLifecycle?: boolean; + initialScreenName?: string; + configTimeoutMs?: number; + maxQueueSize?: number; + fetch?: typeof fetch; +}; + +export type TrackContext = { + appIdentifier?: string; + pathname?: string; + querystring?: string; + screen?: string; + title?: string; + referrer?: string; + language?: string; + userAgent?: string; +}; + +export type TrackProperties = Record; + +export type NavigationRoute = { + name?: string; + path?: string; + params?: Record; +}; + +export type NavigationRef = { + getCurrentRoute?: () => NavigationRoute | undefined; +}; + +export type NavigationTrackerOptions = { + includeRouteParams?: boolean; + getRouteName?: (route: NavigationRoute | undefined) => string; + getPath?: (route: NavigationRoute | undefined) => string; +}; + +export type NavigationTracker = { + onReady(navigationRef: NavigationRef): Promise; + onStateChange(navigationRef: NavigationRef): Promise; + trackCurrentRoute(navigationRef: NavigationRef): Promise; +}; + +export class RybbitReactNative { + init(config: RybbitConfig): Promise; + screen(name: string, properties?: TrackProperties, context?: TrackContext): Promise; + pageview(path?: string, context?: TrackContext): Promise; + event(name: string, properties?: TrackProperties, context?: TrackContext): Promise; + error(error: Error | unknown, properties?: TrackProperties, context?: TrackContext): Promise; + identify(userId: string, traits?: Record): Promise; + setTraits(traits: Record): Promise; + clearUserId(): Promise; + getUserId(): string | null; + flush(): Promise; + createNavigationTracker(options?: NavigationTrackerOptions): NavigationTracker; + cleanup(): void; +} + +declare const rybbit: RybbitReactNative; +export default rybbit; diff --git a/react-native/index.js b/react-native/index.js new file mode 100644 index 000000000..84b44871a --- /dev/null +++ b/react-native/index.js @@ -0,0 +1,427 @@ +"use strict"; + +const SDK_VERSION = "0.1.0"; +const DEFAULT_CONFIG_TIMEOUT_MS = 3000; +const DEFAULT_MAX_QUEUE_SIZE = 100; + +let ReactNativeModule; + +function getReactNative() { + if (!ReactNativeModule) { + ReactNativeModule = require("react-native"); + } + return ReactNativeModule; +} + +function trimTrailingSlash(value) { + return value.replace(/\/+$/, ""); +} + +function createMemoryStorage() { + const values = new Map(); + return { + async getItem(key) { + return values.has(key) ? values.get(key) : null; + }, + async setItem(key, value) { + values.set(key, value); + }, + async removeItem(key) { + values.delete(key); + }, + }; +} + +function generateId() { + const randomPart = Math.random().toString(36).slice(2); + const timePart = Date.now().toString(36); + return `rn_${timePart}_${randomPart}`; +} + +function withTimeout(promise, timeoutMs) { + let timeoutId; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error("Request timed out")), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timeoutId)); +} + +function asPathname(path) { + if (!path) return "/"; + const value = String(path).trim(); + if (!value) return "/"; + return value.startsWith("/") ? value : `/${value}`; +} + +function getLanguage() { + try { + const { NativeModules, Platform } = getReactNative(); + if (Platform.OS === "ios") { + const settings = NativeModules.SettingsManager?.settings || {}; + return settings.AppleLocale || settings.AppleLanguages?.[0] || ""; + } + return NativeModules.I18nManager?.localeIdentifier || ""; + } catch { + return ""; + } +} + +function getScreenSize() { + try { + const { Dimensions } = getReactNative(); + const screen = Dimensions.get("screen"); + return { + screenWidth: Math.max(1, Math.round(screen.width || 0)), + screenHeight: Math.max(1, Math.round(screen.height || 0)), + }; + } catch { + return { + screenWidth: 1, + screenHeight: 1, + }; + } +} + +function getUserAgent(appVersion) { + try { + const { Platform } = getReactNative(); + if (Platform.OS === "android") { + const version = Platform.Version || ""; + return `Mozilla/5.0 (Linux; Android ${version}) AppleWebKit/537.36 (KHTML, like Gecko) RybbitReactNative/${SDK_VERSION}${appVersion ? ` ${appVersion}` : ""}`; + } + if (Platform.OS === "ios") { + const version = String(Platform.Version || "").replace(/\./g, "_"); + return `Mozilla/5.0 (iPhone; CPU iPhone OS ${version} like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) RybbitReactNative/${SDK_VERSION}${appVersion ? ` ${appVersion}` : ""}`; + } + return `RybbitReactNative/${SDK_VERSION} (${Platform.OS})${appVersion ? ` ${appVersion}` : ""}`; + } catch { + return `RybbitReactNative/${SDK_VERSION}`; + } +} + +class RybbitReactNative { + constructor() { + this.config = null; + this.remoteConfig = {}; + this.storage = createMemoryStorage(); + this.anonymousId = null; + this.userId = null; + this.queue = []; + this.appStateSubscription = null; + } + + async init(config) { + if (!config || !config.analyticsHost || !config.siteId) { + throw new Error("analyticsHost and siteId are required"); + } + + this.config = { + analyticsHost: trimTrailingSlash(config.analyticsHost), + siteId: String(config.siteId), + appIdentifier: config.appIdentifier || config.bundleId || "", + appVersion: config.appVersion || "", + tag: config.tag || "", + storageKeyPrefix: config.storageKeyPrefix || "@rybbit", + debug: !!config.debug, + autoTrackAppLifecycle: config.autoTrackAppLifecycle !== false, + initialScreenName: config.initialScreenName || "", + configTimeoutMs: config.configTimeoutMs || DEFAULT_CONFIG_TIMEOUT_MS, + maxQueueSize: config.maxQueueSize || DEFAULT_MAX_QUEUE_SIZE, + fetch: config.fetch || (typeof fetch === "function" ? fetch : undefined), + }; + this.storage = config.storage || createMemoryStorage(); + + this.anonymousId = await this.getOrCreateAnonymousId(); + this.userId = await this.storage.getItem(this.storageKey("user-id")); + this.remoteConfig = await this.fetchRemoteConfig(); + + if (this.config.autoTrackAppLifecycle) { + this.setupAppLifecycleTracking(); + } + + if (this.config.initialScreenName && this.remoteConfig.trackInitialPageView !== false) { + await this.screen(this.config.initialScreenName); + } + + await this.flush(); + } + + storageKey(name) { + return `${this.config.storageKeyPrefix}:${this.config.siteId}:${name}`; + } + + async getOrCreateAnonymousId() { + const key = this.storageKey("anonymous-id"); + const existing = await this.storage.getItem(key); + if (existing) return existing; + + const nextId = generateId(); + await this.storage.setItem(key, nextId); + return nextId; + } + + async fetchRemoteConfig() { + try { + const response = await withTimeout( + this.config.fetch(`${this.config.analyticsHost}/site/tracking-config/${this.config.siteId}`, { + method: "GET", + }), + this.config.configTimeoutMs + ); + if (!response.ok) return {}; + return await response.json(); + } catch (error) { + this.debug("Failed to fetch tracking config", error); + return {}; + } + } + + setupAppLifecycleTracking() { + try { + const { AppState } = getReactNative(); + let previousState = AppState.currentState; + + if (previousState === "active") { + this.event("app_open").catch(error => this.debug("Failed to track app_open", error)); + } + + this.appStateSubscription?.remove?.(); + this.appStateSubscription = AppState.addEventListener("change", nextState => { + if (previousState !== "active" && nextState === "active") { + this.event("app_open").catch(error => this.debug("Failed to track app_open", error)); + } else if (previousState === "active" && nextState !== "active") { + this.event("app_background", { state: nextState }).catch(error => + this.debug("Failed to track app_background", error) + ); + } + previousState = nextState; + }); + } catch (error) { + this.debug("Failed to setup AppState tracking", error); + } + } + + createBasePayload(context) { + this.ensureInitialized(); + const screenSize = getScreenSize(); + const appIdentifier = context?.appIdentifier || this.config.appIdentifier || this.config.siteId; + + const payload = { + site_id: this.config.siteId, + anonymous_id: this.anonymousId, + hostname: appIdentifier, + pathname: asPathname(context?.pathname || context?.screen || "/"), + querystring: context?.querystring || "", + screenWidth: screenSize.screenWidth, + screenHeight: screenSize.screenHeight, + language: context?.language || getLanguage(), + page_title: context?.title || context?.screen || "", + referrer: context?.referrer || "", + user_agent: context?.userAgent || getUserAgent(this.config.appVersion), + }; + + if (this.userId) payload.user_id = this.userId; + if (this.config.tag) payload.tag = this.config.tag; + + return payload; + } + + async send(payload) { + this.ensureInitialized(); + try { + const response = await this.config.fetch(`${this.config.analyticsHost}/track`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Tracking request failed with ${response.status}`); + } + } catch (error) { + this.enqueue(payload); + this.debug("Failed to send tracking payload", error); + } + } + + enqueue(payload) { + this.queue.push(payload); + if (this.queue.length > this.config.maxQueueSize) { + this.queue.shift(); + } + } + + async flush() { + this.ensureInitialized(); + if (this.queue.length === 0) return; + + const queued = [...this.queue]; + this.queue = []; + for (const payload of queued) { + await this.send(payload); + } + } + + async track(type, eventName, properties, context) { + const payload = { + ...this.createBasePayload(context), + type, + event_name: eventName || "", + }; + + if (properties && Object.keys(properties).length > 0) { + payload.properties = JSON.stringify(properties); + } + + await this.send(payload); + } + + async screen(name, properties, context) { + const pathname = context?.pathname || asPathname(name); + await this.track("pageview", "", properties, { + ...context, + screen: name, + pathname, + title: context?.title || name, + }); + } + + async pageview(path, context) { + await this.track("pageview", "", undefined, { + ...context, + pathname: path || context?.pathname || "/", + title: context?.title || "", + }); + } + + async event(name, properties, context) { + if (!name || typeof name !== "string") { + throw new Error("Event name is required and must be a string"); + } + await this.track("custom_event", name, properties || {}, context); + } + + async error(error, properties, context) { + if (this.remoteConfig.trackErrors === false) return; + const err = error instanceof Error ? error : new Error(String(error)); + await this.track( + "error", + err.name || "Error", + { + message: String(err.message || "Unknown error").slice(0, 500), + stack: String(err.stack || "").slice(0, 2000), + ...(properties || {}), + }, + context + ); + } + + async identify(userId, traits) { + this.ensureInitialized(); + if (!userId || typeof userId !== "string") { + throw new Error("User ID must be a non-empty string"); + } + + this.userId = userId.trim(); + await this.storage.setItem(this.storageKey("user-id"), this.userId); + + await this.sendIdentify(this.userId, traits, true); + } + + async setTraits(traits) { + this.ensureInitialized(); + if (!this.userId) { + throw new Error("Cannot set traits without identifying user first"); + } + await this.sendIdentify(this.userId, traits || {}, false); + } + + async sendIdentify(userId, traits, isNewIdentify) { + try { + const response = await this.config.fetch(`${this.config.analyticsHost}/identify`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + site_id: this.config.siteId, + anonymous_id: this.anonymousId, + user_id: userId, + traits, + is_new_identify: isNewIdentify, + user_agent: getUserAgent(this.config.appVersion), + }), + }); + + if (!response.ok) { + throw new Error(`Identify request failed with ${response.status}`); + } + } catch (error) { + this.debug("Failed to send identify payload", error); + } + } + + async clearUserId() { + this.ensureInitialized(); + this.userId = null; + await this.storage.removeItem(this.storageKey("user-id")); + } + + getUserId() { + return this.userId; + } + + createNavigationTracker(options) { + let previousRouteName = null; + const client = this; + const getRouteName = options?.getRouteName || (route => route?.name || ""); + const getPath = options?.getPath || (route => route?.path || route?.name || ""); + const includeRouteParams = !!options?.includeRouteParams; + + const trackCurrentRoute = async navigationRef => { + const route = navigationRef?.getCurrentRoute?.(); + const routeName = getRouteName(route); + if (!routeName || routeName === previousRouteName) return; + + previousRouteName = routeName; + await client.screen(routeName, includeRouteParams && route?.params ? { routeParams: route.params } : undefined, { + pathname: asPathname(getPath(route)), + screen: routeName, + }); + }; + + return { + onReady: trackCurrentRoute, + onStateChange: trackCurrentRoute, + trackCurrentRoute, + }; + } + + cleanup() { + this.appStateSubscription?.remove?.(); + this.appStateSubscription = null; + } + + ensureInitialized() { + if (!this.config || !this.anonymousId) { + throw new Error("rybbit.init() must be called before tracking"); + } + if (typeof this.config.fetch !== "function") { + throw new Error("No fetch implementation is available"); + } + } + + debug(message, error) { + if (this.config?.debug) { + console.warn(`[Rybbit] ${message}`, error); + } + } +} + +const defaultClient = new RybbitReactNative(); + +module.exports = defaultClient; +module.exports.default = defaultClient; +module.exports.RybbitReactNative = RybbitReactNative; diff --git a/react-native/package.json b/react-native/package.json new file mode 100644 index 000000000..4de59b1dc --- /dev/null +++ b/react-native/package.json @@ -0,0 +1,24 @@ +{ + "name": "@rybbit/react-native", + "version": "0.1.0", + "description": "React Native analytics SDK for Rybbit", + "main": "index.js", + "types": "index.d.ts", + "files": [ + "index.js", + "index.d.ts", + "README.md" + ], + "keywords": [ + "analytics", + "react-native", + "rybbit" + ], + "peerDependencies": { + "react-native": ">=0.72.0" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT" +} diff --git a/server/drizzle/0006_site_type.sql b/server/drizzle/0006_site_type.sql new file mode 100644 index 000000000..7685984ad --- /dev/null +++ b/server/drizzle/0006_site_type.sql @@ -0,0 +1,6 @@ +ALTER TABLE "sites" ADD COLUMN IF NOT EXISTS "type" text;--> statement-breakpoint +DO $$ BEGIN +ALTER TABLE "sites" ADD CONSTRAINT "sites_type_check" CHECK ("type" IS NULL OR "type" IN ('web', 'mobile')); +EXCEPTION +WHEN duplicate_object THEN null; +END $$; diff --git a/server/drizzle/meta/0006_snapshot.json b/server/drizzle/meta/0006_snapshot.json new file mode 100644 index 000000000..eb7f77ea0 --- /dev/null +++ b/server/drizzle/meta/0006_snapshot.json @@ -0,0 +1,2724 @@ +{ + "id": "3987804d-997b-4f52-aeaa-60f3c38025d2", + "prevId": "2f7fdcb4-ca39-48b2-965a-bb095d262fa6", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "accountId": { + "name": "accountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerId": { + "name": "providerId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "accessToken": { + "name": "accessToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refreshToken": { + "name": "refreshToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idToken": { + "name": "idToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accessTokenExpiresAt": { + "name": "accessTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refreshTokenExpiresAt": { + "name": "refreshTokenExpiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.active_sessions": { + "name": "active_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_activity": { + "name": "last_activity", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_regions": { + "name": "agent_regions", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint_url": { + "name": "endpoint_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "last_health_check": { + "name": "last_health_check", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_healthy": { + "name": "is_healthy", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referenceId": { + "name": "referenceId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refillInterval": { + "name": "refillInterval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refillAmount": { + "name": "refillAmount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lastRefillAt": { + "name": "lastRefillAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rateLimitEnabled": { + "name": "rateLimitEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "rateLimitTimeWindow": { + "name": "rateLimitTimeWindow", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rateLimitMax": { + "name": "rateLimitMax", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "requestCount": { + "name": "requestCount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "lastRequest": { + "name": "lastRequest", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "configId": { + "name": "configId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "apikey_referenceId_user_id_fk": { + "name": "apikey_referenceId_user_id_fk", + "tableFrom": "apikey", + "tableTo": "user", + "columnsFrom": ["referenceId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cancellation_feedback": { + "name": "cancellation_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason_details": { + "name": "reason_details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_offer_shown": { + "name": "retention_offer_shown", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retention_offer_accepted": { + "name": "retention_offer_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan_name_at_cancellation": { + "name": "plan_name_at_cancellation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "monthly_event_count_at_cancellation": { + "name": "monthly_event_count_at_cancellation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.funnels": { + "name": "funnels", + "schema": "", + "columns": { + "report_id": { + "name": "report_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "funnels_site_id_sites_site_id_fk": { + "name": "funnels_site_id_sites_site_id_fk", + "tableFrom": "funnels", + "tableTo": "sites", + "columnsFrom": ["site_id"], + "columnsTo": ["site_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "funnels_user_id_user_id_fk": { + "name": "funnels_user_id_user_id_fk", + "tableFrom": "funnels", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "goal_id": { + "name": "goal_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "goal_type": { + "name": "goal_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "goals_site_id_sites_site_id_fk": { + "name": "goals_site_id_sites_site_id_fk", + "tableFrom": "goals", + "tableTo": "sites", + "columnsFrom": ["site_id"], + "columnsTo": ["site_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gsc_connections": { + "name": "gsc_connections", + "schema": "", + "columns": { + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "gsc_property_url": { + "name": "gsc_property_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "gsc_connections_site_id_sites_site_id_fk": { + "name": "gsc_connections_site_id_sites_site_id_fk", + "tableFrom": "gsc_connections", + "tableTo": "sites", + "columnsFrom": ["site_id"], + "columnsTo": ["site_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.import_status": { + "name": "import_status", + "schema": "", + "columns": { + "import_id": { + "name": "import_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "import_platform_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "imported_events": { + "name": "imported_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_events": { + "name": "skipped_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "invalid_events": { + "name": "invalid_events", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "import_status_site_id_sites_site_id_fk": { + "name": "import_status_site_id_sites_site_id_fk", + "tableFrom": "import_status", + "tableTo": "sites", + "columnsFrom": ["site_id"], + "columnsTo": ["site_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "import_status_organization_id_organization_id_fk": { + "name": "import_status_organization_id_organization_id_fk", + "tableFrom": "import_status", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviterId": { + "name": "inviterId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "has_restricted_site_access": { + "name": "has_restricted_site_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "site_ids": { + "name": "site_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "teamId": { + "name": "teamId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_inviterId_user_id_fk": { + "name": "invitation_inviterId_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviterId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invitation_organizationId_organization_id_fk": { + "name": "invitation_organizationId_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invitation_teamId_team_id_fk": { + "name": "invitation_teamId_team_id_fk", + "tableFrom": "invitation", + "tableTo": "team", + "columnsFrom": ["teamId"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "has_restricted_site_access": { + "name": "has_restricted_site_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "member_organizationId_organization_id_fk": { + "name": "member_organizationId_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "member_userId_user_id_fk": { + "name": "member_userId_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member_site_access": { + "name": "member_site_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "member_site_access_member_idx": { + "name": "member_site_access_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_site_access_site_idx": { + "name": "member_site_access_site_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_site_access_member_id_member_id_fk": { + "name": "member_site_access_member_id_member_id_fk", + "tableFrom": "member_site_access", + "tableTo": "member", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_site_access_site_id_sites_site_id_fk": { + "name": "member_site_access_site_id_sites_site_id_fk", + "tableFrom": "member_site_access", + "tableTo": "sites", + "columnsFrom": ["site_id"], + "columnsTo": ["site_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_site_access_created_by_user_id_fk": { + "name": "member_site_access_created_by_user_id_fk", + "tableFrom": "member_site_access", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "member_site_access_unique": { + "name": "member_site_access_unique", + "nullsNotDistinct": false, + "columns": ["member_id", "site_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_channels": { + "name": "notification_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "monitor_ids": { + "name": "monitor_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trigger_events": { + "name": "trigger_events", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"down\",\"recovery\"]'::jsonb" + }, + "cooldown_minutes": { + "name": "cooldown_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "last_notified_at": { + "name": "last_notified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "notification_channels_organization_id_organization_id_fk": { + "name": "notification_channels_organization_id_organization_id_fk", + "tableFrom": "notification_channels", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "notification_channels_created_by_user_id_fk": { + "name": "notification_channels_created_by_user_id_fk", + "tableFrom": "notification_channels", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "monthlyEventCount": { + "name": "monthlyEventCount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "overMonthlyLimit": { + "name": "overMonthlyLimit", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "approachingLimitNotifiedPeriodStart": { + "name": "approachingLimitNotifiedPeriodStart", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planOverride": { + "name": "planOverride", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_plan": { + "name": "custom_plan", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ipAddress": { + "name": "ipAddress", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userAgent": { + "name": "userAgent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonatedBy": { + "name": "impersonatedBy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activeOrganizationId": { + "name": "activeOrganizationId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activeTeamId": { + "name": "activeTeamId", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sites": { + "name": "sites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "site_id": { + "name": "site_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "embed_enabled": { + "name": "embed_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "saltUserIds": { + "name": "saltUserIds", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "blockBots": { + "name": "blockBots", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "excluded_ips": { + "name": "excluded_ips", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "excluded_countries": { + "name": "excluded_countries", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "sessionReplay": { + "name": "sessionReplay", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "webVitals": { + "name": "webVitals", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "trackErrors": { + "name": "trackErrors", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "trackOutbound": { + "name": "trackOutbound", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "trackUrlParams": { + "name": "trackUrlParams", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "trackInitialPageView": { + "name": "trackInitialPageView", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "trackSpaNavigation": { + "name": "trackSpaNavigation", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "trackIp": { + "name": "trackIp", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "trackButtonClicks": { + "name": "trackButtonClicks", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "trackCopy": { + "name": "trackCopy", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "trackFormInteractions": { + "name": "trackFormInteractions", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_link_key": { + "name": "private_link_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "sites_created_by_user_id_fk": { + "name": "sites_created_by_user_id_fk", + "tableFrom": "sites", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "sites_organization_id_organization_id_fk": { + "name": "sites_organization_id_organization_id_fk", + "tableFrom": "sites", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sites_type_check": { + "name": "sites_type_check", + "value": "\"sites\".\"type\" IS NULL OR \"sites\".\"type\" IN ('web', 'mobile')" + } + }, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organizationId": { + "name": "organizationId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "team_organizationId_organization_id_fk": { + "name": "team_organizationId_organization_id_fk", + "tableFrom": "team", + "tableTo": "organization", + "columnsFrom": ["organizationId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teamMember": { + "name": "teamMember", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "teamId": { + "name": "teamId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "teamMember_teamId_team_id_fk": { + "name": "teamMember_teamId_team_id_fk", + "tableFrom": "teamMember", + "tableTo": "team", + "columnsFrom": ["teamId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teamMember_userId_user_id_fk": { + "name": "teamMember_userId_user_id_fk", + "tableFrom": "teamMember", + "tableTo": "user", + "columnsFrom": ["userId"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_site_access": { + "name": "team_site_access", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_site_access_team_idx": { + "name": "team_site_access_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_site_access_site_idx": { + "name": "team_site_access_site_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_site_access_team_id_team_id_fk": { + "name": "team_site_access_team_id_team_id_fk", + "tableFrom": "team_site_access", + "tableTo": "team", + "columnsFrom": ["team_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_site_access_site_id_sites_site_id_fk": { + "name": "team_site_access_site_id_sites_site_id_fk", + "tableFrom": "team_site_access", + "tableTo": "sites", + "columnsFrom": ["site_id"], + "columnsTo": ["site_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_site_access_unique": { + "name": "team_site_access_unique", + "nullsNotDistinct": false, + "columns": ["team_id", "site_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.telemetry": { + "name": "telemetry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "table_counts": { + "name": "table_counts", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "clickhouse_size_gb": { + "name": "clickhouse_size_gb", + "type": "real", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.uptime_alert_history": { + "name": "uptime_alert_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "alert_id": { + "name": "alert_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "triggered_at": { + "name": "triggered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "alert_data": { + "name": "alert_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "uptime_alert_history_alert_id_uptime_alerts_id_fk": { + "name": "uptime_alert_history_alert_id_uptime_alerts_id_fk", + "tableFrom": "uptime_alert_history", + "tableTo": "uptime_alerts", + "columnsFrom": ["alert_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "uptime_alert_history_monitor_id_uptime_monitors_id_fk": { + "name": "uptime_alert_history_monitor_id_uptime_monitors_id_fk", + "tableFrom": "uptime_alert_history", + "tableTo": "uptime_monitors", + "columnsFrom": ["monitor_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.uptime_alerts": { + "name": "uptime_alerts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "alert_type": { + "name": "alert_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alert_config": { + "name": "alert_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "conditions": { + "name": "conditions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "uptime_alerts_monitor_id_uptime_monitors_id_fk": { + "name": "uptime_alerts_monitor_id_uptime_monitors_id_fk", + "tableFrom": "uptime_alerts", + "tableTo": "uptime_monitors", + "columnsFrom": ["monitor_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.uptime_incidents": { + "name": "uptime_incidents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_type": { + "name": "last_error_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "uptime_incidents_organization_id_organization_id_fk": { + "name": "uptime_incidents_organization_id_organization_id_fk", + "tableFrom": "uptime_incidents", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "uptime_incidents_monitor_id_uptime_monitors_id_fk": { + "name": "uptime_incidents_monitor_id_uptime_monitors_id_fk", + "tableFrom": "uptime_incidents", + "tableTo": "uptime_monitors", + "columnsFrom": ["monitor_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "uptime_incidents_acknowledged_by_user_id_fk": { + "name": "uptime_incidents_acknowledged_by_user_id_fk", + "tableFrom": "uptime_incidents", + "tableTo": "user", + "columnsFrom": ["acknowledged_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "uptime_incidents_resolved_by_user_id_fk": { + "name": "uptime_incidents_resolved_by_user_id_fk", + "tableFrom": "uptime_incidents", + "tableTo": "user", + "columnsFrom": ["resolved_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.uptime_monitor_status": { + "name": "uptime_monitor_status", + "schema": "", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_check_at": { + "name": "next_check_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_status": { + "name": "current_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'unknown'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "consecutive_successes": { + "name": "consecutive_successes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "uptime_percentage_24h": { + "name": "uptime_percentage_24h", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "uptime_percentage_7d": { + "name": "uptime_percentage_7d", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "uptime_percentage_30d": { + "name": "uptime_percentage_30d", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "average_response_time_24h": { + "name": "average_response_time_24h", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "uptime_monitor_status_updated_at_idx": { + "name": "uptime_monitor_status_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "uptime_monitor_status_monitor_id_uptime_monitors_id_fk": { + "name": "uptime_monitor_status_monitor_id_uptime_monitors_id_fk", + "tableFrom": "uptime_monitor_status", + "tableTo": "uptime_monitors", + "columnsFrom": ["monitor_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "uptime_monitor_status_current_status_check": { + "name": "uptime_monitor_status_current_status_check", + "value": "current_status IN ('up', 'down', 'unknown')" + }, + "uptime_monitor_status_uptime_24h_check": { + "name": "uptime_monitor_status_uptime_24h_check", + "value": "uptime_percentage_24h >= 0 AND uptime_percentage_24h <= 100" + }, + "uptime_monitor_status_uptime_7d_check": { + "name": "uptime_monitor_status_uptime_7d_check", + "value": "uptime_percentage_7d >= 0 AND uptime_percentage_7d <= 100" + }, + "uptime_monitor_status_uptime_30d_check": { + "name": "uptime_monitor_status_uptime_30d_check", + "value": "uptime_percentage_30d >= 0 AND uptime_percentage_30d <= 100" + } + }, + "isRLSEnabled": false + }, + "public.uptime_monitors": { + "name": "uptime_monitors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "monitor_type": { + "name": "monitor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "http_config": { + "name": "http_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "tcp_config": { + "name": "tcp_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "validation_rules": { + "name": "validation_rules", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "monitoring_type": { + "name": "monitoring_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'local'" + }, + "selected_regions": { + "name": "selected_regions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[\"local\"]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "uptime_monitors_organization_id_organization_id_fk": { + "name": "uptime_monitors_organization_id_organization_id_fk", + "tableFrom": "uptime_monitors", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "uptime_monitors_created_by_user_id_fk": { + "name": "uptime_monitors_created_by_user_id_fk", + "tableFrom": "uptime_monitors", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "displayUsername": { + "name": "displayUsername", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "banReason": { + "name": "banReason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banExpires": { + "name": "banExpires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "stripeCustomerId": { + "name": "stripeCustomerId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "overMonthlyLimit": { + "name": "overMonthlyLimit", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "monthlyEventCount": { + "name": "monthlyEventCount", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "sendAutoEmailReports": { + "name": "sendAutoEmailReports", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "scheduled_tip_email_ids": { + "name": "scheduled_tip_email_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + }, + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_aliases": { + "name": "user_aliases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "anonymous_id": { + "name": "anonymous_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_aliases_user_idx": { + "name": "user_aliases_user_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_aliases_anon_idx": { + "name": "user_aliases_anon_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "anonymous_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_aliases_site_id_sites_site_id_fk": { + "name": "user_aliases_site_id_sites_site_id_fk", + "tableFrom": "user_aliases", + "tableTo": "sites", + "columnsFrom": ["site_id"], + "columnsTo": ["site_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_aliases_site_anon_unique": { + "name": "user_aliases_site_anon_unique", + "nullsNotDistinct": false, + "columns": ["site_id", "anonymous_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "site_id": { + "name": "site_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "traits": { + "name": "traits", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_profiles_site_idx": { + "name": "user_profiles_site_idx", + "columns": [ + { + "expression": "site_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_profiles_site_id_sites_site_id_fk": { + "name": "user_profiles_site_id_sites_site_id_fk", + "tableFrom": "user_profiles", + "tableTo": "sites", + "columnsFrom": ["site_id"], + "columnsTo": ["site_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_profiles_site_id_user_id_pk": { + "name": "user_profiles_site_id_user_id_pk", + "columns": ["site_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.import_platform_enum": { + "name": "import_platform_enum", + "schema": "public", + "values": ["umami", "simple_analytics", "plausible"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 4a4ffe02a..afacfa0a4 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1778551960948, "tag": "0005_sticky_gressill", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1779019200000, + "tag": "0006_site_type", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/server/src/api/admin/getAdminSites.ts b/server/src/api/admin/getAdminSites.ts index a091eedc2..6aa28f3d6 100644 --- a/server/src/api/admin/getAdminSites.ts +++ b/server/src/api/admin/getAdminSites.ts @@ -97,10 +97,7 @@ export async function getAdminSites(request: FastifyRequest, reply: FastifyReply } // Get goal and funnel counts per site - const goalCounts = await db - .select({ siteId: goals.siteId, count: count() }) - .from(goals) - .groupBy(goals.siteId); + const goalCounts = await db.select({ siteId: goals.siteId, count: count() }).from(goals).groupBy(goals.siteId); const funnelCounts = await db .select({ siteId: funnels.siteId, count: count() }) @@ -128,7 +125,8 @@ export async function getAdminSites(request: FastifyRequest, reply: FastifyReply return { siteId: site.siteId, name: site.name, - domain: site.domain, + type: site.type || "web", + domain: site.domain || "", createdAt: site.createdAt, public: site.public, eventsLast24Hours: siteEventMap24h.get(site.siteId) || 0, diff --git a/server/src/api/sites/addSite.ts b/server/src/api/sites/addSite.ts index d126bfbe0..4e6bfda00 100644 --- a/server/src/api/sites/addSite.ts +++ b/server/src/api/sites/addSite.ts @@ -14,6 +14,7 @@ export async function addSite( Body: { domain: string; name: string; + type?: "web" | "mobile" | null; public?: boolean; saltUserIds?: boolean; blockBots?: boolean; @@ -39,6 +40,7 @@ export async function addSite( const { domain, name, + type, public: isPublic, saltUserIds, blockBots, @@ -58,16 +60,29 @@ export async function addSite( tags, } = request.body; + const siteType = type === "mobile" ? "mobile" : "web"; + // Strip protocol and trailing slash before validation const cleanedDomain = domain.replace(/^https?:\/\//, "").replace(/\/+$/, ""); - // Validate domain format using regex + // Validate domain/app identifier format using regex const domainRegex = /^(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?\.)+\p{L}{2,}$/u; - if (!domainRegex.test(cleanedDomain)) { + const appIdentifierRegex = /^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$/; + if (siteType === "web" && !domainRegex.test(cleanedDomain)) { return reply.status(400).send({ error: "Invalid domain format. Must be a valid domain like example.com or sub.example.com", }); } + if (siteType === "mobile" && !appIdentifierRegex.test(cleanedDomain)) { + return reply.status(400).send({ + error: "Invalid app identifier. Use a bundle/package identifier like com.example.app", + }); + } + if (siteType === "mobile" && (sessionReplay || webVitals)) { + return reply.status(400).send({ + error: "Session replay and Web Vitals are only available for web sites", + }); + } try { const userId = request.user?.id; @@ -111,6 +126,7 @@ export async function addSite( .insert(sites) .values({ id, + type: siteType === "web" ? null : siteType, domain: cleanedDomain, name, createdBy: userId, @@ -120,8 +136,8 @@ export async function addSite( blockBots: blockBots === undefined ? true : blockBots, ...(excludedIPs !== undefined && { excludedIPs }), ...(excludedCountries !== undefined && { excludedCountries }), - ...(sessionReplay !== undefined && { sessionReplay }), - ...(webVitals !== undefined && { webVitals }), + ...(sessionReplay !== undefined && { sessionReplay: siteType === "mobile" ? false : sessionReplay }), + ...(webVitals !== undefined && { webVitals: siteType === "mobile" ? false : webVitals }), ...(trackErrors !== undefined && { trackErrors }), ...(trackOutbound !== undefined && { trackOutbound }), ...(trackUrlParams !== undefined && { trackUrlParams }), diff --git a/server/src/api/sites/getSite.ts b/server/src/api/sites/getSite.ts index a5cf425e0..7231c793f 100644 --- a/server/src/api/sites/getSite.ts +++ b/server/src/api/sites/getSite.ts @@ -30,7 +30,8 @@ export async function getSite(request: FastifyRequest, reply: Fas id: site.id, siteId: site.siteId, name: site.name, - domain: site.domain, + type: site.type || "web", + domain: site.domain || "", createdAt: site.createdAt, updatedAt: site.updatedAt, createdBy: site.createdBy, diff --git a/server/src/api/sites/getSitesFromOrg.ts b/server/src/api/sites/getSitesFromOrg.ts index 932088538..8bb81b054 100644 --- a/server/src/api/sites/getSitesFromOrg.ts +++ b/server/src/api/sites/getSitesFromOrg.ts @@ -2,7 +2,15 @@ import { eq, and, inArray } from "drizzle-orm"; import { FastifyRequest, FastifyReply } from "fastify"; import { clickhouse } from "../../db/clickhouse/clickhouse.js"; import { db } from "../../db/postgres/postgres.js"; -import { sites, member, organization, memberSiteAccess, team, teamMember, teamSiteAccess } from "../../db/postgres/schema.js"; +import { + sites, + member, + organization, + memberSiteAccess, + team, + teamMember, + teamSiteAccess, +} from "../../db/postgres/schema.js"; import { IS_CLOUD, DEFAULT_EVENT_LIMIT } from "../../lib/const.js"; import { getUserIdFromRequest } from "../../lib/auth-utils.js"; import { processResults } from "../analytics/utils/utils.js"; @@ -26,10 +34,10 @@ export async function getSitesFromOrg( const [memberCheck, allSitesData, orgInfo] = await Promise.all([ userId ? db - .select() - .from(member) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) - .limit(1) + .select() + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) + .limit(1) : Promise.resolve([]), db.select().from(sites).where(eq(sites.organizationId, organizationId)), db.select().from(organization).where(eq(organization.id, organizationId)).limit(1), @@ -84,9 +92,7 @@ export async function getSitesFromOrg( } // Keep sites that are NOT team-gated OR are in the user's teams - sitesData = sitesData.filter( - site => !teamGatedSiteIds.has(site.siteId) || userTeamSiteIds.has(site.siteId) - ); + sitesData = sitesData.filter(site => !teamGatedSiteIds.has(site.siteId) || userTeamSiteIds.has(site.siteId)); } } @@ -154,6 +160,8 @@ export async function getSitesFromOrg( // Enhance sites data with session counts and subscription info const enhancedSitesData = sitesData.map(site => ({ ...site, + type: site.type || "web", + domain: site.domain || "", sessionsLast24Hours: sessionCountMap.get(site.siteId) || 0, isOwner: memberRecord?.role !== "member", teams: siteTeamMap.get(site.siteId) || [], diff --git a/server/src/api/sites/getTrackingConfig.ts b/server/src/api/sites/getTrackingConfig.ts index 2a8332212..13d144661 100644 --- a/server/src/api/sites/getTrackingConfig.ts +++ b/server/src/api/sites/getTrackingConfig.ts @@ -13,8 +13,9 @@ export async function getTrackingConfig(request: FastifyRequest<{ Params: { site // Return tracking configuration // This endpoint is public since the analytics script needs to fetch it return reply.send({ - sessionReplay: config.sessionReplay || false, - webVitals: config.webVitals || false, + type: config.type, + sessionReplay: config.type === "mobile" ? false : config.sessionReplay || false, + webVitals: config.type === "mobile" ? false : config.webVitals || false, trackErrors: config.trackErrors || false, trackOutbound: config.trackOutbound ?? true, trackUrlParams: config.trackUrlParams ?? true, diff --git a/server/src/api/sites/updateSiteConfig.ts b/server/src/api/sites/updateSiteConfig.ts index 3cd165fec..ca303c79c 100644 --- a/server/src/api/sites/updateSiteConfig.ts +++ b/server/src/api/sites/updateSiteConfig.ts @@ -10,17 +10,12 @@ import { validateIPPattern } from "../../lib/ipUtils.js"; const updateSiteConfigSchema = z.object({ // Site settings name: z.string().min(1).max(255).optional(), + type: z.enum(["web", "mobile"]).nullable().optional(), public: z.boolean().optional(), embedEnabled: z.boolean().optional(), saltUserIds: z.boolean().optional(), blockBots: z.boolean().optional(), - domain: z - .string() - .regex( - /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, - "Invalid domain format. Must be a valid domain like example.com or sub.example.com" - ) - .optional(), + domain: z.string().min(1).max(253).optional(), excludedIPs: z.array(z.string().trim().min(1)).max(100).optional(), excludedCountries: z .array( @@ -87,6 +82,35 @@ export async function updateSiteConfig( return reply.status(404).send({ error: "Site not found" }); } + const nextSiteType = updateData.type === undefined ? site.type || "web" : updateData.type || "web"; + + const nextDomain = updateData.domain ?? site.domain; + const cleanedDomain = nextDomain.replace(/^https?:\/\//, "").replace(/\/+$/, ""); + + if (updateData.domain !== undefined || updateData.type !== undefined) { + const domainRegex = /^(?:[\p{L}\p{N}](?:[\p{L}\p{N}-]{0,61}[\p{L}\p{N}])?\.)+\p{L}{2,}$/u; + const appIdentifierRegex = /^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$/; + if (nextSiteType === "web" && !domainRegex.test(cleanedDomain)) { + return reply.status(400).send({ + success: false, + error: "Invalid domain format. Must be a valid domain like example.com or sub.example.com", + }); + } + if (nextSiteType === "mobile" && !appIdentifierRegex.test(cleanedDomain)) { + return reply.status(400).send({ + success: false, + error: "Invalid app identifier. Use a bundle/package identifier like com.example.app", + }); + } + } + + if (nextSiteType === "mobile" && (updateData.sessionReplay || updateData.webVitals)) { + return reply.status(400).send({ + success: false, + error: "Session replay and Web Vitals are only available for web sites", + }); + } + // Additional validation for excluded IPs if provided if (updateData.excludedIPs) { const validationErrors: string[] = []; @@ -116,7 +140,6 @@ export async function updateSiteConfig( "embedEnabled", "saltUserIds", "blockBots", - "domain", "excludedIPs", "excludedCountries", "tags", @@ -139,6 +162,17 @@ export async function updateSiteConfig( } } + if (updateData.type !== undefined) { + dbUpdateData.type = nextSiteType === "web" ? null : nextSiteType; + } + if (updateData.domain !== undefined) { + dbUpdateData.domain = cleanedDomain; + } + if (nextSiteType === "mobile") { + dbUpdateData.sessionReplay = false; + dbUpdateData.webVitals = false; + } + // Only proceed if there are fields to update if (Object.keys(dbUpdateData).length === 0) { return reply.status(400).send({ @@ -154,7 +188,7 @@ export async function updateSiteConfig( await db.update(sites).set(dbUpdateData).where(eq(sites.siteId, siteId)); // Update the site config cache - await siteConfig.updateConfig(siteId, updateData); + await siteConfig.updateConfig(siteId, dbUpdateData); // Get the updated configuration to return const updatedConfig = await siteConfig.getConfig(siteId); diff --git a/server/src/db/postgres/schema.ts b/server/src/db/postgres/schema.ts index 674e7518e..232241788 100644 --- a/server/src/db/postgres/schema.ts +++ b/server/src/db/postgres/schema.ts @@ -57,37 +57,42 @@ export const verification = pgTable("verification", { }); // Sites table -export const sites = pgTable("sites", { - id: text("id").$defaultFn(() => sql`encode(gen_random_bytes(6), 'hex')`), - // deprecated - keeping as primary key for backwards compatibility - siteId: serial("site_id").primaryKey().notNull(), - name: text("name").notNull(), - domain: text("domain").notNull(), - createdAt: timestamp("created_at", { mode: "string" }).defaultNow(), - updatedAt: timestamp("updated_at", { mode: "string" }).defaultNow(), - createdBy: text("created_by").references(() => user.id, { onDelete: "set null" }), - organizationId: text("organization_id").references(() => organization.id), - public: boolean().default(false), - embedEnabled: boolean("embed_enabled").default(false), - saltUserIds: boolean().default(false), - blockBots: boolean().default(true).notNull(), - excludedIPs: jsonb("excluded_ips").default([]), // Array of IP addresses/ranges to exclude - excludedCountries: jsonb("excluded_countries").default([]), // Array of ISO country codes to exclude (e.g., ["US", "GB"]) - sessionReplay: boolean().default(false), - webVitals: boolean().default(false), - trackErrors: boolean().default(false), - trackOutbound: boolean().default(true), - trackUrlParams: boolean().default(true), - trackInitialPageView: boolean().default(true), - trackSpaNavigation: boolean().default(true), - trackIp: boolean().default(false), - trackButtonClicks: boolean().default(false), - trackCopy: boolean().default(false), - trackFormInteractions: boolean().default(false), - apiKey: text("api_key"), // Format: rb_{64_hex_chars} = 67 chars total - privateLinkKey: text("private_link_key"), - tags: jsonb("tags").default([]).$type(), -}); +export const sites = pgTable( + "sites", + { + id: text("id").$defaultFn(() => sql`encode(gen_random_bytes(6), 'hex')`), + // deprecated - keeping as primary key for backwards compatibility + siteId: serial("site_id").primaryKey().notNull(), + name: text("name").notNull(), + type: text("type").$type<"web" | "mobile" | null>(), + domain: text("domain").notNull(), + createdAt: timestamp("created_at", { mode: "string" }).defaultNow(), + updatedAt: timestamp("updated_at", { mode: "string" }).defaultNow(), + createdBy: text("created_by").references(() => user.id, { onDelete: "set null" }), + organizationId: text("organization_id").references(() => organization.id), + public: boolean().default(false), + embedEnabled: boolean("embed_enabled").default(false), + saltUserIds: boolean().default(false), + blockBots: boolean().default(true).notNull(), + excludedIPs: jsonb("excluded_ips").default([]), // Array of IP addresses/ranges to exclude + excludedCountries: jsonb("excluded_countries").default([]), // Array of ISO country codes to exclude (e.g., ["US", "GB"]) + sessionReplay: boolean().default(false), + webVitals: boolean().default(false), + trackErrors: boolean().default(false), + trackOutbound: boolean().default(true), + trackUrlParams: boolean().default(true), + trackInitialPageView: boolean().default(true), + trackSpaNavigation: boolean().default(true), + trackIp: boolean().default(false), + trackButtonClicks: boolean().default(false), + trackCopy: boolean().default(false), + trackFormInteractions: boolean().default(false), + apiKey: text("api_key"), // Format: rb_{64_hex_chars} = 67 chars total + privateLinkKey: text("private_link_key"), + tags: jsonb("tags").default([]).$type(), + }, + table => [check("sites_type_check", sql`${table.type} IS NULL OR ${table.type} IN ('web', 'mobile')`)] +); // Active sessions table export const activeSessions = pgTable("active_sessions", { diff --git a/server/src/lib/siteConfig.ts b/server/src/lib/siteConfig.ts index d64650a74..0269f35e3 100644 --- a/server/src/lib/siteConfig.ts +++ b/server/src/lib/siteConfig.ts @@ -8,6 +8,7 @@ import { logger } from "./logger/logger.js"; export interface SiteConfigData { id: string | null; siteId: number; + type: "web" | "mobile"; public: boolean; embedEnabled: boolean; saltUserIds: boolean; @@ -62,6 +63,7 @@ class SiteConfig { .select({ id: sites.id, siteId: sites.siteId, + type: sites.type, public: sites.public, embedEnabled: sites.embedEnabled, saltUserIds: sites.saltUserIds, @@ -94,6 +96,7 @@ class SiteConfig { const configData: SiteConfigData = { id: site.id, siteId: site.siteId, + type: site.type || "web", public: site.public || false, embedEnabled: site.embedEnabled || false, saltUserIds: site.saltUserIds || false, diff --git a/server/src/services/tracker/identifyService.ts b/server/src/services/tracker/identifyService.ts index 546fd4c4a..b2c681e58 100644 --- a/server/src/services/tracker/identifyService.ts +++ b/server/src/services/tracker/identifyService.ts @@ -17,7 +17,10 @@ const MAX_TRAITS_SIZE = 2048; // Validation schema for identify requests const identifyPayloadSchema = z.object({ site_id: z.string().min(1), + anonymous_id: z.string().min(1).max(255).optional(), user_id: z.string().min(1).max(255), + ip_address: z.string().ip().optional(), + user_agent: z.string().max(512).optional(), traits: z .record(z.unknown()) .optional() @@ -36,11 +39,7 @@ const identifyPayloadSchema = z.object({ // Anonymous events older than this are unlikely to belong to the identifying user. const BACKFILL_DAYS = 30; -async function backfillIdentifiedUserId( - siteId: number, - anonymousId: string, - userId: string -) { +async function backfillIdentifiedUserId(siteId: number, anonymousId: string, userId: string) { try { const tables = ["events", "session_replay_events", "session_replay_metadata"]; for (const table of tables) { @@ -67,7 +66,7 @@ export async function handleIdentify(request: FastifyRequest, reply: FastifyRepl }); } - const { site_id, user_id, traits, is_new_identify } = validationResult.data; + const { site_id, anonymous_id, user_id, traits, is_new_identify, ip_address, user_agent } = validationResult.data; // Get site configuration const siteConfiguration = await siteConfig.getConfig(site_id); @@ -80,10 +79,13 @@ export async function handleIdentify(request: FastifyRequest, reply: FastifyRepl const siteId = siteConfiguration.siteId; - // Compute anonymous_id from request (same logic as tracking) - const ipAddress = getIpAddress(request); - const userAgent = request.headers["user-agent"] || ""; - const anonymousId = await userIdService.generateUserId(ipAddress, userAgent, siteId); + const anonymousId = anonymous_id + ? await userIdService.generateUserIdFromClientId(anonymous_id, siteId) + : await userIdService.generateUserId( + ip_address || getIpAddress(request), + user_agent || request.headers["user-agent"] || "", + siteId + ); // Create alias if this is a new identify call (links anonymous_id to user_id) if (is_new_identify) { @@ -91,10 +93,7 @@ export async function handleIdentify(request: FastifyRequest, reply: FastifyRepl // discoverable via search/inventory queries even when no traits are set, // and so createdAt reflects identification time rather than first setTraits. try { - await db - .insert(userProfiles) - .values({ siteId, userId: user_id }) - .onConflictDoNothing(); + await db.insert(userProfiles).values({ siteId, userId: user_id }).onConflictDoNothing(); } catch (error) { logger.error({ siteId, userId: user_id, error }, "Error creating user profile shell"); } @@ -132,9 +131,7 @@ export async function handleIdentify(request: FastifyRequest, reply: FastifyRepl // Atomic upsert: merge non-null traits and remove keys explicitly set to null. if (traits && Object.keys(traits).length > 0) { try { - const filteredTraits = Object.fromEntries( - Object.entries(traits).filter(([_, v]) => v !== null) - ); + const filteredTraits = Object.fromEntries(Object.entries(traits).filter(([_, v]) => v !== null)); const nullKeys = Object.entries(traits) .filter(([_, v]) => v === null) .map(([k]) => k); diff --git a/server/src/services/tracker/trackEvent.ts b/server/src/services/tracker/trackEvent.ts index 158331828..7a2eaea6a 100644 --- a/server/src/services/tracker/trackEvent.ts +++ b/server/src/services/tracker/trackEvent.ts @@ -22,6 +22,7 @@ const baseEventFields = { language: z.string().max(35).optional(), page_title: z.string().max(512).optional(), referrer: z.string().max(2048).optional(), + anonymous_id: z.string().min(1).max(255).optional(), user_id: z.string().max(255).optional(), tag: z.string().max(256).optional(), ip_address: z.string().ip().optional(), @@ -172,14 +173,16 @@ export const trackingPayloadSchema = z.discriminatedUnion("type", [ const parsed = JSON.parse(val); if (typeof parsed.sourceElement !== "string") return false; if (parsed.text !== undefined && typeof parsed.text !== "string") return false; - if (parsed.textLength !== undefined && (typeof parsed.textLength !== "number" || parsed.textLength < 0)) return false; + if (parsed.textLength !== undefined && (typeof parsed.textLength !== "number" || parsed.textLength < 0)) + return false; return true; } catch { return false; } }, { - message: "Properties must be valid JSON with copy fields (sourceElement required, text and textLength optional)", + message: + "Properties must be valid JSON with copy fields (sourceElement required, text and textLength optional)", } ), }) @@ -207,7 +210,8 @@ export const trackingPayloadSchema = z.discriminatedUnion("type", [ } }, { - message: "Properties must be valid JSON with form_submit fields (formId, formName, formAction, method, fieldCount required)", + message: + "Properties must be valid JSON with form_submit fields (formId, formName, formAction, method, fieldCount required)", } ), }) @@ -299,10 +303,7 @@ export async function trackEvent(request: FastifyRequest, reply: FastifyReply) { // Client-side bot signal score check const clientBotScore = validatedPayload._bs; if (typeof clientBotScore === "number" && clientBotScore >= CLIENT_BOT_SCORE_THRESHOLD) { - logger.info( - { siteId: validatedPayload.site_id, clientBotScore }, - "Bot request filtered (client signals)" - ); + logger.info({ siteId: validatedPayload.site_id, clientBotScore }, "Bot request filtered (client signals)"); return reply.status(200).send({ success: true, message: "Event not tracked - bot detected using client signals", @@ -316,10 +317,7 @@ export async function trackEvent(request: FastifyRequest, reply: FastifyReply) { userAgent && /Windows NT|Macintosh|X11/.test(userAgent) ) { - logger.info( - { siteId: validatedPayload.site_id, userAgent }, - "Bot request filtered (desktop 800x600)" - ); + logger.info({ siteId: validatedPayload.site_id, userAgent }, "Bot request filtered (desktop 800x600)"); return reply.status(200).send({ success: true, message: "Event not tracked - bot detected using desktop 800x600", diff --git a/server/src/services/tracker/utils.ts b/server/src/services/tracker/utils.ts index 5f696d985..c9368aff0 100644 --- a/server/src/services/tracker/utils.ts +++ b/server/src/services/tracker/utils.ts @@ -94,7 +94,16 @@ export function clearSelfReferrer(referrer: string, hostname: string): string { // Create base tracking payload from request export async function createBasePayload( request: FastifyRequest, - eventType: "pageview" | "custom_event" | "performance" | "error" | "outbound" | "button_click" | "copy" | "form_submit" | "input_change" = "pageview", + eventType: + | "pageview" + | "custom_event" + | "performance" + | "error" + | "outbound" + | "button_click" + | "copy" + | "form_submit" + | "input_change" = "pageview", validatedBody: ValidatedTrackingPayload, siteConfiguration: SiteConfigData ): Promise { @@ -103,12 +112,9 @@ export async function createBasePayload( // Override IP if provided in payload const ipAddress = validatedBody.ip_address || getIpAddress(request); - // Always compute anonymous_id based on IP+UserAgent (device fingerprint) - const anonymousId = await userIdService.generateUserId( - ipAddress, - userAgent, - siteConfiguration.siteId - ); + const anonymousId = validatedBody.anonymous_id + ? await userIdService.generateUserIdFromClientId(validatedBody.anonymous_id, siteConfiguration.siteId) + : await userIdService.generateUserId(ipAddress, userAgent, siteConfiguration.siteId); // userId is always the device fingerprint // identifiedUserId is the custom user ID when provided, empty string otherwise diff --git a/server/src/services/userId/userIdService.ts b/server/src/services/userId/userIdService.ts index a2e3cfb0d..3b6a33e51 100644 --- a/server/src/services/userId/userIdService.ts +++ b/server/src/services/userId/userIdService.ts @@ -66,6 +66,13 @@ class UserIdService { .digest("hex") .substring(0, 12); } + + async generateUserIdFromClientId(clientId: string, siteId: number): Promise { + const config = await siteConfig.getConfig(siteId); + const salt = config?.saltUserIds ? this.getDailySalt() : ""; + + return crypto.createHash("sha256").update(`${siteId}:${clientId}:${salt}`).digest("hex").substring(0, 12); + } } export const userIdService = new UserIdService();