From 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b Mon Sep 17 00:00:00 2001 From: Fuwn <50817549+Fuwn@users.noreply.github.com> Date: Sat, 24 Jan 2026 13:09:50 +0000 Subject: Initial commit Created from https://vercel.com/new --- src/app/(collect)/p/[slug]/route.ts | 68 ++ src/app/(collect)/q/[slug]/route.ts | 61 ++ src/app/(main)/App.tsx | 62 ++ src/app/(main)/MobileNav.tsx | 71 +++ src/app/(main)/SideNav.tsx | 87 +++ src/app/(main)/TopNav.tsx | 26 + src/app/(main)/UpdateNotice.tsx | 61 ++ src/app/(main)/admin/AdminLayout.tsx | 33 + src/app/(main)/admin/AdminNav.tsx | 48 ++ src/app/(main)/admin/layout.tsx | 17 + src/app/(main)/admin/teams/AdminTeamsDataTable.tsx | 19 + src/app/(main)/admin/teams/AdminTeamsPage.tsx | 19 + src/app/(main)/admin/teams/AdminTeamsTable.tsx | 86 +++ .../(main)/admin/teams/[teamId]/AdminTeamPage.tsx | 11 + src/app/(main)/admin/teams/[teamId]/page.tsx | 12 + src/app/(main)/admin/teams/page.tsx | 9 + src/app/(main)/admin/users/UserAddButton.tsx | 32 + src/app/(main)/admin/users/UserAddForm.tsx | 71 +++ src/app/(main)/admin/users/UserDeleteButton.tsx | 35 ++ src/app/(main)/admin/users/UserDeleteForm.tsx | 41 ++ src/app/(main)/admin/users/UsersDataTable.tsx | 14 + src/app/(main)/admin/users/UsersPage.tsx | 24 + src/app/(main)/admin/users/UsersTable.tsx | 84 +++ .../(main)/admin/users/[userId]/UserEditForm.tsx | 73 +++ src/app/(main)/admin/users/[userId]/UserHeader.tsx | 9 + src/app/(main)/admin/users/[userId]/UserPage.tsx | 19 + .../(main)/admin/users/[userId]/UserProvider.tsx | 20 + .../(main)/admin/users/[userId]/UserSettings.tsx | 25 + .../(main)/admin/users/[userId]/UserWebsites.tsx | 15 + src/app/(main)/admin/users/[userId]/page.tsx | 12 + src/app/(main)/admin/users/page.tsx | 9 + .../admin/websites/AdminWebsitesDataTable.tsx | 13 + .../(main)/admin/websites/AdminWebsitesPage.tsx | 19 + .../(main)/admin/websites/AdminWebsitesTable.tsx | 89 +++ .../websites/[websiteId]/AdminWebsitePage.tsx | 14 + src/app/(main)/admin/websites/[websiteId]/page.tsx | 12 + src/app/(main)/admin/websites/page.tsx | 9 + src/app/(main)/boards/BoardAddButton.tsx | 32 + src/app/(main)/boards/BoardAddForm.tsx | 60 ++ src/app/(main)/boards/BoardsPage.tsx | 17 + src/app/(main)/boards/[boardId]/Board.tsx | 10 + src/app/(main)/boards/[boardId]/page.tsx | 12 + src/app/(main)/boards/page.tsx | 10 + .../(main)/console/[websiteId]/TestConsolePage.tsx | 207 +++++++ src/app/(main)/console/[websiteId]/page.tsx | 22 + src/app/(main)/dashboard/DashboardPage.tsx | 17 + src/app/(main)/dashboard/page.tsx | 10 + src/app/(main)/layout.tsx | 18 + src/app/(main)/links/LinkAddButton.tsx | 19 + src/app/(main)/links/LinkDeleteButton.tsx | 57 ++ src/app/(main)/links/LinkEditButton.tsx | 16 + src/app/(main)/links/LinkEditForm.tsx | 148 +++++ src/app/(main)/links/LinkProvider.tsx | 21 + src/app/(main)/links/LinksDataTable.tsx | 14 + src/app/(main)/links/LinksPage.tsx | 26 + src/app/(main)/links/LinksTable.tsx | 51 ++ src/app/(main)/links/[linkId]/LinkControls.tsx | 32 + src/app/(main)/links/[linkId]/LinkHeader.tsx | 19 + src/app/(main)/links/[linkId]/LinkMetricsBar.tsx | 70 +++ src/app/(main)/links/[linkId]/LinkPage.tsx | 34 + src/app/(main)/links/[linkId]/LinkPanels.tsx | 83 +++ src/app/(main)/links/[linkId]/page.tsx | 12 + src/app/(main)/links/page.tsx | 10 + src/app/(main)/pixels/PixelAddButton.tsx | 19 + src/app/(main)/pixels/PixelDeleteButton.tsx | 57 ++ src/app/(main)/pixels/PixelEditButton.tsx | 21 + src/app/(main)/pixels/PixelEditForm.tsx | 129 ++++ src/app/(main)/pixels/PixelProvider.tsx | 21 + src/app/(main)/pixels/PixelsDataTable.tsx | 14 + src/app/(main)/pixels/PixelsPage.tsx | 26 + src/app/(main)/pixels/PixelsTable.tsx | 48 ++ src/app/(main)/pixels/[pixelId]/PixelControls.tsx | 32 + src/app/(main)/pixels/[pixelId]/PixelHeader.tsx | 19 + .../(main)/pixels/[pixelId]/PixelMetricsBar.tsx | 70 +++ src/app/(main)/pixels/[pixelId]/PixelPage.tsx | 34 + src/app/(main)/pixels/[pixelId]/PixelPanels.tsx | 83 +++ src/app/(main)/pixels/[pixelId]/page.tsx | 12 + src/app/(main)/pixels/page.tsx | 10 + src/app/(main)/settings/SettingsLayout.tsx | 26 + src/app/(main)/settings/SettingsNav.tsx | 53 ++ src/app/(main)/settings/layout.tsx | 17 + .../settings/preferences/DateRangeSetting.tsx | 28 + .../settings/preferences/LanguageSetting.tsx | 48 ++ .../settings/preferences/PreferenceSettings.tsx | 36 ++ .../settings/preferences/PreferencesPage.tsx | 22 + .../(main)/settings/preferences/ThemeSetting.tsx | 21 + .../settings/preferences/TimezoneSetting.tsx | 44 ++ src/app/(main)/settings/preferences/page.tsx | 10 + .../settings/profile/PasswordChangeButton.tsx | 29 + .../(main)/settings/profile/PasswordEditForm.tsx | 67 ++ src/app/(main)/settings/profile/ProfileHeader.tsx | 8 + src/app/(main)/settings/profile/ProfilePage.tsx | 22 + .../(main)/settings/profile/ProfileSettings.tsx | 51 ++ src/app/(main)/settings/profile/page.tsx | 10 + .../(main)/settings/teams/TeamsSettingsPage.tsx | 16 + .../settings/teams/[teamId]/TeamSettingsPage.tsx | 11 + src/app/(main)/settings/teams/[teamId]/page.tsx | 12 + src/app/(main)/settings/teams/page.tsx | 10 + .../settings/websites/WebsitesSettingsPage.tsx | 16 + .../websites/[websiteId]/WebsiteSettingsPage.tsx | 16 + .../(main)/settings/websites/[websiteId]/page.tsx | 12 + src/app/(main)/settings/websites/page.tsx | 12 + src/app/(main)/teams/TeamAddForm.tsx | 39 ++ src/app/(main)/teams/TeamJoinForm.tsx | 40 ++ src/app/(main)/teams/TeamLeaveButton.tsx | 41 ++ src/app/(main)/teams/TeamLeaveForm.tsx | 48 ++ src/app/(main)/teams/TeamProvider.tsx | 21 + src/app/(main)/teams/TeamsAddButton.tsx | 33 + src/app/(main)/teams/TeamsDataTable.tsx | 27 + src/app/(main)/teams/TeamsHeader.tsx | 26 + src/app/(main)/teams/TeamsJoinButton.tsx | 31 + src/app/(main)/teams/TeamsPage.tsx | 19 + src/app/(main)/teams/TeamsTable.tsx | 29 + src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx | 40 ++ src/app/(main)/teams/[teamId]/TeamEditForm.tsx | 89 +++ src/app/(main)/teams/[teamId]/TeamManage.tsx | 32 + .../(main)/teams/[teamId]/TeamMemberEditButton.tsx | 46 ++ .../(main)/teams/[teamId]/TeamMemberEditForm.tsx | 62 ++ .../teams/[teamId]/TeamMemberRemoveButton.tsx | 60 ++ .../(main)/teams/[teamId]/TeamMembersDataTable.tsx | 19 + src/app/(main)/teams/[teamId]/TeamMembersTable.tsx | 55 ++ src/app/(main)/teams/[teamId]/TeamSettings.tsx | 49 ++ .../teams/[teamId]/TeamWebsiteRemoveButton.tsx | 25 + .../teams/[teamId]/TeamWebsitesDataTable.tsx | 19 + .../(main)/teams/[teamId]/TeamWebsitesTable.tsx | 50 ++ src/app/(main)/teams/page.tsx | 10 + src/app/(main)/websites/WebsiteAddButton.tsx | 28 + src/app/(main)/websites/WebsiteAddForm.tsx | 60 ++ src/app/(main)/websites/WebsiteProvider.tsx | 27 + src/app/(main)/websites/WebsitesDataTable.tsx | 47 ++ src/app/(main)/websites/WebsitesHeader.tsx | 18 + src/app/(main)/websites/WebsitesPage.tsx | 26 + src/app/(main)/websites/WebsitesTable.tsx | 41 ++ .../(reports)/attribution/Attribution.tsx | 128 ++++ .../(reports)/attribution/AttributionPage.tsx | 63 ++ .../[websiteId]/(reports)/attribution/page.tsx | 12 + .../[websiteId]/(reports)/breakdown/Breakdown.tsx | 91 +++ .../(reports)/breakdown/BreakdownPage.tsx | 51 ++ .../(reports)/breakdown/FieldSelectForm.tsx | 46 ++ .../[websiteId]/(reports)/breakdown/page.tsx | 12 + .../[websiteId]/(reports)/funnels/Funnel.tsx | 134 ++++ .../(reports)/funnels/FunnelAddButton.tsx | 28 + .../(reports)/funnels/FunnelEditForm.tsx | 141 +++++ .../[websiteId]/(reports)/funnels/FunnelsPage.tsx | 36 ++ .../[websiteId]/(reports)/funnels/page.tsx | 12 + .../websites/[websiteId]/(reports)/goals/Goal.tsx | 99 +++ .../[websiteId]/(reports)/goals/GoalAddButton.tsx | 28 + .../[websiteId]/(reports)/goals/GoalEditForm.tsx | 104 ++++ .../[websiteId]/(reports)/goals/GoalsPage.tsx | 36 ++ .../websites/[websiteId]/(reports)/goals/page.tsx | 12 + .../(reports)/journeys/Journey.module.css | 267 ++++++++ .../[websiteId]/(reports)/journeys/Journey.tsx | 294 +++++++++ .../(reports)/journeys/JourneysPage.tsx | 67 ++ .../[websiteId]/(reports)/journeys/page.tsx | 12 + .../[websiteId]/(reports)/retention/Retention.tsx | 140 +++++ .../(reports)/retention/RetentionPage.tsx | 22 + .../[websiteId]/(reports)/retention/page.tsx | 12 + .../[websiteId]/(reports)/revenue/Revenue.tsx | 152 +++++ .../[websiteId]/(reports)/revenue/RevenuePage.tsx | 18 + .../[websiteId]/(reports)/revenue/RevenueTable.tsx | 21 + .../[websiteId]/(reports)/revenue/page.tsx | 12 + .../websites/[websiteId]/(reports)/utm/UTM.tsx | 71 +++ .../websites/[websiteId]/(reports)/utm/UTMPage.tsx | 18 + .../websites/[websiteId]/(reports)/utm/page.tsx | 12 + .../websites/[websiteId]/ExpandedViewModal.tsx | 52 ++ .../(main)/websites/[websiteId]/WebsiteChart.tsx | 61 ++ .../websites/[websiteId]/WebsiteControls.tsx | 40 ++ .../websites/[websiteId]/WebsiteExpandedMenu.tsx | 183 ++++++ .../websites/[websiteId]/WebsiteExpandedView.tsx | 57 ++ .../(main)/websites/[websiteId]/WebsiteHeader.tsx | 57 ++ .../(main)/websites/[websiteId]/WebsiteLayout.tsx | 30 + .../(main)/websites/[websiteId]/WebsiteMenu.tsx | 56 ++ .../websites/[websiteId]/WebsiteMetricsBar.tsx | 88 +++ src/app/(main)/websites/[websiteId]/WebsiteNav.tsx | 180 ++++++ .../(main)/websites/[websiteId]/WebsitePage.tsx | 22 + .../(main)/websites/[websiteId]/WebsitePanels.tsx | 140 +++++ .../(main)/websites/[websiteId]/WebsiteTabs.tsx | 64 ++ .../[websiteId]/cohorts/CohortAddButton.tsx | 21 + .../[websiteId]/cohorts/CohortDeleteButton.tsx | 60 ++ .../[websiteId]/cohorts/CohortEditButton.tsx | 37 ++ .../[websiteId]/cohorts/CohortEditForm.tsx | 135 ++++ .../[websiteId]/cohorts/CohortsDataTable.tsx | 24 + .../websites/[websiteId]/cohorts/CohortsPage.tsx | 16 + .../websites/[websiteId]/cohorts/CohortsTable.tsx | 41 ++ .../(main)/websites/[websiteId]/cohorts/page.tsx | 12 + .../websites/[websiteId]/compare/ComparePage.tsx | 20 + .../websites/[websiteId]/compare/CompareTables.tsx | 171 ++++++ .../(main)/websites/[websiteId]/compare/page.tsx | 12 + .../[websiteId]/events/EventProperties.tsx | 127 ++++ .../[websiteId]/events/EventsDataTable.tsx | 48 ++ .../[websiteId]/events/EventsMetricsBar.tsx | 40 ++ .../websites/[websiteId]/events/EventsPage.tsx | 59 ++ .../websites/[websiteId]/events/EventsTable.tsx | 107 ++++ .../(main)/websites/[websiteId]/events/page.tsx | 12 + src/app/(main)/websites/[websiteId]/layout.tsx | 21 + src/app/(main)/websites/[websiteId]/page.tsx | 12 + .../[websiteId]/realtime/RealtimeCountries.tsx | 31 + .../[websiteId]/realtime/RealtimeHeader.tsx | 17 + .../websites/[websiteId]/realtime/RealtimeLog.tsx | 206 +++++++ .../websites/[websiteId]/realtime/RealtimePage.tsx | 58 ++ .../[websiteId]/realtime/RealtimePaths.tsx | 45 ++ .../[websiteId]/realtime/RealtimeReferrers.tsx | 45 ++ .../(main)/websites/[websiteId]/realtime/page.tsx | 12 + .../[websiteId]/segments/SegmentAddButton.tsx | 21 + .../[websiteId]/segments/SegmentDeleteButton.tsx | 60 ++ .../[websiteId]/segments/SegmentEditButton.tsx | 37 ++ .../[websiteId]/segments/SegmentEditForm.tsx | 86 +++ .../[websiteId]/segments/SegmentsDataTable.tsx | 24 + .../websites/[websiteId]/segments/SegmentsPage.tsx | 16 + .../[websiteId]/segments/SegmentsTable.tsx | 38 ++ .../(main)/websites/[websiteId]/segments/page.tsx | 12 + .../[websiteId]/sessions/SessionActivity.tsx | 94 +++ .../websites/[websiteId]/sessions/SessionData.tsx | 32 + .../websites/[websiteId]/sessions/SessionInfo.tsx | 85 +++ .../websites/[websiteId]/sessions/SessionModal.tsx | 41 ++ .../[websiteId]/sessions/SessionProfile.tsx | 84 +++ .../[websiteId]/sessions/SessionProperties.tsx | 97 +++ .../websites/[websiteId]/sessions/SessionStats.tsx | 21 + .../[websiteId]/sessions/SessionsDataTable.tsx | 15 + .../[websiteId]/sessions/SessionsMetricsBar.tsx | 40 ++ .../websites/[websiteId]/sessions/SessionsPage.tsx | 43 ++ .../[websiteId]/sessions/SessionsTable.tsx | 58 ++ .../(main)/websites/[websiteId]/sessions/page.tsx | 12 + .../websites/[websiteId]/settings/SettingsPage.tsx | 6 + .../websites/[websiteId]/settings/WebsiteData.tsx | 104 ++++ .../[websiteId]/settings/WebsiteDeleteForm.tsx | 40 ++ .../[websiteId]/settings/WebsiteEditForm.tsx | 55 ++ .../[websiteId]/settings/WebsiteResetForm.tsx | 37 ++ .../[websiteId]/settings/WebsiteSettings.tsx | 28 + .../[websiteId]/settings/WebsiteSettingsHeader.tsx | 22 + .../[websiteId]/settings/WebsiteShareForm.tsx | 93 +++ .../[websiteId]/settings/WebsiteTrackingCode.tsx | 40 ++ .../[websiteId]/settings/WebsiteTransferForm.tsx | 102 +++ .../(main)/websites/[websiteId]/settings/page.tsx | 12 + src/app/(main)/websites/page.tsx | 10 + src/app/Providers.tsx | 62 ++ src/app/api/admin/teams/route.ts | 58 ++ src/app/api/admin/users/route.ts | 46 ++ src/app/api/admin/websites/route.ts | 58 ++ src/app/api/auth/login/route.ts | 48 ++ src/app/api/auth/logout/route.ts | 12 + src/app/api/auth/sso/route.ts | 18 + src/app/api/auth/verify/route.ts | 15 + src/app/api/batch/route.ts | 58 ++ src/app/api/config/route.ts | 21 + src/app/api/heartbeat/route.ts | 3 + src/app/api/links/[linkId]/route.ts | 77 +++ src/app/api/links/route.ts | 64 ++ src/app/api/me/password/route.ts | 33 + src/app/api/me/route.ts | 12 + src/app/api/me/teams/route.ts | 23 + src/app/api/me/websites/route.ts | 26 + src/app/api/pixels/[pixelId]/route.ts | 76 +++ src/app/api/pixels/route.ts | 62 ++ src/app/api/realtime/[websiteId]/route.ts | 36 ++ src/app/api/reports/[reportId]/route.ts | 80 +++ src/app/api/reports/attribution/route.ts | 26 + src/app/api/reports/breakdown/route.ts | 26 + src/app/api/reports/funnel/route.ts | 26 + src/app/api/reports/goal/route.ts | 26 + src/app/api/reports/journey/route.ts | 25 + src/app/api/reports/retention/route.ts | 26 + src/app/api/reports/revenue/route.ts | 26 + src/app/api/reports/route.ts | 73 +++ src/app/api/reports/utm/route.ts | 37 ++ src/app/api/scripts/telemetry/route.ts | 28 + src/app/api/send/route.ts | 284 +++++++++ src/app/api/share/[shareId]/route.ts | 19 + src/app/api/teams/[teamId]/links/route.ts | 29 + src/app/api/teams/[teamId]/pixels/route.ts | 29 + src/app/api/teams/[teamId]/route.ts | 71 +++ src/app/api/teams/[teamId]/users/[userId]/route.ts | 85 +++ src/app/api/teams/[teamId]/users/route.ts | 83 +++ src/app/api/teams/[teamId]/websites/route.ts | 29 + src/app/api/teams/join/route.ts | 39 ++ src/app/api/teams/route.ts | 55 ++ src/app/api/users/[userId]/route.ts | 102 +++ src/app/api/users/[userId]/teams/route.ts | 27 + src/app/api/users/[userId]/websites/route.ts | 33 + src/app/api/users/route.ts | 44 ++ src/app/api/websites/[websiteId]/active/route.ts | 25 + .../api/websites/[websiteId]/daterange/route.ts | 25 + .../[websiteId]/event-data/[eventId]/route.ts | 25 + .../[websiteId]/event-data/events/route.ts | 37 ++ .../[websiteId]/event-data/fields/route.ts | 35 ++ .../[websiteId]/event-data/properties/route.ts | 35 ++ .../websites/[websiteId]/event-data/stats/route.ts | 35 ++ .../[websiteId]/event-data/values/route.ts | 41 ++ src/app/api/websites/[websiteId]/events/route.ts | 37 ++ .../websites/[websiteId]/events/series/route.ts | 37 ++ src/app/api/websites/[websiteId]/export/route.ts | 64 ++ .../websites/[websiteId]/metrics/expanded/route.ts | 66 ++ src/app/api/websites/[websiteId]/metrics/route.ts | 66 ++ .../api/websites/[websiteId]/pageviews/route.ts | 72 +++ src/app/api/websites/[websiteId]/reports/route.ts | 46 ++ src/app/api/websites/[websiteId]/reset/route.ts | 25 + src/app/api/websites/[websiteId]/route.ts | 84 +++ .../[websiteId]/segments/[segmentId]/route.ts | 92 +++ src/app/api/websites/[websiteId]/segments/route.ts | 70 +++ .../[websiteId]/session-data/properties/route.ts | 35 ++ .../[websiteId]/session-data/values/route.ts | 40 ++ .../sessions/[sessionId]/activity/route.ts | 33 + .../sessions/[sessionId]/properties/route.ts | 25 + .../[websiteId]/sessions/[sessionId]/route.ts | 25 + src/app/api/websites/[websiteId]/sessions/route.ts | 36 ++ .../websites/[websiteId]/sessions/stats/route.ts | 42 ++ .../websites/[websiteId]/sessions/weekly/route.ts | 36 ++ src/app/api/websites/[websiteId]/stats/route.ts | 43 ++ src/app/api/websites/[websiteId]/transfer/route.ts | 50 ++ src/app/api/websites/[websiteId]/values/route.ts | 50 ++ src/app/api/websites/route.ts | 86 +++ src/app/layout.tsx | 49 ++ src/app/login/LoginForm.tsx | 70 +++ src/app/login/LoginPage.tsx | 11 + src/app/login/page.tsx | 14 + src/app/logout/LogoutPage.tsx | 25 + src/app/logout/page.tsx | 14 + src/app/not-found.tsx | 13 + src/app/page.tsx | 19 + src/app/share/[...shareId]/Footer.tsx | 12 + src/app/share/[...shareId]/Header.tsx | 24 + src/app/share/[...shareId]/SharePage.tsx | 41 ++ src/app/share/[...shareId]/page.tsx | 7 + src/app/sso/SSOPage.tsx | 22 + src/app/sso/page.tsx | 10 + src/assets/add-user.svg | 1 + src/assets/bar-chart.svg | 1 + src/assets/bars.svg | 1 + src/assets/bolt.svg | 1 + src/assets/bookmark.svg | 1 + src/assets/change.svg | 1 + src/assets/compare.svg | 1 + src/assets/dashboard.svg | 1 + src/assets/download.svg | 1 + src/assets/expand.svg | 1 + src/assets/export.svg | 1 + src/assets/flag.svg | 1 + src/assets/funnel.svg | 1 + src/assets/gear.svg | 1 + src/assets/lightbulb.svg | 1 + src/assets/lightning.svg | 1 + src/assets/location.svg | 1 + src/assets/lock.svg | 1 + src/assets/logo-white.svg | 1 + src/assets/logo.svg | 1 + src/assets/magnet.svg | 1 + src/assets/money.svg | 1 + src/assets/network.svg | 1 + src/assets/nodes.svg | 1 + src/assets/overview.svg | 1 + src/assets/path.svg | 1 + src/assets/profile.svg | 1 + src/assets/pushpin.svg | 1 + src/assets/redo.svg | 1 + src/assets/reports.svg | 1 + src/assets/security.svg | 1 + src/assets/speaker.svg | 1 + src/assets/switch.svg | 1 + src/assets/tag.svg | 1 + src/assets/target.svg | 1 + src/assets/visitor.svg | 1 + src/assets/website.svg | 1 + src/components/boards/Board.tsx | 9 + src/components/charts/BarChart.tsx | 131 ++++ src/components/charts/BubbleChart.tsx | 31 + src/components/charts/Chart.tsx | 130 ++++ src/components/charts/ChartTooltip.tsx | 23 + src/components/charts/PieChart.tsx | 31 + src/components/common/ActionForm.tsx | 15 + src/components/common/AnimatedDiv.tsx | 3 + src/components/common/Avatar.tsx | 21 + src/components/common/ConfirmationForm.tsx | 42 ++ src/components/common/DataGrid.tsx | 107 ++++ src/components/common/DateDisplay.tsx | 28 + src/components/common/DateDistance.tsx | 19 + src/components/common/Empty.tsx | 24 + src/components/common/EmptyPlaceholder.tsx | 28 + src/components/common/ErrorBoundary.tsx | 38 ++ src/components/common/ErrorMessage.tsx | 16 + src/components/common/ExternalLink.tsx | 23 + src/components/common/Favicon.tsx | 22 + src/components/common/FilterLink.tsx | 49 ++ src/components/common/FilterRecord.tsx | 117 ++++ src/components/common/GridRow.tsx | 32 + src/components/common/LinkButton.tsx | 41 ++ src/components/common/LoadingPanel.tsx | 71 +++ src/components/common/PageBody.tsx | 42 ++ src/components/common/PageHeader.tsx | 58 ++ src/components/common/Pager.tsx | 60 ++ src/components/common/Panel.tsx | 64 ++ src/components/common/SectionHeader.tsx | 28 + src/components/common/SideMenu.tsx | 80 +++ src/components/common/TypeConfirmationForm.tsx | 55 ++ src/components/common/TypeIcon.tsx | 29 + src/components/hooks/context/useLink.ts | 6 + src/components/hooks/context/usePixel.ts | 6 + src/components/hooks/context/useTeam.ts | 6 + src/components/hooks/context/useUser.ts | 6 + src/components/hooks/context/useWebsite.ts | 6 + src/components/hooks/index.ts | 84 +++ .../hooks/queries/useActiveUsersQuery.ts | 12 + src/components/hooks/queries/useDateRangeQuery.ts | 23 + src/components/hooks/queries/useDeleteQuery.ts | 12 + .../hooks/queries/useEventDataEventsQuery.ts | 27 + .../hooks/queries/useEventDataPropertiesQuery.ts | 27 + src/components/hooks/queries/useEventDataQuery.ts | 27 + .../hooks/queries/useEventDataValuesQuery.ts | 34 + src/components/hooks/queries/useLinkQuery.ts | 15 + src/components/hooks/queries/useLinksQuery.ts | 17 + src/components/hooks/queries/useLoginQuery.ts | 23 + src/components/hooks/queries/usePixelQuery.ts | 15 + src/components/hooks/queries/usePixelsQuery.ts | 17 + src/components/hooks/queries/useRealtimeQuery.ts | 17 + src/components/hooks/queries/useReportQuery.ts | 15 + src/components/hooks/queries/useReportsQuery.ts | 19 + src/components/hooks/queries/useResultQuery.ts | 44 ++ .../hooks/queries/useSessionActivityQuery.ts | 21 + .../hooks/queries/useSessionDataPropertiesQuery.ts | 27 + .../hooks/queries/useSessionDataQuery.ts | 12 + .../hooks/queries/useSessionDataValuesQuery.ts | 32 + src/components/hooks/queries/useShareTokenQuery.ts | 25 + .../hooks/queries/useTeamMembersQuery.ts | 16 + src/components/hooks/queries/useTeamQuery.ts | 17 + .../hooks/queries/useTeamWebsitesQuery.ts | 15 + src/components/hooks/queries/useTeamsQuery.ts | 20 + src/components/hooks/queries/useUpdateQuery.ts | 15 + src/components/hooks/queries/useUserQuery.ts | 17 + src/components/hooks/queries/useUserTeamsQuery.ts | 15 + .../hooks/queries/useUserWebsitesQuery.ts | 31 + src/components/hooks/queries/useUsersQuery.ts | 17 + .../hooks/queries/useWebsiteCohortQuery.ts | 21 + .../hooks/queries/useWebsiteCohortsQuery.ts | 25 + .../hooks/queries/useWebsiteEventsQuery.ts | 39 ++ .../hooks/queries/useWebsiteEventsSeriesQuery.ts | 18 + .../queries/useWebsiteExpandedMetricsQuery.ts | 51 ++ .../hooks/queries/useWebsiteMetricsQuery.ts | 47 ++ .../hooks/queries/useWebsitePageviewsQuery.ts | 36 ++ src/components/hooks/queries/useWebsiteQuery.ts | 17 + .../hooks/queries/useWebsiteSegmentQuery.ts | 21 + .../hooks/queries/useWebsiteSegmentsQuery.ts | 24 + .../hooks/queries/useWebsiteSessionQuery.ts | 13 + .../hooks/queries/useWebsiteSessionStatsQuery.ts | 17 + .../hooks/queries/useWebsiteSessionsQuery.ts | 34 + .../hooks/queries/useWebsiteStatsQuery.ts | 36 ++ .../hooks/queries/useWebsiteValuesQuery.ts | 62 ++ src/components/hooks/queries/useWebsitesQuery.ts | 20 + .../hooks/queries/useWeeklyTrafficQuery.ts | 28 + src/components/hooks/useApi.ts | 67 ++ src/components/hooks/useConfig.ts | 33 + src/components/hooks/useCountryNames.ts | 32 + src/components/hooks/useDateParameters.ts | 18 + src/components/hooks/useDateRange.ts | 37 ++ src/components/hooks/useDocumentClick.ts | 13 + src/components/hooks/useEscapeKey.ts | 19 + src/components/hooks/useFields.ts | 23 + src/components/hooks/useFilterParameters.ts | 70 +++ src/components/hooks/useFilters.ts | 99 +++ src/components/hooks/useForceUpdate.ts | 9 + src/components/hooks/useFormat.ts | 74 +++ src/components/hooks/useGlobalState.ts | 13 + src/components/hooks/useLanguageNames.ts | 32 + src/components/hooks/useLocale.ts | 60 ++ src/components/hooks/useMessages.ts | 48 ++ src/components/hooks/useMobile.ts | 9 + src/components/hooks/useModified.ts | 13 + src/components/hooks/useNavigation.ts | 43 ++ src/components/hooks/usePageParameters.ts | 16 + src/components/hooks/usePagedQuery.ts | 27 + src/components/hooks/useRegionNames.ts | 22 + src/components/hooks/useSlug.ts | 14 + src/components/hooks/useSticky.ts | 25 + src/components/hooks/useTimezone.ts | 95 +++ src/components/icons.ts | 1 + src/components/input/ActionSelect.tsx | 18 + src/components/input/CurrencySelect.tsx | 34 + src/components/input/DateFilter.tsx | 141 +++++ src/components/input/DialogButton.tsx | 64 ++ src/components/input/DownloadButton.tsx | 42 ++ src/components/input/ExportButton.tsx | 64 ++ src/components/input/FieldFilters.tsx | 117 ++++ src/components/input/FilterBar.tsx | 155 +++++ src/components/input/FilterButtons.tsx | 33 + src/components/input/FilterEditForm.tsx | 95 +++ src/components/input/LanguageButton.tsx | 41 ++ src/components/input/LookupField.tsx | 65 ++ src/components/input/MenuButton.tsx | 32 + src/components/input/MobileMenuButton.tsx | 17 + src/components/input/MonthFilter.tsx | 18 + src/components/input/MonthSelect.tsx | 47 ++ src/components/input/NavButton.tsx | 188 ++++++ src/components/input/PanelButton.tsx | 19 + src/components/input/PreferencesButton.tsx | 32 + src/components/input/ProfileButton.tsx | 74 +++ src/components/input/RefreshButton.tsx | 32 + src/components/input/ReportEditButton.tsx | 99 +++ src/components/input/SegmentFilters.tsx | 42 ++ src/components/input/SegmentSaveButton.tsx | 26 + src/components/input/SettingsButton.tsx | 84 +++ src/components/input/WebsiteDateFilter.tsx | 102 +++ src/components/input/WebsiteFilterButton.tsx | 32 + src/components/input/WebsiteSelect.tsx | 74 +++ src/components/messages.ts | 518 ++++++++++++++++ src/components/metrics/ActiveUsers.tsx | 39 ++ src/components/metrics/ChangeLabel.tsx | 60 ++ src/components/metrics/DatePickerForm.tsx | 74 +++ src/components/metrics/EventData.tsx | 22 + src/components/metrics/EventsChart.tsx | 93 +++ src/components/metrics/Legend.tsx | 39 ++ src/components/metrics/ListTable.tsx | 152 +++++ src/components/metrics/MetricCard.tsx | 56 ++ src/components/metrics/MetricLabel.tsx | 142 +++++ src/components/metrics/MetricsBar.tsx | 14 + src/components/metrics/MetricsExpandedTable.tsx | 139 +++++ src/components/metrics/MetricsTable.tsx | 95 +++ src/components/metrics/PageviewsChart.tsx | 98 +++ src/components/metrics/RealtimeChart.tsx | 59 ++ src/components/metrics/WeeklyTraffic.tsx | 112 ++++ src/components/metrics/WorldMap.tsx | 105 ++++ src/components/svg/AddUser.tsx | 16 + src/components/svg/BarChart.tsx | 8 + src/components/svg/Bars.tsx | 8 + src/components/svg/Bolt.tsx | 8 + src/components/svg/Bookmark.tsx | 8 + src/components/svg/Calendar.tsx | 8 + src/components/svg/Change.tsx | 13 + src/components/svg/Clock.tsx | 12 + src/components/svg/Compare.tsx | 8 + src/components/svg/Dashboard.tsx | 21 + src/components/svg/Download.tsx | 9 + src/components/svg/Expand.tsx | 18 + src/components/svg/Export.tsx | 12 + src/components/svg/Flag.tsx | 8 + src/components/svg/Funnel.tsx | 18 + src/components/svg/Gear.tsx | 8 + src/components/svg/Globe.tsx | 8 + src/components/svg/Lightbulb.tsx | 15 + src/components/svg/Lightning.tsx | 33 + src/components/svg/Link.tsx | 8 + src/components/svg/Location.tsx | 8 + src/components/svg/Lock.tsx | 8 + src/components/svg/Logo.tsx | 17 + src/components/svg/LogoWhite.tsx | 26 + src/components/svg/Magnet.tsx | 15 + src/components/svg/Money.tsx | 15 + src/components/svg/Moon.tsx | 8 + src/components/svg/Network.tsx | 15 + src/components/svg/Nodes.tsx | 12 + src/components/svg/Overview.tsx | 8 + src/components/svg/Path.tsx | 15 + src/components/svg/Profile.tsx | 8 + src/components/svg/Pushpin.tsx | 8 + src/components/svg/Redo.tsx | 8 + src/components/svg/Reports.tsx | 8 + src/components/svg/Security.tsx | 16 + src/components/svg/Speaker.tsx | 8 + src/components/svg/Sun.tsx | 9 + src/components/svg/Switch.tsx | 19 + src/components/svg/Tag.tsx | 16 + src/components/svg/Target.tsx | 21 + src/components/svg/Visitor.tsx | 8 + src/components/svg/Website.tsx | 13 + src/components/svg/index.ts | 37 ++ src/declaration.d.ts | 18 + src/index.ts | 82 +++ src/lang/ar-SA.json | 339 ++++++++++ src/lang/be-BY.json | 339 ++++++++++ src/lang/bg-BG.json | 339 ++++++++++ src/lang/bn-BD.json | 339 ++++++++++ src/lang/bs-BA.json | 339 ++++++++++ src/lang/ca-ES.json | 339 ++++++++++ src/lang/cs-CZ.json | 339 ++++++++++ src/lang/da-DK.json | 339 ++++++++++ src/lang/de-CH.json | 339 ++++++++++ src/lang/de-DE.json | 339 ++++++++++ src/lang/el-GR.json | 339 ++++++++++ src/lang/en-GB.json | 339 ++++++++++ src/lang/en-US.json | 339 ++++++++++ src/lang/es-ES.json | 340 ++++++++++ src/lang/fa-IR.json | 339 ++++++++++ src/lang/fi-FI.json | 339 ++++++++++ src/lang/fo-FO.json | 339 ++++++++++ src/lang/fr-FR.json | 341 +++++++++++ src/lang/ga-ES.json | 339 ++++++++++ src/lang/he-IL.json | 339 ++++++++++ src/lang/hi-IN.json | 339 ++++++++++ src/lang/hr-HR.json | 339 ++++++++++ src/lang/hu-HU.json | 339 ++++++++++ src/lang/id-ID.json | 339 ++++++++++ src/lang/it-IT.json | 339 ++++++++++ src/lang/ja-JP.json | 339 ++++++++++ src/lang/km-KH.json | 339 ++++++++++ src/lang/ko-KR.json | 339 ++++++++++ src/lang/lt-LT.json | 339 ++++++++++ src/lang/mn-MN.json | 339 ++++++++++ src/lang/ms-MY.json | 339 ++++++++++ src/lang/my-MM.json | 339 ++++++++++ src/lang/nb-NO.json | 339 ++++++++++ src/lang/nl-NL.json | 339 ++++++++++ src/lang/pl-PL.json | 339 ++++++++++ src/lang/pt-BR.json | 339 ++++++++++ src/lang/pt-PT.json | 339 ++++++++++ src/lang/ro-RO.json | 339 ++++++++++ src/lang/ru-RU.json | 339 ++++++++++ src/lang/si-LK.json | 339 ++++++++++ src/lang/sk-SK.json | 339 ++++++++++ src/lang/sl-SI.json | 318 ++++++++++ src/lang/sv-SE.json | 339 ++++++++++ src/lang/ta-IN.json | 339 ++++++++++ src/lang/th-TH.json | 339 ++++++++++ src/lang/tr-TR.json | 339 ++++++++++ src/lang/uk-UA.json | 339 ++++++++++ src/lang/ur-PK.json | 339 ++++++++++ src/lang/uz-UZ.json | 280 +++++++++ src/lang/vi-VN.json | 280 +++++++++ src/lang/zh-CN.json | 339 ++++++++++ src/lang/zh-TW.json | 339 ++++++++++ src/lib/__tests__/charts.test.ts | 39 ++ src/lib/__tests__/detect.test.ts | 22 + src/lib/__tests__/format.test.ts | 38 ++ src/lib/auth.ts | 80 +++ src/lib/charts.ts | 27 + src/lib/clickhouse.ts | 273 +++++++++ src/lib/client.ts | 14 + src/lib/colors.ts | 91 +++ src/lib/constants.ts | 682 +++++++++++++++++++++ src/lib/crypto.ts | 65 ++ src/lib/data.ts | 94 +++ src/lib/date.ts | 375 +++++++++++ src/lib/db.ts | 40 ++ src/lib/detect.ts | 154 +++++ src/lib/fetch.ts | 58 ++ src/lib/filters.ts | 31 + src/lib/format.ts | 118 ++++ src/lib/generate.ts | 20 + src/lib/ip.ts | 60 ++ src/lib/jwt.ts | 36 ++ src/lib/kafka.ts | 112 ++++ src/lib/lang.ts | 111 ++++ src/lib/load.ts | 40 ++ src/lib/params.ts | 62 ++ src/lib/password.ts | 11 + src/lib/prisma.ts | 368 +++++++++++ src/lib/react.ts | 77 +++ src/lib/redis.ts | 18 + src/lib/request.ts | 145 +++++ src/lib/response.ts | 58 ++ src/lib/schema.ts | 232 +++++++ src/lib/sql.ts | 0 src/lib/storage.ts | 25 + src/lib/types.ts | 143 +++++ src/lib/url.ts | 49 ++ src/lib/utils.ts | 46 ++ src/permissions/index.ts | 6 + src/permissions/link.ts | 64 ++ src/permissions/pixel.ts | 64 ++ src/permissions/report.ts | 27 + src/permissions/team.ts | 68 ++ src/permissions/user.ts | 29 + src/permissions/website.ts | 128 ++++ src/queries/prisma/index.ts | 8 + src/queries/prisma/link.ts | 66 ++ src/queries/prisma/pixel.ts | 60 ++ src/queries/prisma/report.ts | 89 +++ src/queries/prisma/segment.ts | 61 ++ src/queries/prisma/team.ts | 165 +++++ src/queries/prisma/teamUser.ts | 66 ++ src/queries/prisma/user.ts | 206 +++++++ src/queries/prisma/website.ts | 234 +++++++ src/queries/sql/events/getEventData.ts | 63 ++ src/queries/sql/events/getEventDataEvents.ts | 139 +++++ src/queries/sql/events/getEventDataFields.ts | 84 +++ src/queries/sql/events/getEventDataProperties.ts | 88 +++ src/queries/sql/events/getEventDataStats.ts | 90 +++ src/queries/sql/events/getEventDataUsage.ts | 38 ++ src/queries/sql/events/getEventDataValues.ts | 93 +++ src/queries/sql/events/getEventExpandedMetrics.ts | 132 ++++ src/queries/sql/events/getEventMetrics.ts | 97 +++ src/queries/sql/events/getEventStats.ts | 101 +++ src/queries/sql/events/getEventUsage.ts | 38 ++ src/queries/sql/events/getWebsiteEvents.ts | 119 ++++ src/queries/sql/events/saveEvent.ts | 249 ++++++++ src/queries/sql/events/saveEventData.ts | 79 +++ src/queries/sql/events/saveRevenue.ts | 36 ++ src/queries/sql/getActiveVisitors.ts | 50 ++ src/queries/sql/getChannelExpandedMetrics.ts | 190 ++++++ src/queries/sql/getChannelMetrics.ts | 142 +++++ src/queries/sql/getRealtimeActivity.ts | 80 +++ src/queries/sql/getRealtimeData.ts | 78 +++ src/queries/sql/getValues.ts | 129 ++++ src/queries/sql/getWebsiteDateRange.ts | 55 ++ src/queries/sql/getWebsiteStats.ts | 128 ++++ src/queries/sql/getWeeklyTraffic.ts | 97 +++ src/queries/sql/index.ts | 41 ++ .../sql/pageviews/getPageviewExpandedMetrics.ts | 227 +++++++ src/queries/sql/pageviews/getPageviewMetrics.ts | 191 ++++++ src/queries/sql/pageviews/getPageviewStats.ts | 98 +++ src/queries/sql/reports/getAttribution.ts | 514 ++++++++++++++++ src/queries/sql/reports/getBreakdown.ts | 135 ++++ src/queries/sql/reports/getFunnel.ts | 255 ++++++++ src/queries/sql/reports/getGoal.ts | 105 ++++ src/queries/sql/reports/getJourney.ts | 275 +++++++++ src/queries/sql/reports/getRetention.ts | 173 ++++++ src/queries/sql/reports/getRevenue.ts | 217 +++++++ src/queries/sql/reports/getUTM.ts | 84 +++ src/queries/sql/sessions/createSession.ts | 44 ++ src/queries/sql/sessions/getSessionActivity.ts | 78 +++ src/queries/sql/sessions/getSessionData.ts | 60 ++ .../sql/sessions/getSessionDataProperties.ts | 75 +++ src/queries/sql/sessions/getSessionDataValues.ts | 85 +++ .../sql/sessions/getSessionExpandedMetrics.ts | 152 +++++ src/queries/sql/sessions/getSessionMetrics.ts | 130 ++++ src/queries/sql/sessions/getSessionStats.ts | 98 +++ src/queries/sql/sessions/getWebsiteSession.ts | 113 ++++ src/queries/sql/sessions/getWebsiteSessionStats.ts | 97 +++ src/queries/sql/sessions/getWebsiteSessions.ts | 156 +++++ src/queries/sql/sessions/saveSessionData.ts | 112 ++++ src/store/app.ts | 50 ++ src/store/cache.ts | 9 + src/store/dashboard.ts | 22 + src/store/version.ts | 55 ++ src/store/websites.ts | 35 ++ src/styles/global.css | 43 ++ src/styles/variables.css | 4 + src/tracker/index.d.ts | 153 +++++ src/tracker/index.js | 240 ++++++++ 725 files changed, 51733 insertions(+) create mode 100644 src/app/(collect)/p/[slug]/route.ts create mode 100644 src/app/(collect)/q/[slug]/route.ts create mode 100644 src/app/(main)/App.tsx create mode 100644 src/app/(main)/MobileNav.tsx create mode 100644 src/app/(main)/SideNav.tsx create mode 100644 src/app/(main)/TopNav.tsx create mode 100644 src/app/(main)/UpdateNotice.tsx create mode 100644 src/app/(main)/admin/AdminLayout.tsx create mode 100644 src/app/(main)/admin/AdminNav.tsx create mode 100644 src/app/(main)/admin/layout.tsx create mode 100644 src/app/(main)/admin/teams/AdminTeamsDataTable.tsx create mode 100644 src/app/(main)/admin/teams/AdminTeamsPage.tsx create mode 100644 src/app/(main)/admin/teams/AdminTeamsTable.tsx create mode 100644 src/app/(main)/admin/teams/[teamId]/AdminTeamPage.tsx create mode 100644 src/app/(main)/admin/teams/[teamId]/page.tsx create mode 100644 src/app/(main)/admin/teams/page.tsx create mode 100644 src/app/(main)/admin/users/UserAddButton.tsx create mode 100644 src/app/(main)/admin/users/UserAddForm.tsx create mode 100644 src/app/(main)/admin/users/UserDeleteButton.tsx create mode 100644 src/app/(main)/admin/users/UserDeleteForm.tsx create mode 100644 src/app/(main)/admin/users/UsersDataTable.tsx create mode 100644 src/app/(main)/admin/users/UsersPage.tsx create mode 100644 src/app/(main)/admin/users/UsersTable.tsx create mode 100644 src/app/(main)/admin/users/[userId]/UserEditForm.tsx create mode 100644 src/app/(main)/admin/users/[userId]/UserHeader.tsx create mode 100644 src/app/(main)/admin/users/[userId]/UserPage.tsx create mode 100644 src/app/(main)/admin/users/[userId]/UserProvider.tsx create mode 100644 src/app/(main)/admin/users/[userId]/UserSettings.tsx create mode 100644 src/app/(main)/admin/users/[userId]/UserWebsites.tsx create mode 100644 src/app/(main)/admin/users/[userId]/page.tsx create mode 100644 src/app/(main)/admin/users/page.tsx create mode 100644 src/app/(main)/admin/websites/AdminWebsitesDataTable.tsx create mode 100644 src/app/(main)/admin/websites/AdminWebsitesPage.tsx create mode 100644 src/app/(main)/admin/websites/AdminWebsitesTable.tsx create mode 100644 src/app/(main)/admin/websites/[websiteId]/AdminWebsitePage.tsx create mode 100644 src/app/(main)/admin/websites/[websiteId]/page.tsx create mode 100644 src/app/(main)/admin/websites/page.tsx create mode 100644 src/app/(main)/boards/BoardAddButton.tsx create mode 100644 src/app/(main)/boards/BoardAddForm.tsx create mode 100644 src/app/(main)/boards/BoardsPage.tsx create mode 100644 src/app/(main)/boards/[boardId]/Board.tsx create mode 100644 src/app/(main)/boards/[boardId]/page.tsx create mode 100644 src/app/(main)/boards/page.tsx create mode 100644 src/app/(main)/console/[websiteId]/TestConsolePage.tsx create mode 100644 src/app/(main)/console/[websiteId]/page.tsx create mode 100644 src/app/(main)/dashboard/DashboardPage.tsx create mode 100644 src/app/(main)/dashboard/page.tsx create mode 100644 src/app/(main)/layout.tsx create mode 100644 src/app/(main)/links/LinkAddButton.tsx create mode 100644 src/app/(main)/links/LinkDeleteButton.tsx create mode 100644 src/app/(main)/links/LinkEditButton.tsx create mode 100644 src/app/(main)/links/LinkEditForm.tsx create mode 100644 src/app/(main)/links/LinkProvider.tsx create mode 100644 src/app/(main)/links/LinksDataTable.tsx create mode 100644 src/app/(main)/links/LinksPage.tsx create mode 100644 src/app/(main)/links/LinksTable.tsx create mode 100644 src/app/(main)/links/[linkId]/LinkControls.tsx create mode 100644 src/app/(main)/links/[linkId]/LinkHeader.tsx create mode 100644 src/app/(main)/links/[linkId]/LinkMetricsBar.tsx create mode 100644 src/app/(main)/links/[linkId]/LinkPage.tsx create mode 100644 src/app/(main)/links/[linkId]/LinkPanels.tsx create mode 100644 src/app/(main)/links/[linkId]/page.tsx create mode 100644 src/app/(main)/links/page.tsx create mode 100644 src/app/(main)/pixels/PixelAddButton.tsx create mode 100644 src/app/(main)/pixels/PixelDeleteButton.tsx create mode 100644 src/app/(main)/pixels/PixelEditButton.tsx create mode 100644 src/app/(main)/pixels/PixelEditForm.tsx create mode 100644 src/app/(main)/pixels/PixelProvider.tsx create mode 100644 src/app/(main)/pixels/PixelsDataTable.tsx create mode 100644 src/app/(main)/pixels/PixelsPage.tsx create mode 100644 src/app/(main)/pixels/PixelsTable.tsx create mode 100644 src/app/(main)/pixels/[pixelId]/PixelControls.tsx create mode 100644 src/app/(main)/pixels/[pixelId]/PixelHeader.tsx create mode 100644 src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx create mode 100644 src/app/(main)/pixels/[pixelId]/PixelPage.tsx create mode 100644 src/app/(main)/pixels/[pixelId]/PixelPanels.tsx create mode 100644 src/app/(main)/pixels/[pixelId]/page.tsx create mode 100644 src/app/(main)/pixels/page.tsx create mode 100644 src/app/(main)/settings/SettingsLayout.tsx create mode 100644 src/app/(main)/settings/SettingsNav.tsx create mode 100644 src/app/(main)/settings/layout.tsx create mode 100644 src/app/(main)/settings/preferences/DateRangeSetting.tsx create mode 100644 src/app/(main)/settings/preferences/LanguageSetting.tsx create mode 100644 src/app/(main)/settings/preferences/PreferenceSettings.tsx create mode 100644 src/app/(main)/settings/preferences/PreferencesPage.tsx create mode 100644 src/app/(main)/settings/preferences/ThemeSetting.tsx create mode 100644 src/app/(main)/settings/preferences/TimezoneSetting.tsx create mode 100644 src/app/(main)/settings/preferences/page.tsx create mode 100644 src/app/(main)/settings/profile/PasswordChangeButton.tsx create mode 100644 src/app/(main)/settings/profile/PasswordEditForm.tsx create mode 100644 src/app/(main)/settings/profile/ProfileHeader.tsx create mode 100644 src/app/(main)/settings/profile/ProfilePage.tsx create mode 100644 src/app/(main)/settings/profile/ProfileSettings.tsx create mode 100644 src/app/(main)/settings/profile/page.tsx create mode 100644 src/app/(main)/settings/teams/TeamsSettingsPage.tsx create mode 100644 src/app/(main)/settings/teams/[teamId]/TeamSettingsPage.tsx create mode 100644 src/app/(main)/settings/teams/[teamId]/page.tsx create mode 100644 src/app/(main)/settings/teams/page.tsx create mode 100644 src/app/(main)/settings/websites/WebsitesSettingsPage.tsx create mode 100644 src/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage.tsx create mode 100644 src/app/(main)/settings/websites/[websiteId]/page.tsx create mode 100644 src/app/(main)/settings/websites/page.tsx create mode 100644 src/app/(main)/teams/TeamAddForm.tsx create mode 100644 src/app/(main)/teams/TeamJoinForm.tsx create mode 100644 src/app/(main)/teams/TeamLeaveButton.tsx create mode 100644 src/app/(main)/teams/TeamLeaveForm.tsx create mode 100644 src/app/(main)/teams/TeamProvider.tsx create mode 100644 src/app/(main)/teams/TeamsAddButton.tsx create mode 100644 src/app/(main)/teams/TeamsDataTable.tsx create mode 100644 src/app/(main)/teams/TeamsHeader.tsx create mode 100644 src/app/(main)/teams/TeamsJoinButton.tsx create mode 100644 src/app/(main)/teams/TeamsPage.tsx create mode 100644 src/app/(main)/teams/TeamsTable.tsx create mode 100644 src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx create mode 100644 src/app/(main)/teams/[teamId]/TeamEditForm.tsx create mode 100644 src/app/(main)/teams/[teamId]/TeamManage.tsx create mode 100644 src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx create mode 100644 src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx create mode 100644 src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx create mode 100644 src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx create mode 100644 src/app/(main)/teams/[teamId]/TeamMembersTable.tsx create mode 100644 src/app/(main)/teams/[teamId]/TeamSettings.tsx create mode 100644 src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx create mode 100644 src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx create mode 100644 src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx create mode 100644 src/app/(main)/teams/page.tsx create mode 100644 src/app/(main)/websites/WebsiteAddButton.tsx create mode 100644 src/app/(main)/websites/WebsiteAddForm.tsx create mode 100644 src/app/(main)/websites/WebsiteProvider.tsx create mode 100644 src/app/(main)/websites/WebsitesDataTable.tsx create mode 100644 src/app/(main)/websites/WebsitesHeader.tsx create mode 100644 src/app/(main)/websites/WebsitesPage.tsx create mode 100644 src/app/(main)/websites/WebsitesTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteChart.tsx create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteControls.tsx create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteNav.tsx create mode 100644 src/app/(main)/websites/[websiteId]/WebsitePage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/WebsitePanels.tsx create mode 100644 src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/cohorts/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx create mode 100644 src/app/(main)/websites/[websiteId]/compare/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/events/EventProperties.tsx create mode 100644 src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx create mode 100644 src/app/(main)/websites/[websiteId]/events/EventsPage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/events/EventsTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/events/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/layout.tsx create mode 100644 src/app/(main)/websites/[websiteId]/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx create mode 100644 src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx create mode 100644 src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx create mode 100644 src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx create mode 100644 src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx create mode 100644 src/app/(main)/websites/[websiteId]/realtime/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton.tsx create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/segments/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx create mode 100644 src/app/(main)/websites/[websiteId]/sessions/page.tsx create mode 100644 src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx create mode 100644 src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx create mode 100644 src/app/(main)/websites/[websiteId]/settings/page.tsx create mode 100644 src/app/(main)/websites/page.tsx create mode 100644 src/app/Providers.tsx create mode 100644 src/app/api/admin/teams/route.ts create mode 100644 src/app/api/admin/users/route.ts create mode 100644 src/app/api/admin/websites/route.ts create mode 100644 src/app/api/auth/login/route.ts create mode 100644 src/app/api/auth/logout/route.ts create mode 100644 src/app/api/auth/sso/route.ts create mode 100644 src/app/api/auth/verify/route.ts create mode 100644 src/app/api/batch/route.ts create mode 100644 src/app/api/config/route.ts create mode 100644 src/app/api/heartbeat/route.ts create mode 100644 src/app/api/links/[linkId]/route.ts create mode 100644 src/app/api/links/route.ts create mode 100644 src/app/api/me/password/route.ts create mode 100644 src/app/api/me/route.ts create mode 100644 src/app/api/me/teams/route.ts create mode 100644 src/app/api/me/websites/route.ts create mode 100644 src/app/api/pixels/[pixelId]/route.ts create mode 100644 src/app/api/pixels/route.ts create mode 100644 src/app/api/realtime/[websiteId]/route.ts create mode 100644 src/app/api/reports/[reportId]/route.ts create mode 100644 src/app/api/reports/attribution/route.ts create mode 100644 src/app/api/reports/breakdown/route.ts create mode 100644 src/app/api/reports/funnel/route.ts create mode 100644 src/app/api/reports/goal/route.ts create mode 100644 src/app/api/reports/journey/route.ts create mode 100644 src/app/api/reports/retention/route.ts create mode 100644 src/app/api/reports/revenue/route.ts create mode 100644 src/app/api/reports/route.ts create mode 100644 src/app/api/reports/utm/route.ts create mode 100644 src/app/api/scripts/telemetry/route.ts create mode 100644 src/app/api/send/route.ts create mode 100644 src/app/api/share/[shareId]/route.ts create mode 100644 src/app/api/teams/[teamId]/links/route.ts create mode 100644 src/app/api/teams/[teamId]/pixels/route.ts create mode 100644 src/app/api/teams/[teamId]/route.ts create mode 100644 src/app/api/teams/[teamId]/users/[userId]/route.ts create mode 100644 src/app/api/teams/[teamId]/users/route.ts create mode 100644 src/app/api/teams/[teamId]/websites/route.ts create mode 100644 src/app/api/teams/join/route.ts create mode 100644 src/app/api/teams/route.ts create mode 100644 src/app/api/users/[userId]/route.ts create mode 100644 src/app/api/users/[userId]/teams/route.ts create mode 100644 src/app/api/users/[userId]/websites/route.ts create mode 100644 src/app/api/users/route.ts create mode 100644 src/app/api/websites/[websiteId]/active/route.ts create mode 100644 src/app/api/websites/[websiteId]/daterange/route.ts create mode 100644 src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts create mode 100644 src/app/api/websites/[websiteId]/event-data/events/route.ts create mode 100644 src/app/api/websites/[websiteId]/event-data/fields/route.ts create mode 100644 src/app/api/websites/[websiteId]/event-data/properties/route.ts create mode 100644 src/app/api/websites/[websiteId]/event-data/stats/route.ts create mode 100644 src/app/api/websites/[websiteId]/event-data/values/route.ts create mode 100644 src/app/api/websites/[websiteId]/events/route.ts create mode 100644 src/app/api/websites/[websiteId]/events/series/route.ts create mode 100644 src/app/api/websites/[websiteId]/export/route.ts create mode 100644 src/app/api/websites/[websiteId]/metrics/expanded/route.ts create mode 100644 src/app/api/websites/[websiteId]/metrics/route.ts create mode 100644 src/app/api/websites/[websiteId]/pageviews/route.ts create mode 100644 src/app/api/websites/[websiteId]/reports/route.ts create mode 100644 src/app/api/websites/[websiteId]/reset/route.ts create mode 100644 src/app/api/websites/[websiteId]/route.ts create mode 100644 src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts create mode 100644 src/app/api/websites/[websiteId]/segments/route.ts create mode 100644 src/app/api/websites/[websiteId]/session-data/properties/route.ts create mode 100644 src/app/api/websites/[websiteId]/session-data/values/route.ts create mode 100644 src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts create mode 100644 src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts create mode 100644 src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts create mode 100644 src/app/api/websites/[websiteId]/sessions/route.ts create mode 100644 src/app/api/websites/[websiteId]/sessions/stats/route.ts create mode 100644 src/app/api/websites/[websiteId]/sessions/weekly/route.ts create mode 100644 src/app/api/websites/[websiteId]/stats/route.ts create mode 100644 src/app/api/websites/[websiteId]/transfer/route.ts create mode 100644 src/app/api/websites/[websiteId]/values/route.ts create mode 100644 src/app/api/websites/route.ts create mode 100644 src/app/layout.tsx create mode 100644 src/app/login/LoginForm.tsx create mode 100644 src/app/login/LoginPage.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/logout/LogoutPage.tsx create mode 100644 src/app/logout/page.tsx create mode 100644 src/app/not-found.tsx create mode 100644 src/app/page.tsx create mode 100644 src/app/share/[...shareId]/Footer.tsx create mode 100644 src/app/share/[...shareId]/Header.tsx create mode 100644 src/app/share/[...shareId]/SharePage.tsx create mode 100644 src/app/share/[...shareId]/page.tsx create mode 100644 src/app/sso/SSOPage.tsx create mode 100644 src/app/sso/page.tsx create mode 100644 src/assets/add-user.svg create mode 100644 src/assets/bar-chart.svg create mode 100644 src/assets/bars.svg create mode 100644 src/assets/bolt.svg create mode 100644 src/assets/bookmark.svg create mode 100644 src/assets/change.svg create mode 100644 src/assets/compare.svg create mode 100644 src/assets/dashboard.svg create mode 100644 src/assets/download.svg create mode 100644 src/assets/expand.svg create mode 100644 src/assets/export.svg create mode 100644 src/assets/flag.svg create mode 100644 src/assets/funnel.svg create mode 100644 src/assets/gear.svg create mode 100644 src/assets/lightbulb.svg create mode 100644 src/assets/lightning.svg create mode 100644 src/assets/location.svg create mode 100644 src/assets/lock.svg create mode 100644 src/assets/logo-white.svg create mode 100644 src/assets/logo.svg create mode 100644 src/assets/magnet.svg create mode 100644 src/assets/money.svg create mode 100644 src/assets/network.svg create mode 100644 src/assets/nodes.svg create mode 100644 src/assets/overview.svg create mode 100644 src/assets/path.svg create mode 100644 src/assets/profile.svg create mode 100644 src/assets/pushpin.svg create mode 100644 src/assets/redo.svg create mode 100644 src/assets/reports.svg create mode 100644 src/assets/security.svg create mode 100644 src/assets/speaker.svg create mode 100644 src/assets/switch.svg create mode 100644 src/assets/tag.svg create mode 100644 src/assets/target.svg create mode 100644 src/assets/visitor.svg create mode 100644 src/assets/website.svg create mode 100644 src/components/boards/Board.tsx create mode 100644 src/components/charts/BarChart.tsx create mode 100644 src/components/charts/BubbleChart.tsx create mode 100644 src/components/charts/Chart.tsx create mode 100644 src/components/charts/ChartTooltip.tsx create mode 100644 src/components/charts/PieChart.tsx create mode 100644 src/components/common/ActionForm.tsx create mode 100644 src/components/common/AnimatedDiv.tsx create mode 100644 src/components/common/Avatar.tsx create mode 100644 src/components/common/ConfirmationForm.tsx create mode 100644 src/components/common/DataGrid.tsx create mode 100644 src/components/common/DateDisplay.tsx create mode 100644 src/components/common/DateDistance.tsx create mode 100644 src/components/common/Empty.tsx create mode 100644 src/components/common/EmptyPlaceholder.tsx create mode 100644 src/components/common/ErrorBoundary.tsx create mode 100644 src/components/common/ErrorMessage.tsx create mode 100644 src/components/common/ExternalLink.tsx create mode 100644 src/components/common/Favicon.tsx create mode 100644 src/components/common/FilterLink.tsx create mode 100644 src/components/common/FilterRecord.tsx create mode 100644 src/components/common/GridRow.tsx create mode 100644 src/components/common/LinkButton.tsx create mode 100644 src/components/common/LoadingPanel.tsx create mode 100644 src/components/common/PageBody.tsx create mode 100644 src/components/common/PageHeader.tsx create mode 100644 src/components/common/Pager.tsx create mode 100644 src/components/common/Panel.tsx create mode 100644 src/components/common/SectionHeader.tsx create mode 100644 src/components/common/SideMenu.tsx create mode 100644 src/components/common/TypeConfirmationForm.tsx create mode 100644 src/components/common/TypeIcon.tsx create mode 100644 src/components/hooks/context/useLink.ts create mode 100644 src/components/hooks/context/usePixel.ts create mode 100644 src/components/hooks/context/useTeam.ts create mode 100644 src/components/hooks/context/useUser.ts create mode 100644 src/components/hooks/context/useWebsite.ts create mode 100644 src/components/hooks/index.ts create mode 100644 src/components/hooks/queries/useActiveUsersQuery.ts create mode 100644 src/components/hooks/queries/useDateRangeQuery.ts create mode 100644 src/components/hooks/queries/useDeleteQuery.ts create mode 100644 src/components/hooks/queries/useEventDataEventsQuery.ts create mode 100644 src/components/hooks/queries/useEventDataPropertiesQuery.ts create mode 100644 src/components/hooks/queries/useEventDataQuery.ts create mode 100644 src/components/hooks/queries/useEventDataValuesQuery.ts create mode 100644 src/components/hooks/queries/useLinkQuery.ts create mode 100644 src/components/hooks/queries/useLinksQuery.ts create mode 100644 src/components/hooks/queries/useLoginQuery.ts create mode 100644 src/components/hooks/queries/usePixelQuery.ts create mode 100644 src/components/hooks/queries/usePixelsQuery.ts create mode 100644 src/components/hooks/queries/useRealtimeQuery.ts create mode 100644 src/components/hooks/queries/useReportQuery.ts create mode 100644 src/components/hooks/queries/useReportsQuery.ts create mode 100644 src/components/hooks/queries/useResultQuery.ts create mode 100644 src/components/hooks/queries/useSessionActivityQuery.ts create mode 100644 src/components/hooks/queries/useSessionDataPropertiesQuery.ts create mode 100644 src/components/hooks/queries/useSessionDataQuery.ts create mode 100644 src/components/hooks/queries/useSessionDataValuesQuery.ts create mode 100644 src/components/hooks/queries/useShareTokenQuery.ts create mode 100644 src/components/hooks/queries/useTeamMembersQuery.ts create mode 100644 src/components/hooks/queries/useTeamQuery.ts create mode 100644 src/components/hooks/queries/useTeamWebsitesQuery.ts create mode 100644 src/components/hooks/queries/useTeamsQuery.ts create mode 100644 src/components/hooks/queries/useUpdateQuery.ts create mode 100644 src/components/hooks/queries/useUserQuery.ts create mode 100644 src/components/hooks/queries/useUserTeamsQuery.ts create mode 100644 src/components/hooks/queries/useUserWebsitesQuery.ts create mode 100644 src/components/hooks/queries/useUsersQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteCohortQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteCohortsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteEventsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteMetricsQuery.ts create mode 100644 src/components/hooks/queries/useWebsitePageviewsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteSegmentQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteSegmentsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteSessionQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteSessionStatsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteSessionsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteStatsQuery.ts create mode 100644 src/components/hooks/queries/useWebsiteValuesQuery.ts create mode 100644 src/components/hooks/queries/useWebsitesQuery.ts create mode 100644 src/components/hooks/queries/useWeeklyTrafficQuery.ts create mode 100644 src/components/hooks/useApi.ts create mode 100644 src/components/hooks/useConfig.ts create mode 100644 src/components/hooks/useCountryNames.ts create mode 100644 src/components/hooks/useDateParameters.ts create mode 100644 src/components/hooks/useDateRange.ts create mode 100644 src/components/hooks/useDocumentClick.ts create mode 100644 src/components/hooks/useEscapeKey.ts create mode 100644 src/components/hooks/useFields.ts create mode 100644 src/components/hooks/useFilterParameters.ts create mode 100644 src/components/hooks/useFilters.ts create mode 100644 src/components/hooks/useForceUpdate.ts create mode 100644 src/components/hooks/useFormat.ts create mode 100644 src/components/hooks/useGlobalState.ts create mode 100644 src/components/hooks/useLanguageNames.ts create mode 100644 src/components/hooks/useLocale.ts create mode 100644 src/components/hooks/useMessages.ts create mode 100644 src/components/hooks/useMobile.ts create mode 100644 src/components/hooks/useModified.ts create mode 100644 src/components/hooks/useNavigation.ts create mode 100644 src/components/hooks/usePageParameters.ts create mode 100644 src/components/hooks/usePagedQuery.ts create mode 100644 src/components/hooks/useRegionNames.ts create mode 100644 src/components/hooks/useSlug.ts create mode 100644 src/components/hooks/useSticky.ts create mode 100644 src/components/hooks/useTimezone.ts create mode 100644 src/components/icons.ts create mode 100644 src/components/input/ActionSelect.tsx create mode 100644 src/components/input/CurrencySelect.tsx create mode 100644 src/components/input/DateFilter.tsx create mode 100644 src/components/input/DialogButton.tsx create mode 100644 src/components/input/DownloadButton.tsx create mode 100644 src/components/input/ExportButton.tsx create mode 100644 src/components/input/FieldFilters.tsx create mode 100644 src/components/input/FilterBar.tsx create mode 100644 src/components/input/FilterButtons.tsx create mode 100644 src/components/input/FilterEditForm.tsx create mode 100644 src/components/input/LanguageButton.tsx create mode 100644 src/components/input/LookupField.tsx create mode 100644 src/components/input/MenuButton.tsx create mode 100644 src/components/input/MobileMenuButton.tsx create mode 100644 src/components/input/MonthFilter.tsx create mode 100644 src/components/input/MonthSelect.tsx create mode 100644 src/components/input/NavButton.tsx create mode 100644 src/components/input/PanelButton.tsx create mode 100644 src/components/input/PreferencesButton.tsx create mode 100644 src/components/input/ProfileButton.tsx create mode 100644 src/components/input/RefreshButton.tsx create mode 100644 src/components/input/ReportEditButton.tsx create mode 100644 src/components/input/SegmentFilters.tsx create mode 100644 src/components/input/SegmentSaveButton.tsx create mode 100644 src/components/input/SettingsButton.tsx create mode 100644 src/components/input/WebsiteDateFilter.tsx create mode 100644 src/components/input/WebsiteFilterButton.tsx create mode 100644 src/components/input/WebsiteSelect.tsx create mode 100644 src/components/messages.ts create mode 100644 src/components/metrics/ActiveUsers.tsx create mode 100644 src/components/metrics/ChangeLabel.tsx create mode 100644 src/components/metrics/DatePickerForm.tsx create mode 100644 src/components/metrics/EventData.tsx create mode 100644 src/components/metrics/EventsChart.tsx create mode 100644 src/components/metrics/Legend.tsx create mode 100644 src/components/metrics/ListTable.tsx create mode 100644 src/components/metrics/MetricCard.tsx create mode 100644 src/components/metrics/MetricLabel.tsx create mode 100644 src/components/metrics/MetricsBar.tsx create mode 100644 src/components/metrics/MetricsExpandedTable.tsx create mode 100644 src/components/metrics/MetricsTable.tsx create mode 100644 src/components/metrics/PageviewsChart.tsx create mode 100644 src/components/metrics/RealtimeChart.tsx create mode 100644 src/components/metrics/WeeklyTraffic.tsx create mode 100644 src/components/metrics/WorldMap.tsx create mode 100644 src/components/svg/AddUser.tsx create mode 100644 src/components/svg/BarChart.tsx create mode 100644 src/components/svg/Bars.tsx create mode 100644 src/components/svg/Bolt.tsx create mode 100644 src/components/svg/Bookmark.tsx create mode 100644 src/components/svg/Calendar.tsx create mode 100644 src/components/svg/Change.tsx create mode 100644 src/components/svg/Clock.tsx create mode 100644 src/components/svg/Compare.tsx create mode 100644 src/components/svg/Dashboard.tsx create mode 100644 src/components/svg/Download.tsx create mode 100644 src/components/svg/Expand.tsx create mode 100644 src/components/svg/Export.tsx create mode 100644 src/components/svg/Flag.tsx create mode 100644 src/components/svg/Funnel.tsx create mode 100644 src/components/svg/Gear.tsx create mode 100644 src/components/svg/Globe.tsx create mode 100644 src/components/svg/Lightbulb.tsx create mode 100644 src/components/svg/Lightning.tsx create mode 100644 src/components/svg/Link.tsx create mode 100644 src/components/svg/Location.tsx create mode 100644 src/components/svg/Lock.tsx create mode 100644 src/components/svg/Logo.tsx create mode 100644 src/components/svg/LogoWhite.tsx create mode 100644 src/components/svg/Magnet.tsx create mode 100644 src/components/svg/Money.tsx create mode 100644 src/components/svg/Moon.tsx create mode 100644 src/components/svg/Network.tsx create mode 100644 src/components/svg/Nodes.tsx create mode 100644 src/components/svg/Overview.tsx create mode 100644 src/components/svg/Path.tsx create mode 100644 src/components/svg/Profile.tsx create mode 100644 src/components/svg/Pushpin.tsx create mode 100644 src/components/svg/Redo.tsx create mode 100644 src/components/svg/Reports.tsx create mode 100644 src/components/svg/Security.tsx create mode 100644 src/components/svg/Speaker.tsx create mode 100644 src/components/svg/Sun.tsx create mode 100644 src/components/svg/Switch.tsx create mode 100644 src/components/svg/Tag.tsx create mode 100644 src/components/svg/Target.tsx create mode 100644 src/components/svg/Visitor.tsx create mode 100644 src/components/svg/Website.tsx create mode 100644 src/components/svg/index.ts create mode 100644 src/declaration.d.ts create mode 100644 src/index.ts create mode 100644 src/lang/ar-SA.json create mode 100644 src/lang/be-BY.json create mode 100644 src/lang/bg-BG.json create mode 100644 src/lang/bn-BD.json create mode 100644 src/lang/bs-BA.json create mode 100644 src/lang/ca-ES.json create mode 100644 src/lang/cs-CZ.json create mode 100644 src/lang/da-DK.json create mode 100644 src/lang/de-CH.json create mode 100644 src/lang/de-DE.json create mode 100644 src/lang/el-GR.json create mode 100644 src/lang/en-GB.json create mode 100644 src/lang/en-US.json create mode 100644 src/lang/es-ES.json create mode 100644 src/lang/fa-IR.json create mode 100644 src/lang/fi-FI.json create mode 100644 src/lang/fo-FO.json create mode 100644 src/lang/fr-FR.json create mode 100644 src/lang/ga-ES.json create mode 100644 src/lang/he-IL.json create mode 100644 src/lang/hi-IN.json create mode 100644 src/lang/hr-HR.json create mode 100644 src/lang/hu-HU.json create mode 100644 src/lang/id-ID.json create mode 100644 src/lang/it-IT.json create mode 100644 src/lang/ja-JP.json create mode 100644 src/lang/km-KH.json create mode 100644 src/lang/ko-KR.json create mode 100644 src/lang/lt-LT.json create mode 100644 src/lang/mn-MN.json create mode 100644 src/lang/ms-MY.json create mode 100644 src/lang/my-MM.json create mode 100644 src/lang/nb-NO.json create mode 100644 src/lang/nl-NL.json create mode 100644 src/lang/pl-PL.json create mode 100644 src/lang/pt-BR.json create mode 100644 src/lang/pt-PT.json create mode 100644 src/lang/ro-RO.json create mode 100644 src/lang/ru-RU.json create mode 100644 src/lang/si-LK.json create mode 100644 src/lang/sk-SK.json create mode 100644 src/lang/sl-SI.json create mode 100644 src/lang/sv-SE.json create mode 100644 src/lang/ta-IN.json create mode 100644 src/lang/th-TH.json create mode 100644 src/lang/tr-TR.json create mode 100644 src/lang/uk-UA.json create mode 100644 src/lang/ur-PK.json create mode 100644 src/lang/uz-UZ.json create mode 100644 src/lang/vi-VN.json create mode 100644 src/lang/zh-CN.json create mode 100644 src/lang/zh-TW.json create mode 100644 src/lib/__tests__/charts.test.ts create mode 100644 src/lib/__tests__/detect.test.ts create mode 100644 src/lib/__tests__/format.test.ts create mode 100644 src/lib/auth.ts create mode 100644 src/lib/charts.ts create mode 100644 src/lib/clickhouse.ts create mode 100644 src/lib/client.ts create mode 100644 src/lib/colors.ts create mode 100644 src/lib/constants.ts create mode 100644 src/lib/crypto.ts create mode 100644 src/lib/data.ts create mode 100644 src/lib/date.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/detect.ts create mode 100644 src/lib/fetch.ts create mode 100644 src/lib/filters.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/generate.ts create mode 100644 src/lib/ip.ts create mode 100644 src/lib/jwt.ts create mode 100644 src/lib/kafka.ts create mode 100644 src/lib/lang.ts create mode 100644 src/lib/load.ts create mode 100644 src/lib/params.ts create mode 100644 src/lib/password.ts create mode 100644 src/lib/prisma.ts create mode 100644 src/lib/react.ts create mode 100644 src/lib/redis.ts create mode 100644 src/lib/request.ts create mode 100644 src/lib/response.ts create mode 100644 src/lib/schema.ts create mode 100644 src/lib/sql.ts create mode 100644 src/lib/storage.ts create mode 100644 src/lib/types.ts create mode 100644 src/lib/url.ts create mode 100644 src/lib/utils.ts create mode 100644 src/permissions/index.ts create mode 100644 src/permissions/link.ts create mode 100644 src/permissions/pixel.ts create mode 100644 src/permissions/report.ts create mode 100644 src/permissions/team.ts create mode 100644 src/permissions/user.ts create mode 100644 src/permissions/website.ts create mode 100644 src/queries/prisma/index.ts create mode 100644 src/queries/prisma/link.ts create mode 100644 src/queries/prisma/pixel.ts create mode 100644 src/queries/prisma/report.ts create mode 100644 src/queries/prisma/segment.ts create mode 100644 src/queries/prisma/team.ts create mode 100644 src/queries/prisma/teamUser.ts create mode 100644 src/queries/prisma/user.ts create mode 100644 src/queries/prisma/website.ts create mode 100644 src/queries/sql/events/getEventData.ts create mode 100644 src/queries/sql/events/getEventDataEvents.ts create mode 100644 src/queries/sql/events/getEventDataFields.ts create mode 100644 src/queries/sql/events/getEventDataProperties.ts create mode 100644 src/queries/sql/events/getEventDataStats.ts create mode 100644 src/queries/sql/events/getEventDataUsage.ts create mode 100644 src/queries/sql/events/getEventDataValues.ts create mode 100644 src/queries/sql/events/getEventExpandedMetrics.ts create mode 100644 src/queries/sql/events/getEventMetrics.ts create mode 100644 src/queries/sql/events/getEventStats.ts create mode 100644 src/queries/sql/events/getEventUsage.ts create mode 100644 src/queries/sql/events/getWebsiteEvents.ts create mode 100644 src/queries/sql/events/saveEvent.ts create mode 100644 src/queries/sql/events/saveEventData.ts create mode 100644 src/queries/sql/events/saveRevenue.ts create mode 100644 src/queries/sql/getActiveVisitors.ts create mode 100644 src/queries/sql/getChannelExpandedMetrics.ts create mode 100644 src/queries/sql/getChannelMetrics.ts create mode 100644 src/queries/sql/getRealtimeActivity.ts create mode 100644 src/queries/sql/getRealtimeData.ts create mode 100644 src/queries/sql/getValues.ts create mode 100644 src/queries/sql/getWebsiteDateRange.ts create mode 100644 src/queries/sql/getWebsiteStats.ts create mode 100644 src/queries/sql/getWeeklyTraffic.ts create mode 100644 src/queries/sql/index.ts create mode 100644 src/queries/sql/pageviews/getPageviewExpandedMetrics.ts create mode 100644 src/queries/sql/pageviews/getPageviewMetrics.ts create mode 100644 src/queries/sql/pageviews/getPageviewStats.ts create mode 100644 src/queries/sql/reports/getAttribution.ts create mode 100644 src/queries/sql/reports/getBreakdown.ts create mode 100644 src/queries/sql/reports/getFunnel.ts create mode 100644 src/queries/sql/reports/getGoal.ts create mode 100644 src/queries/sql/reports/getJourney.ts create mode 100644 src/queries/sql/reports/getRetention.ts create mode 100644 src/queries/sql/reports/getRevenue.ts create mode 100644 src/queries/sql/reports/getUTM.ts create mode 100644 src/queries/sql/sessions/createSession.ts create mode 100644 src/queries/sql/sessions/getSessionActivity.ts create mode 100644 src/queries/sql/sessions/getSessionData.ts create mode 100644 src/queries/sql/sessions/getSessionDataProperties.ts create mode 100644 src/queries/sql/sessions/getSessionDataValues.ts create mode 100644 src/queries/sql/sessions/getSessionExpandedMetrics.ts create mode 100644 src/queries/sql/sessions/getSessionMetrics.ts create mode 100644 src/queries/sql/sessions/getSessionStats.ts create mode 100644 src/queries/sql/sessions/getWebsiteSession.ts create mode 100644 src/queries/sql/sessions/getWebsiteSessionStats.ts create mode 100644 src/queries/sql/sessions/getWebsiteSessions.ts create mode 100644 src/queries/sql/sessions/saveSessionData.ts create mode 100644 src/store/app.ts create mode 100644 src/store/cache.ts create mode 100644 src/store/dashboard.ts create mode 100644 src/store/version.ts create mode 100644 src/store/websites.ts create mode 100644 src/styles/global.css create mode 100644 src/styles/variables.css create mode 100644 src/tracker/index.d.ts create mode 100644 src/tracker/index.js (limited to 'src') diff --git a/src/app/(collect)/p/[slug]/route.ts b/src/app/(collect)/p/[slug]/route.ts new file mode 100644 index 0000000..79d6faa --- /dev/null +++ b/src/app/(collect)/p/[slug]/route.ts @@ -0,0 +1,68 @@ +export const dynamic = 'force-dynamic'; + +import { NextResponse } from 'next/server'; +import { POST } from '@/app/api/send/route'; +import type { Pixel } from '@/generated/prisma/client'; +import redis from '@/lib/redis'; +import { notFound } from '@/lib/response'; +import { findPixel } from '@/queries/prisma'; + +const image = Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw', 'base64'); + +export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + + let pixel: Pixel; + + if (redis.enabled) { + pixel = await redis.client.fetch( + `pixel:${slug}`, + async () => { + return findPixel({ + where: { + slug, + }, + }); + }, + 86400, + ); + + if (!pixel) { + return notFound(); + } + } else { + pixel = await findPixel({ + where: { + slug, + }, + }); + + if (!pixel) { + return notFound(); + } + } + + const payload = { + type: 'event', + payload: { + pixel: pixel.id, + url: request.url, + referrer: request.headers.get('referer'), + }, + }; + + const req = new Request(request.url, { + method: 'POST', + headers: request.headers, + body: JSON.stringify(payload), + }); + + await POST(req); + + return new NextResponse(image, { + headers: { + 'Content-Type': 'image/gif', + 'Content-Length': image.length.toString(), + }, + }); +} diff --git a/src/app/(collect)/q/[slug]/route.ts b/src/app/(collect)/q/[slug]/route.ts new file mode 100644 index 0000000..24089bd --- /dev/null +++ b/src/app/(collect)/q/[slug]/route.ts @@ -0,0 +1,61 @@ +export const dynamic = 'force-dynamic'; + +import { NextResponse } from 'next/server'; +import { POST } from '@/app/api/send/route'; +import type { Link } from '@/generated/prisma/client'; +import redis from '@/lib/redis'; +import { notFound } from '@/lib/response'; +import { findLink } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + + let link: Link; + + if (redis.enabled) { + link = await redis.client.fetch( + `link:${slug}`, + async () => { + return findLink({ + where: { + slug, + }, + }); + }, + 86400, + ); + + if (!link) { + return notFound(); + } + } else { + link = await findLink({ + where: { + slug, + }, + }); + + if (!link) { + return notFound(); + } + } + + const payload = { + type: 'event', + payload: { + link: link.id, + url: request.url, + referrer: request.headers.get('referer'), + }, + }; + + const req = new Request(request.url, { + method: 'POST', + headers: request.headers, + body: JSON.stringify(payload), + }); + + await POST(req); + + return NextResponse.redirect(link.url); +} diff --git a/src/app/(main)/App.tsx b/src/app/(main)/App.tsx new file mode 100644 index 0000000..eada680 --- /dev/null +++ b/src/app/(main)/App.tsx @@ -0,0 +1,62 @@ +'use client'; +import { Column, Grid, Loading, Row } from '@umami/react-zen'; +import Script from 'next/script'; +import { useEffect } from 'react'; +import { MobileNav } from '@/app/(main)/MobileNav'; +import { SideNav } from '@/app/(main)/SideNav'; +import { useConfig, useLoginQuery, useNavigation } from '@/components/hooks'; +import { LAST_TEAM_CONFIG } from '@/lib/constants'; +import { removeItem, setItem } from '@/lib/storage'; +import { UpdateNotice } from './UpdateNotice'; + +export function App({ children }) { + const { user, isLoading, error } = useLoginQuery(); + const config = useConfig(); + const { pathname, teamId } = useNavigation(); + + useEffect(() => { + if (teamId) { + setItem(LAST_TEAM_CONFIG, teamId); + } else { + removeItem(LAST_TEAM_CONFIG); + } + }, [teamId]); + + if (isLoading || !config) { + return ; + } + + if (error) { + window.location.href = config.cloudMode + ? `${process.env.cloudUrl}/login` + : `${process.env.basePath || ''}/login`; + return null; + } + + if (!user || !config) { + return null; + } + + return ( + + + + + + + + + {children} + + + {process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && ( + `; + + return ( + + + {formatMessage(messages.trackingCode)} + + + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx new file mode 100644 index 0000000..8af4f05 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx @@ -0,0 +1,102 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + ListItem, + Loading, + Select, + Text, +} from '@umami/react-zen'; +import { type Key, useState } from 'react'; +import { + useLoginQuery, + useMessages, + useUpdateQuery, + useUserTeamsQuery, + useWebsite, +} from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; + +export function WebsiteTransferForm({ + websiteId, + onSave, + onClose, +}: { + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { user } = useLoginQuery(); + const website = useWebsite(); + const [teamId, setTeamId] = useState(null); + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + const { mutateAsync, error, isPending } = useUpdateQuery(`/websites/${websiteId}/transfer`); + const { data: teams, isLoading } = useUserTeamsQuery(user.id); + const isTeamWebsite = !!website?.teamId; + + const items = + teams?.data?.filter(({ members }) => + members.some( + ({ role, userId }) => + [ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id, + ), + ) || []; + + const handleSubmit = async () => { + await mutateAsync( + { + userId: website.teamId ? user.id : undefined, + teamId: website.userId ? teamId : undefined, + }, + { + onSuccess: async () => { + onSave?.(); + onClose?.(); + }, + }, + ); + }; + + const handleChange = (key: Key) => { + setTeamId(key as string); + }; + + if (isLoading) { + return ; + } + + return ( +
+ + {formatMessage( + isTeamWebsite ? messages.transferTeamWebsiteToUser : messages.transferUserWebsiteToTeam, + )} + + + {!isTeamWebsite && ( + + )} + + + + + {formatMessage(labels.transfer)} + + +
+ ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/page.tsx b/src/app/(main)/websites/[websiteId]/settings/page.tsx new file mode 100644 index 0000000..a26d14f --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { SettingsPage } from './SettingsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return ; +} + +export const metadata: Metadata = { + title: 'Settings', +}; diff --git a/src/app/(main)/websites/page.tsx b/src/app/(main)/websites/page.tsx new file mode 100644 index 0000000..cefaf80 --- /dev/null +++ b/src/app/(main)/websites/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { WebsitesPage } from './WebsitesPage'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'Websites', +}; diff --git a/src/app/Providers.tsx b/src/app/Providers.tsx new file mode 100644 index 0000000..ae1a000 --- /dev/null +++ b/src/app/Providers.tsx @@ -0,0 +1,62 @@ +'use client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider, ZenProvider } from '@umami/react-zen'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { IntlProvider } from 'react-intl'; +import { ErrorBoundary } from '@/components/common/ErrorBoundary'; +import { useLocale } from '@/components/hooks'; +import 'chartjs-adapter-date-fns'; + +const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + staleTime: 1000 * 60, + }, + }, +}); + +function MessagesProvider({ children }) { + const { locale, messages, dir } = useLocale(); + + useEffect(() => { + document.documentElement.setAttribute('dir', dir); + document.documentElement.setAttribute('lang', locale); + }, [locale, dir]); + + return ( + null}> + {children} + + ); +} + +export function Providers({ children }) { + const router = useRouter(); + + function navigate(url: string) { + if (shouldUseNativeLink(url)) { + window.location.href = url; + } else { + router.push(url); + } + } + + function shouldUseNativeLink(url: string) { + return url.startsWith('http'); + } + + return ( + + + + + {children} + + + + + ); +} diff --git a/src/app/api/admin/teams/route.ts b/src/app/api/admin/teams/route.ts new file mode 100644 index 0000000..ceb16ab --- /dev/null +++ b/src/app/api/admin/teams/route.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewAllTeams } from '@/permissions'; +import { getTeams } from '@/queries/prisma/team'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewAllTeams(auth))) { + return unauthorized(); + } + + const teams = await getTeams( + { + include: { + members: { + include: { + user: { + select: { + id: true, + username: true, + }, + }, + }, + }, + _count: { + select: { + websites: { + where: { deletedAt: null }, + }, + members: { + where: { + user: { deletedAt: null }, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }, + query, + ); + + return json(teams); +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..2e52261 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewUsers } from '@/permissions'; +import { getUsers } from '@/queries/prisma/user'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewUsers(auth))) { + return unauthorized(); + } + + const users = await getUsers( + { + include: { + _count: { + select: { + websites: { + where: { deletedAt: null }, + }, + }, + }, + }, + omit: { + password: true, + }, + orderBy: { + createdAt: 'desc', + }, + }, + query, + ); + + return json(users); +} diff --git a/src/app/api/admin/websites/route.ts b/src/app/api/admin/websites/route.ts new file mode 100644 index 0000000..09b2ef9 --- /dev/null +++ b/src/app/api/admin/websites/route.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; +import { ROLES } from '@/lib/constants'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewAllWebsites } from '@/permissions'; +import { getWebsites } from '@/queries/prisma/website'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewAllWebsites(auth))) { + return unauthorized(); + } + + const websites = await getWebsites( + { + include: { + user: { + where: { + deletedAt: null, + }, + select: { + username: true, + id: true, + }, + }, + team: { + where: { + deletedAt: null, + }, + include: { + members: { + where: { + role: ROLES.teamOwner, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }, + query, + ); + + return json(websites); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..17ca2f7 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +import { saveAuth } from '@/lib/auth'; +import { ROLES } from '@/lib/constants'; +import { secret } from '@/lib/crypto'; +import { createSecureToken } from '@/lib/jwt'; +import { checkPassword } from '@/lib/password'; +import redis from '@/lib/redis'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { getAllUserTeams, getUserByUsername } from '@/queries/prisma'; + +export async function POST(request: Request) { + const schema = z.object({ + username: z.string(), + password: z.string(), + }); + + const { body, error } = await parseRequest(request, schema, { skipAuth: true }); + + if (error) { + return error(); + } + + const { username, password } = body; + + const user = await getUserByUsername(username, { includePassword: true }); + + if (!user || !checkPassword(password, user.password)) { + return unauthorized({ code: 'incorrect-username-password' }); + } + + const { id, role, createdAt } = user; + + let token: string; + + if (redis.enabled) { + token = await saveAuth({ userId: id, role }); + } else { + token = createSecureToken({ userId: user.id, role }, secret()); + } + + const teams = await getAllUserTeams(id); + + return json({ + token, + user: { id, username, role, createdAt, isAdmin: role === ROLES.admin, teams }, + }); +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..7bf0a81 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,12 @@ +import redis from '@/lib/redis'; +import { ok } from '@/lib/response'; + +export async function POST(request: Request) { + if (redis.enabled) { + const token = request.headers.get('authorization')?.split(' ')?.[1]; + + await redis.client.del(token); + } + + return ok(); +} diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts new file mode 100644 index 0000000..bba3dde --- /dev/null +++ b/src/app/api/auth/sso/route.ts @@ -0,0 +1,18 @@ +import { saveAuth } from '@/lib/auth'; +import redis from '@/lib/redis'; +import { parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; + +export async function POST(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + if (redis.enabled) { + const token = await saveAuth({ userId: auth.user.id }, 86400); + + return json({ user: auth.user, token }); + } +} diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts new file mode 100644 index 0000000..b308b7b --- /dev/null +++ b/src/app/api/auth/verify/route.ts @@ -0,0 +1,15 @@ +import { parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; +import { getAllUserTeams } from '@/queries/prisma'; + +export async function POST(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const teams = await getAllUserTeams(auth.user.id); + + return json({ ...auth.user, teams }); +} diff --git a/src/app/api/batch/route.ts b/src/app/api/batch/route.ts new file mode 100644 index 0000000..46e8b3c --- /dev/null +++ b/src/app/api/batch/route.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; +import * as send from '@/app/api/send/route'; +import { parseRequest } from '@/lib/request'; +import { json, serverError } from '@/lib/response'; +import { anyObjectParam } from '@/lib/schema'; + +const schema = z.array(anyObjectParam); + +export async function POST(request: Request) { + try { + const { body, error } = await parseRequest(request, schema, { skipAuth: true }); + + if (error) { + return error(); + } + + const errors = []; + + let index = 0; + let cache = null; + for (const data of body) { + // Recreate a fresh Request since `new Request(request)` will have the following error: + // > Cannot read private member #state from an object whose class did not declare it + + // Copy headers we received, ensure JSON content type, and avoid conflicting content-length + const headers = new Headers(request.headers); + headers.set('content-type', 'application/json'); + headers.delete('content-length'); + + const newRequest = new Request(request.url, { + method: 'POST', + headers, + body: JSON.stringify(data), + }); + + const response = await send.POST(newRequest); + const responseJson = await response.json(); + + if (!response.ok) { + errors.push({ index, response: responseJson }); + } else { + cache ??= responseJson.cache; + } + + index++; + } + + return json({ + size: body.length, + processed: body.length - errors.length, + errors: errors.length, + details: errors, + cache, + }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts new file mode 100644 index 0000000..4e40caa --- /dev/null +++ b/src/app/api/config/route.ts @@ -0,0 +1,21 @@ +import { parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; + +export async function GET(request: Request) { + const { error } = await parseRequest(request, null, { skipAuth: true }); + + if (error) { + return error(); + } + + return json({ + cloudMode: !!process.env.CLOUD_MODE, + faviconUrl: process.env.FAVICON_URL, + linksUrl: process.env.LINKS_URL, + pixelsUrl: process.env.PIXELS_URL, + privateMode: !!process.env.PRIVATE_MODE, + telemetryDisabled: !!process.env.DISABLE_TELEMETRY, + trackerScriptName: process.env.TRACKER_SCRIPT_NAME, + updatesDisabled: !!process.env.DISABLE_UPDATES, + }); +} diff --git a/src/app/api/heartbeat/route.ts b/src/app/api/heartbeat/route.ts new file mode 100644 index 0000000..9146308 --- /dev/null +++ b/src/app/api/heartbeat/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json({ ok: true }); +} diff --git a/src/app/api/links/[linkId]/route.ts b/src/app/api/links/[linkId]/route.ts new file mode 100644 index 0000000..92f572c --- /dev/null +++ b/src/app/api/links/[linkId]/route.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; +import { canDeleteLink, canUpdateLink, canViewLink } from '@/permissions'; +import { deleteLink, getLink, updateLink } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ linkId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { linkId } = await params; + + if (!(await canViewLink(auth, linkId))) { + return unauthorized(); + } + + const website = await getLink(linkId); + + return json(website); +} + +export async function POST(request: Request, { params }: { params: Promise<{ linkId: string }> }) { + const schema = z.object({ + name: z.string().optional(), + url: z.string().optional(), + slug: z.string().min(8).optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { linkId } = await params; + const { name, url, slug } = body; + + if (!(await canUpdateLink(auth, linkId))) { + return unauthorized(); + } + + try { + const result = await updateLink(linkId, { name, url, slug }); + + return Response.json(result); + } catch (e: any) { + if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('slug')) { + return badRequest({ message: 'That slug is already taken.' }); + } + + return serverError(e); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ linkId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { linkId } = await params; + + if (!(await canDeleteLink(auth, linkId))) { + return unauthorized(); + } + + await deleteLink(linkId); + + return ok(); +} diff --git a/src/app/api/links/route.ts b/src/app/api/links/route.ts new file mode 100644 index 0000000..a639888 --- /dev/null +++ b/src/app/api/links/route.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; +import { createLink, getUserLinks } from '@/queries/prisma'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const filters = await getQueryFilters(query); + + const links = await getUserLinks(auth.user.id, filters); + + return json(links); +} + +export async function POST(request: Request) { + const schema = z.object({ + name: z.string().max(100), + url: z.string().max(500), + slug: z.string().max(100), + teamId: z.string().nullable().optional(), + id: z.uuid().nullable().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { id, name, url, slug, teamId } = body; + + if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) { + return unauthorized(); + } + + const data: any = { + id: id ?? uuid(), + name, + url, + slug, + teamId, + }; + + if (!teamId) { + data.userId = auth.user.id; + } + + const result = await createLink(data); + + return json(result); +} diff --git a/src/app/api/me/password/route.ts b/src/app/api/me/password/route.ts new file mode 100644 index 0000000..24c7370 --- /dev/null +++ b/src/app/api/me/password/route.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { checkPassword, hashPassword } from '@/lib/password'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json } from '@/lib/response'; +import { getUser, updateUser } from '@/queries/prisma/user'; + +export async function POST(request: Request) { + const schema = z.object({ + currentPassword: z.string(), + newPassword: z.string().min(8), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const userId = auth.user.id; + const { currentPassword, newPassword } = body; + + const user = await getUser(userId, { includePassword: true }); + + if (!checkPassword(currentPassword, user.password)) { + return badRequest({ message: 'Current password is incorrect' }); + } + + const password = hashPassword(newPassword); + + const updated = await updateUser(userId, { password }); + + return json(updated); +} diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts new file mode 100644 index 0000000..59a3255 --- /dev/null +++ b/src/app/api/me/route.ts @@ -0,0 +1,12 @@ +import { parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; + +export async function GET(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + return json(auth); +} diff --git a/src/app/api/me/teams/route.ts b/src/app/api/me/teams/route.ts new file mode 100644 index 0000000..555bf30 --- /dev/null +++ b/src/app/api/me/teams/route.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; +import { pagingParams } from '@/lib/schema'; +import { getUserTeams } from '@/queries/prisma'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const filters = await getQueryFilters(query); + + const teams = await getUserTeams(auth.user.id, filters); + + return json(teams); +} diff --git a/src/app/api/me/websites/route.ts b/src/app/api/me/websites/route.ts new file mode 100644 index 0000000..9ec39c7 --- /dev/null +++ b/src/app/api/me/websites/route.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; +import { pagingParams } from '@/lib/schema'; +import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + includeTeams: z.string().optional(), + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const filters = await getQueryFilters(query); + + if (query.includeTeams) { + return json(await getAllUserWebsitesIncludingTeamOwner(auth.user.id, filters)); + } + + return json(await getUserWebsites(auth.user.id, filters)); +} diff --git a/src/app/api/pixels/[pixelId]/route.ts b/src/app/api/pixels/[pixelId]/route.ts new file mode 100644 index 0000000..ecaf1fd --- /dev/null +++ b/src/app/api/pixels/[pixelId]/route.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; +import { canDeletePixel, canUpdatePixel, canViewPixel } from '@/permissions'; +import { deletePixel, getPixel, updatePixel } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ pixelId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { pixelId } = await params; + + if (!(await canViewPixel(auth, pixelId))) { + return unauthorized(); + } + + const pixel = await getPixel(pixelId); + + return json(pixel); +} + +export async function POST(request: Request, { params }: { params: Promise<{ pixelId: string }> }) { + const schema = z.object({ + name: z.string().optional(), + slug: z.string().min(8).optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { pixelId } = await params; + const { name, slug } = body; + + if (!(await canUpdatePixel(auth, pixelId))) { + return unauthorized(); + } + + try { + const pixel = await updatePixel(pixelId, { name, slug }); + + return Response.json(pixel); + } catch (e: any) { + if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('slug')) { + return badRequest({ message: 'That slug is already taken.' }); + } + + return serverError(e); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ pixelId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { pixelId } = await params; + + if (!(await canDeletePixel(auth, pixelId))) { + return unauthorized(); + } + + await deletePixel(pixelId); + + return ok(); +} diff --git a/src/app/api/pixels/route.ts b/src/app/api/pixels/route.ts new file mode 100644 index 0000000..8baae4f --- /dev/null +++ b/src/app/api/pixels/route.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; +import { createPixel, getUserPixels } from '@/queries/prisma'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const filters = await getQueryFilters(query); + + const links = await getUserPixels(auth.user.id, filters); + + return json(links); +} + +export async function POST(request: Request) { + const schema = z.object({ + name: z.string().max(100), + slug: z.string().max(100), + teamId: z.string().nullable().optional(), + id: z.uuid().nullable().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { id, name, slug, teamId } = body; + + if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) { + return unauthorized(); + } + + const data: any = { + id: id ?? uuid(), + name, + slug, + teamId, + }; + + if (!teamId) { + data.userId = auth.user.id; + } + + const result = await createPixel(data); + + return json(result); +} diff --git a/src/app/api/realtime/[websiteId]/route.ts b/src/app/api/realtime/[websiteId]/route.ts new file mode 100644 index 0000000..32b7a16 --- /dev/null +++ b/src/app/api/realtime/[websiteId]/route.ts @@ -0,0 +1,36 @@ +import { startOfMinute, subMinutes } from 'date-fns'; +import { REALTIME_RANGE } from '@/lib/constants'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getRealtimeData } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, query, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters( + { + ...query, + startAt: subMinutes(startOfMinute(new Date()), REALTIME_RANGE).getTime(), + endAt: Date.now(), + }, + websiteId, + ); + + const data = await getRealtimeData(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/reports/[reportId]/route.ts b/src/app/api/reports/[reportId]/route.ts new file mode 100644 index 0000000..1f22c62 --- /dev/null +++ b/src/app/api/reports/[reportId]/route.ts @@ -0,0 +1,80 @@ +import { parseRequest } from '@/lib/request'; +import { json, notFound, ok, unauthorized } from '@/lib/response'; +import { reportSchema } from '@/lib/schema'; +import { canDeleteWebsite, canUpdateWebsite, canViewReport } from '@/permissions'; +import { deleteReport, getReport, updateReport } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ reportId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { reportId } = await params; + + const report = await getReport(reportId); + + if (!(await canViewReport(auth, report))) { + return unauthorized(); + } + + return json(report); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ reportId: string }> }, +) { + const { auth, body, error } = await parseRequest(request, reportSchema); + + if (error) { + return error(); + } + + const { reportId } = await params; + const { websiteId, type, name, description, parameters } = body; + + const report = await getReport(reportId); + + if (!report) { + return notFound(); + } + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const result = await updateReport(reportId, { + websiteId, + userId: auth.user.id, + type, + name, + description, + parameters, + } as any); + + return json(result); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ reportId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { reportId } = await params; + const report = await getReport(reportId); + + if (!(await canDeleteWebsite(auth, report.websiteId))) { + return unauthorized(); + } + + await deleteReport(reportId); + + return ok(); +} diff --git a/src/app/api/reports/attribution/route.ts b/src/app/api/reports/attribution/route.ts new file mode 100644 index 0000000..bd7d86d --- /dev/null +++ b/src/app/api/reports/attribution/route.ts @@ -0,0 +1,26 @@ +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { type AttributionParameters, getAttribution } from '@/queries/sql/reports/getAttribution'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const parameters = await setWebsiteDate(websiteId, body.parameters); + const filters = await getQueryFilters(body.filters, websiteId); + + const data = await getAttribution(websiteId, parameters as AttributionParameters, filters); + + return json(data); +} diff --git a/src/app/api/reports/breakdown/route.ts b/src/app/api/reports/breakdown/route.ts new file mode 100644 index 0000000..3c59314 --- /dev/null +++ b/src/app/api/reports/breakdown/route.ts @@ -0,0 +1,26 @@ +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { type BreakdownParameters, getBreakdown } from '@/queries/sql'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const parameters = await setWebsiteDate(websiteId, body.parameters); + const filters = await getQueryFilters(body.filters, websiteId); + + const data = await getBreakdown(websiteId, parameters as BreakdownParameters, filters); + + return json(data); +} diff --git a/src/app/api/reports/funnel/route.ts b/src/app/api/reports/funnel/route.ts new file mode 100644 index 0000000..c13f6f1 --- /dev/null +++ b/src/app/api/reports/funnel/route.ts @@ -0,0 +1,26 @@ +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { type FunnelParameters, getFunnel } from '@/queries/sql'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const parameters = await setWebsiteDate(websiteId, body.parameters); + const filters = await getQueryFilters(body.filters, websiteId); + + const data = await getFunnel(websiteId, parameters as FunnelParameters, filters); + + return json(data); +} diff --git a/src/app/api/reports/goal/route.ts b/src/app/api/reports/goal/route.ts new file mode 100644 index 0000000..3bd0415 --- /dev/null +++ b/src/app/api/reports/goal/route.ts @@ -0,0 +1,26 @@ +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { type GoalParameters, getGoal } from '@/queries/sql/reports/getGoal'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const parameters = await setWebsiteDate(websiteId, body.parameters); + const filters = await getQueryFilters(body.filters, websiteId); + + const data = await getGoal(websiteId, parameters as GoalParameters, filters); + + return json(data); +} diff --git a/src/app/api/reports/journey/route.ts b/src/app/api/reports/journey/route.ts new file mode 100644 index 0000000..29e8531 --- /dev/null +++ b/src/app/api/reports/journey/route.ts @@ -0,0 +1,25 @@ +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getJourney } from '@/queries/sql'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId, parameters, filters } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const queryFilters = await getQueryFilters(filters, websiteId); + + const data = await getJourney(websiteId, parameters, queryFilters); + + return json(data); +} diff --git a/src/app/api/reports/retention/route.ts b/src/app/api/reports/retention/route.ts new file mode 100644 index 0000000..d1a7d69 --- /dev/null +++ b/src/app/api/reports/retention/route.ts @@ -0,0 +1,26 @@ +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getRetention, type RetentionParameters } from '@/queries/sql'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(body.filters, websiteId); + const parameters = await setWebsiteDate(websiteId, body.parameters); + + const data = await getRetention(websiteId, parameters as RetentionParameters, filters); + + return json(data); +} diff --git a/src/app/api/reports/revenue/route.ts b/src/app/api/reports/revenue/route.ts new file mode 100644 index 0000000..6a55661 --- /dev/null +++ b/src/app/api/reports/revenue/route.ts @@ -0,0 +1,26 @@ +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getRevenue, type RevenuParameters } from '@/queries/sql/reports/getRevenue'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const parameters = await setWebsiteDate(websiteId, body.parameters); + const filters = await getQueryFilters(body.filters, websiteId); + + const data = await getRevenue(websiteId, parameters as RevenuParameters, filters); + + return json(data); +} diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts new file mode 100644 index 0000000..b0a4135 --- /dev/null +++ b/src/app/api/reports/route.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, reportSchema, reportTypeParam } from '@/lib/schema'; +import { canUpdateWebsite, canViewWebsite } from '@/permissions'; +import { createReport, getReports } from '@/queries/prisma'; + +export async function GET(request: Request) { + const schema = z.object({ + websiteId: z.uuid(), + type: reportTypeParam.optional(), + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { page, search, pageSize, websiteId, type } = query; + const filters = { + page, + pageSize, + search, + }; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getReports( + { + where: { + websiteId, + type, + website: { + deletedAt: null, + }, + }, + }, + filters, + ); + + return json(data); +} + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportSchema); + + if (error) { + return error(); + } + + const { websiteId, type, name, description, parameters } = body; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const result = await createReport({ + id: uuid(), + userId: auth.user.id, + websiteId, + type, + name, + description: description || '', + parameters, + }); + + return json(result); +} diff --git a/src/app/api/reports/utm/route.ts b/src/app/api/reports/utm/route.ts new file mode 100644 index 0000000..577fdab --- /dev/null +++ b/src/app/api/reports/utm/route.ts @@ -0,0 +1,37 @@ +import { UTM_PARAMS } from '@/lib/constants'; +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getUTM, type UTMParameters } from '@/queries/sql'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(body.filters, websiteId); + const parameters = await setWebsiteDate(websiteId, body.parameters); + + const data = { + utm_source: [], + utm_medium: [], + utm_campaign: [], + utm_term: [], + utm_content: [], + }; + + for (const key of UTM_PARAMS) { + data[key] = await getUTM(websiteId, { column: key, ...parameters } as UTMParameters, filters); + } + + return json(data); +} diff --git a/src/app/api/scripts/telemetry/route.ts b/src/app/api/scripts/telemetry/route.ts new file mode 100644 index 0000000..b19e99f --- /dev/null +++ b/src/app/api/scripts/telemetry/route.ts @@ -0,0 +1,28 @@ +import { CURRENT_VERSION, TELEMETRY_PIXEL } from '@/lib/constants'; + +export async function GET() { + if ( + process.env.NODE_ENV !== 'production' || + process.env.DISABLE_TELEMETRY || + process.env.PRIVATE_MODE + ) { + return new Response('/* telemetry disabled */', { + headers: { + 'content-type': 'text/javascript', + }, + }); + } + + const script = ` + (()=>{const i=document.createElement('img'); + i.setAttribute('src','${TELEMETRY_PIXEL}?v=${CURRENT_VERSION}'); + i.setAttribute('style','width:0;height:0;position:absolute;pointer-events:none;'); + document.body.appendChild(i);})(); + `; + + return new Response(script.replace(/\s\s+/g, ''), { + headers: { + 'content-type': 'text/javascript', + }, + }); +} diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts new file mode 100644 index 0000000..a0becc2 --- /dev/null +++ b/src/app/api/send/route.ts @@ -0,0 +1,284 @@ +import { startOfHour, startOfMonth } from 'date-fns'; +import { isbot } from 'isbot'; +import { serializeError } from 'serialize-error'; +import { z } from 'zod'; +import clickhouse from '@/lib/clickhouse'; +import { COLLECTION_TYPE, EVENT_TYPE } from '@/lib/constants'; +import { hash, secret, uuid } from '@/lib/crypto'; +import { getClientInfo, hasBlockedIp } from '@/lib/detect'; +import { createToken, parseToken } from '@/lib/jwt'; +import { fetchWebsite } from '@/lib/load'; +import { parseRequest } from '@/lib/request'; +import { badRequest, forbidden, json, serverError } from '@/lib/response'; +import { anyObjectParam, urlOrPathParam } from '@/lib/schema'; +import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url'; +import { createSession, saveEvent, saveSessionData } from '@/queries/sql'; + +interface Cache { + websiteId: string; + sessionId: string; + visitId: string; + iat: number; +} + +const schema = z.object({ + type: z.enum(['event', 'identify']), + payload: z + .object({ + website: z.uuid().optional(), + link: z.uuid().optional(), + pixel: z.uuid().optional(), + data: anyObjectParam.optional(), + hostname: z.string().max(100).optional(), + language: z.string().max(35).optional(), + referrer: urlOrPathParam.optional(), + screen: z.string().max(11).optional(), + title: z.string().optional(), + url: urlOrPathParam.optional(), + name: z.string().max(50).optional(), + tag: z.string().max(50).optional(), + ip: z.string().optional(), + userAgent: z.string().optional(), + timestamp: z.coerce.number().int().optional(), + id: z.string().optional(), + browser: z.string().optional(), + os: z.string().optional(), + device: z.string().optional(), + }) + .refine( + data => { + const keys = [data.website, data.link, data.pixel]; + const count = keys.filter(Boolean).length; + return count === 1; + }, + { + message: 'Exactly one of website, link, or pixel must be provided', + path: ['website'], + }, + ), +}); + +export async function POST(request: Request) { + try { + const { body, error } = await parseRequest(request, schema, { skipAuth: true }); + + if (error) { + return error(); + } + + const { type, payload } = body; + + const { + website: websiteId, + pixel: pixelId, + link: linkId, + hostname, + screen, + language, + url, + referrer, + name, + data, + title, + tag, + timestamp, + id, + } = payload; + + const sourceId = websiteId || pixelId || linkId; + + // Cache check + let cache: Cache | null = null; + + if (websiteId) { + const cacheHeader = request.headers.get('x-umami-cache'); + + if (cacheHeader) { + const result = await parseToken(cacheHeader, secret()); + + if (result) { + cache = result; + } + } + + // Find website + if (!cache?.websiteId) { + const website = await fetchWebsite(websiteId); + + if (!website) { + return badRequest({ message: 'Website not found.' }); + } + } + } + + // Client info + const { ip, userAgent, device, browser, os, country, region, city } = await getClientInfo( + request, + payload, + ); + + // Bot check + if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) { + return json({ beep: 'boop' }); + } + + // IP block + if (hasBlockedIp(ip)) { + return forbidden(); + } + + const createdAt = timestamp ? new Date(timestamp * 1000) : new Date(); + const now = Math.floor(Date.now() / 1000); + + const sessionSalt = hash(startOfMonth(createdAt).toUTCString()); + const visitSalt = hash(startOfHour(createdAt).toUTCString()); + + const sessionId = id ? uuid(sourceId, id) : uuid(sourceId, ip, userAgent, sessionSalt); + + // Create a session if not found + if (!clickhouse.enabled && !cache?.sessionId) { + await createSession({ + id: sessionId, + websiteId: sourceId, + browser, + os, + device, + screen, + language, + country, + region, + city, + distinctId: id, + createdAt, + }); + } + + // Visit info + let visitId = cache?.visitId || uuid(sessionId, visitSalt); + let iat = cache?.iat || now; + + // Expire visit after 30 minutes + if (!timestamp && now - iat > 1800) { + visitId = uuid(sessionId, visitSalt); + iat = now; + } + + if (type === COLLECTION_TYPE.event) { + const base = hostname ? `https://${hostname}` : 'https://localhost'; + const currentUrl = new URL(url, base); + + let urlPath = + currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname + currentUrl.hash; + const urlQuery = currentUrl.search.substring(1); + const urlDomain = currentUrl.hostname.replace(/^www./, ''); + + let referrerPath: string; + let referrerQuery: string; + let referrerDomain: string; + + // UTM Params + const utmSource = currentUrl.searchParams.get('utm_source'); + const utmMedium = currentUrl.searchParams.get('utm_medium'); + const utmCampaign = currentUrl.searchParams.get('utm_campaign'); + const utmContent = currentUrl.searchParams.get('utm_content'); + const utmTerm = currentUrl.searchParams.get('utm_term'); + + // Click IDs + const gclid = currentUrl.searchParams.get('gclid'); + const fbclid = currentUrl.searchParams.get('fbclid'); + const msclkid = currentUrl.searchParams.get('msclkid'); + const ttclid = currentUrl.searchParams.get('ttclid'); + const lifatid = currentUrl.searchParams.get('li_fat_id'); + const twclid = currentUrl.searchParams.get('twclid'); + + if (process.env.REMOVE_TRAILING_SLASH) { + urlPath = urlPath.replace(/\/(?=(#.*)?$)/, ''); + } + + if (referrer) { + const referrerUrl = new URL(referrer, base); + + referrerPath = referrerUrl.pathname; + referrerQuery = referrerUrl.search.substring(1); + referrerDomain = referrerUrl.hostname.replace(/^www\./, ''); + } + + const eventType = linkId + ? EVENT_TYPE.linkEvent + : pixelId + ? EVENT_TYPE.pixelEvent + : name + ? EVENT_TYPE.customEvent + : EVENT_TYPE.pageView; + + await saveEvent({ + websiteId: sourceId, + sessionId, + visitId, + eventType, + createdAt, + + // Page + pageTitle: safeDecodeURIComponent(title), + hostname: hostname || urlDomain, + urlPath: safeDecodeURI(urlPath), + urlQuery, + referrerPath: safeDecodeURI(referrerPath), + referrerQuery, + referrerDomain, + + // Session + distinctId: id, + browser, + os, + device, + screen, + language, + country, + region, + city, + + // Events + eventName: name, + eventData: data, + tag, + + // UTM + utmSource, + utmMedium, + utmCampaign, + utmContent, + utmTerm, + + // Click IDs + gclid, + fbclid, + msclkid, + ttclid, + lifatid, + twclid, + }); + } else if (type === COLLECTION_TYPE.identify) { + if (data) { + await saveSessionData({ + websiteId, + sessionId, + sessionData: data, + distinctId: id, + createdAt, + }); + } + } + + const token = createToken({ websiteId, sessionId, visitId, iat }, secret()); + + return json({ cache: token, sessionId, visitId }); + } catch (e) { + const error = serializeError(e); + + // eslint-disable-next-line no-console + console.log(error); + + return serverError({ errorObject: error }); + } +} diff --git a/src/app/api/share/[shareId]/route.ts b/src/app/api/share/[shareId]/route.ts new file mode 100644 index 0000000..bef87c4 --- /dev/null +++ b/src/app/api/share/[shareId]/route.ts @@ -0,0 +1,19 @@ +import { secret } from '@/lib/crypto'; +import { createToken } from '@/lib/jwt'; +import { json, notFound } from '@/lib/response'; +import { getSharedWebsite } from '@/queries/prisma'; + +export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) { + const { shareId } = await params; + + const website = await getSharedWebsite(shareId); + + if (!website) { + return notFound(); + } + + const data = { websiteId: website.id }; + const token = createToken(data, secret()); + + return json({ ...data, token }); +} diff --git a/src/app/api/teams/[teamId]/links/route.ts b/src/app/api/teams/[teamId]/links/route.ts new file mode 100644 index 0000000..41e139b --- /dev/null +++ b/src/app/api/teams/[teamId]/links/route.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewTeam } from '@/permissions'; +import { getTeamLinks } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + const { teamId } = await params; + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewTeam(auth, teamId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query); + + const links = await getTeamLinks(teamId, filters); + + return json(links); +} diff --git a/src/app/api/teams/[teamId]/pixels/route.ts b/src/app/api/teams/[teamId]/pixels/route.ts new file mode 100644 index 0000000..daac204 --- /dev/null +++ b/src/app/api/teams/[teamId]/pixels/route.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewTeam } from '@/permissions'; +import { getTeamPixels } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + const { teamId } = await params; + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewTeam(auth, teamId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query); + + const websites = await getTeamPixels(teamId, filters); + + return json(websites); +} diff --git a/src/app/api/teams/[teamId]/route.ts b/src/app/api/teams/[teamId]/route.ts new file mode 100644 index 0000000..c334b2a --- /dev/null +++ b/src/app/api/teams/[teamId]/route.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, notFound, ok, unauthorized } from '@/lib/response'; +import { canDeleteTeam, canUpdateTeam, canViewTeam } from '@/permissions'; +import { deleteTeam, getTeam, updateTeam } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { teamId } = await params; + + if (!(await canViewTeam(auth, teamId))) { + return unauthorized(); + } + + const team = await getTeam(teamId, { includeMembers: true }); + + if (!team) { + return notFound({ message: 'Team not found.' }); + } + + return json(team); +} + +export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + name: z.string().max(50).optional(), + accessCode: z.string().max(50).optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { teamId } = await params; + + if (!(await canUpdateTeam(auth, teamId))) { + return unauthorized({ message: 'You must be the owner/manager of this team.' }); + } + + const team = await updateTeam(teamId, body); + + return json(team); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ teamId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { teamId } = await params; + + if (!(await canDeleteTeam(auth, teamId))) { + return unauthorized({ message: 'You must be the owner/manager of this team.' }); + } + + await deleteTeam(teamId); + + return ok(); +} diff --git a/src/app/api/teams/[teamId]/users/[userId]/route.ts b/src/app/api/teams/[teamId]/users/[userId]/route.ts new file mode 100644 index 0000000..d09af9d --- /dev/null +++ b/src/app/api/teams/[teamId]/users/[userId]/route.ts @@ -0,0 +1,85 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, ok, unauthorized } from '@/lib/response'; +import { teamRoleParam } from '@/lib/schema'; +import { canDeleteTeamUser, canUpdateTeam } from '@/permissions'; +import { deleteTeamUser, getTeamUser, updateTeamUser } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ teamId: string; userId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { teamId, userId } = await params; + + if (!(await canUpdateTeam(auth, teamId))) { + return unauthorized({ message: 'You must be the owner/manager of this team.' }); + } + + const teamUser = await getTeamUser(teamId, userId); + + return json(teamUser); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ teamId: string; userId: string }> }, +) { + const schema = z.object({ + role: teamRoleParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { teamId, userId } = await params; + + if (!(await canUpdateTeam(auth, teamId))) { + return unauthorized({ message: 'You must be the owner/manager of this team.' }); + } + + const teamUser = await getTeamUser(teamId, userId); + + if (!teamUser) { + return badRequest({ message: 'The User does not exists on this team.' }); + } + + const user = await updateTeamUser(teamUser.id, body); + + return json(user); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ teamId: string; userId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { teamId, userId } = await params; + + if (!(await canDeleteTeamUser(auth, teamId, userId))) { + return unauthorized({ message: 'You must be the owner/manager of this team.' }); + } + + const teamUser = await getTeamUser(teamId, userId); + + if (!teamUser) { + return badRequest({ message: 'The User does not exists on this team.' }); + } + + await deleteTeamUser(teamId, userId); + + return ok(); +} diff --git a/src/app/api/teams/[teamId]/users/route.ts b/src/app/api/teams/[teamId]/users/route.ts new file mode 100644 index 0000000..c129763 --- /dev/null +++ b/src/app/api/teams/[teamId]/users/route.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams, teamRoleParam } from '@/lib/schema'; +import { canUpdateTeam, canViewTeam } from '@/permissions'; +import { createTeamUser, getTeamUser, getTeamUsers } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { teamId } = await params; + + if (!(await canViewTeam(auth, teamId))) { + return unauthorized({ message: 'You must be a member of this team.' }); + } + + const filters = await getQueryFilters(query); + + const users = await getTeamUsers( + { + where: { + teamId, + user: { + deletedAt: null, + }, + }, + include: { + user: { + select: { + id: true, + username: true, + }, + }, + }, + orderBy: { + createdAt: 'asc', + }, + }, + filters, + ); + + return json(users); +} + +export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + userId: z.uuid(), + role: teamRoleParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { teamId } = await params; + + if (!(await canUpdateTeam(auth, teamId))) { + return unauthorized({ message: 'You must be the owner/manager of this team.' }); + } + + const { userId, role } = body; + + const teamUser = await getTeamUser(teamId, userId); + + if (teamUser) { + return badRequest({ message: 'User is already a member of the Team.' }); + } + + const users = await createTeamUser(userId, teamId, role); + + return json(users); +} diff --git a/src/app/api/teams/[teamId]/websites/route.ts b/src/app/api/teams/[teamId]/websites/route.ts new file mode 100644 index 0000000..05c6d80 --- /dev/null +++ b/src/app/api/teams/[teamId]/websites/route.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewTeam } from '@/permissions'; +import { getTeamWebsites } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + const { teamId } = await params; + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewTeam(auth, teamId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query); + + const websites = await getTeamWebsites(teamId, filters); + + return json(websites); +} diff --git a/src/app/api/teams/join/route.ts b/src/app/api/teams/join/route.ts new file mode 100644 index 0000000..3ce0913 --- /dev/null +++ b/src/app/api/teams/join/route.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { ROLES } from '@/lib/constants'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, notFound } from '@/lib/response'; +import { createTeamUser, findTeam, getTeamUser } from '@/queries/prisma'; + +export async function POST(request: Request) { + const schema = z.object({ + accessCode: z.string().max(50), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { accessCode } = body; + + const team = await findTeam({ + where: { + accessCode, + }, + }); + + if (!team) { + return notFound({ message: 'Team not found.', code: 'team-not-found' }); + } + + const teamUser = await getTeamUser(team.id, auth.user.id); + + if (teamUser) { + return badRequest({ message: 'User is already a team member.' }); + } + + const user = await createTeamUser(auth.user.id, team.id, ROLES.teamMember); + + return json(user); +} diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts new file mode 100644 index 0000000..53ef592 --- /dev/null +++ b/src/app/api/teams/route.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import { getRandomChars } from '@/lib/generate'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams } from '@/lib/schema'; +import { canCreateTeam } from '@/permissions'; +import { createTeam, getUserTeams } from '@/queries/prisma'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const filters = await getQueryFilters(query); + + const teams = await getUserTeams(auth.user.id, filters); + + return json(teams); +} + +export async function POST(request: Request) { + const schema = z.object({ + name: z.string().max(50), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canCreateTeam(auth))) { + return unauthorized(); + } + + const { name } = body; + + const team = await createTeam( + { + id: uuid(), + name, + accessCode: `team_${getRandomChars(16)}`, + }, + auth.user.id, + ); + + return json(team); +} diff --git a/src/app/api/users/[userId]/route.ts b/src/app/api/users/[userId]/route.ts new file mode 100644 index 0000000..aade8aa --- /dev/null +++ b/src/app/api/users/[userId]/route.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; +import { hashPassword } from '@/lib/password'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, ok, unauthorized } from '@/lib/response'; +import { userRoleParam } from '@/lib/schema'; +import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions'; +import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { userId } = await params; + + if (!(await canViewUser(auth, userId))) { + return unauthorized(); + } + + const user = await getUser(userId); + + return json(user); +} + +export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const schema = z.object({ + username: z.string().max(255).optional(), + password: z.string().max(255).optional(), + role: userRoleParam.optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { userId } = await params; + + if (!(await canUpdateUser(auth, userId))) { + return unauthorized(); + } + + const { username, password, role } = body; + + const user = await getUser(userId); + + const data: any = {}; + + if (password) { + data.password = hashPassword(password); + } + + // Only admin can change these fields + if (role && auth.user.isAdmin) { + data.role = role; + } + + if (username && auth.user.isAdmin) { + data.username = username; + } + + // Check when username changes + if (data.username && user.username !== data.username) { + const user = await getUserByUsername(username); + + if (user) { + return badRequest({ message: 'User already exists' }); + } + } + + const updated = await updateUser(userId, data); + + return json(updated); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ userId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { userId } = await params; + + if (!(await canDeleteUser(auth))) { + return unauthorized(); + } + + if (userId === auth.user.id) { + return badRequest({ message: 'You cannot delete yourself.' }); + } + + await deleteUser(userId); + + return ok(); +} diff --git a/src/app/api/users/[userId]/teams/route.ts b/src/app/api/users/[userId]/teams/route.ts new file mode 100644 index 0000000..7a834a3 --- /dev/null +++ b/src/app/api/users/[userId]/teams/route.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams } from '@/lib/schema'; +import { getUserTeams } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const schema = z.object({ + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { userId } = await params; + + if (auth.user.id !== userId && !auth.user.isAdmin) { + return unauthorized(); + } + + const teams = await getUserTeams(userId, query); + + return json(teams); +} diff --git a/src/app/api/users/[userId]/websites/route.ts b/src/app/api/users/[userId]/websites/route.ts new file mode 100644 index 0000000..1107d8e --- /dev/null +++ b/src/app/api/users/[userId]/websites/route.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website'; + +export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + includeTeams: z.string().optional(), + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { userId } = await params; + + if (!auth.user.isAdmin && auth.user.id !== userId) { + return unauthorized(); + } + + const filters = await getQueryFilters(query); + + if (query.includeTeams) { + return json(await getAllUserWebsitesIncludingTeamOwner(userId, filters)); + } + + return json(await getUserWebsites(userId, filters)); +} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..dbb114c --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; +import { ROLES } from '@/lib/constants'; +import { uuid } from '@/lib/crypto'; +import { hashPassword } from '@/lib/password'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { canCreateUser } from '@/permissions'; +import { createUser, getUserByUsername } from '@/queries/prisma'; + +export async function POST(request: Request) { + const schema = z.object({ + id: z.uuid().optional(), + username: z.string().max(255), + password: z.string(), + role: z.string().regex(/admin|user|view-only/i), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canCreateUser(auth))) { + return unauthorized(); + } + + const { id, username, password, role } = body; + + const existingUser = await getUserByUsername(username, { showDeleted: true }); + + if (existingUser) { + return badRequest({ message: 'User already exists' }); + } + + const user = await createUser({ + id: id || uuid(), + username, + password: hashPassword(password), + role: role ?? ROLES.user, + }); + + return json(user); +} diff --git a/src/app/api/websites/[websiteId]/active/route.ts b/src/app/api/websites/[websiteId]/active/route.ts new file mode 100644 index 0000000..233b97e --- /dev/null +++ b/src/app/api/websites/[websiteId]/active/route.ts @@ -0,0 +1,25 @@ +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getActiveVisitors } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const visitors = await getActiveVisitors(websiteId); + + return json(visitors); +} diff --git a/src/app/api/websites/[websiteId]/daterange/route.ts b/src/app/api/websites/[websiteId]/daterange/route.ts new file mode 100644 index 0000000..14a241f --- /dev/null +++ b/src/app/api/websites/[websiteId]/daterange/route.ts @@ -0,0 +1,25 @@ +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteDateRange } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const dateRange = await getWebsiteDateRange(websiteId); + + return json(dateRange); +} diff --git a/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts b/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts new file mode 100644 index 0000000..54afab2 --- /dev/null +++ b/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts @@ -0,0 +1,25 @@ +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getEventData } from '@/queries/sql/events/getEventData'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; eventId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, eventId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getEventData(websiteId, eventId); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/event-data/events/route.ts b/src/app/api/websites/[websiteId]/event-data/events/route.ts new file mode 100644 index 0000000..eb6ee6e --- /dev/null +++ b/src/app/api/websites/[websiteId]/event-data/events/route.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventDataEvents } from '@/queries/sql/events/getEventDataEvents'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + event: z.string().optional(), + ...filterParams, + }); + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getEventDataEvents(websiteId, { + ...filters, + }); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/event-data/fields/route.ts b/src/app/api/websites/[websiteId]/event-data/fields/route.ts new file mode 100644 index 0000000..bce6a97 --- /dev/null +++ b/src/app/api/websites/[websiteId]/event-data/fields/route.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventDataFields } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getEventDataFields(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/event-data/properties/route.ts b/src/app/api/websites/[websiteId]/event-data/properties/route.ts new file mode 100644 index 0000000..52d15cf --- /dev/null +++ b/src/app/api/websites/[websiteId]/event-data/properties/route.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventDataProperties } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getEventDataProperties(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/event-data/stats/route.ts b/src/app/api/websites/[websiteId]/event-data/stats/route.ts new file mode 100644 index 0000000..042e989 --- /dev/null +++ b/src/app/api/websites/[websiteId]/event-data/stats/route.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventDataStats } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getEventDataStats(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/event-data/values/route.ts b/src/app/api/websites/[websiteId]/event-data/values/route.ts new file mode 100644 index 0000000..12e8f2d --- /dev/null +++ b/src/app/api/websites/[websiteId]/event-data/values/route.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventDataValues } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + event: z.string(), + propertyName: z.string(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { propertyName } = query; + const filters = await getQueryFilters(query, websiteId); + + const data = await getEventDataValues(websiteId, { + ...filters, + propertyName, + }); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/events/route.ts b/src/app/api/websites/[websiteId]/events/route.ts new file mode 100644 index 0000000..74ec3ec --- /dev/null +++ b/src/app/api/websites/[websiteId]/events/route.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, pagingParams, searchParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteEvents } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().optional(), + endAt: z.coerce.number().optional(), + ...filterParams, + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getWebsiteEvents(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/events/series/route.ts b/src/app/api/websites/[websiteId]/events/series/route.ts new file mode 100644 index 0000000..977e9c8 --- /dev/null +++ b/src/app/api/websites/[websiteId]/events/series/route.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, timezoneParam, unitParam } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventStats } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + unit: unitParam.optional(), + timezone: timezoneParam, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getEventStats(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/export/route.ts b/src/app/api/websites/[websiteId]/export/route.ts new file mode 100644 index 0000000..eec81c6 --- /dev/null +++ b/src/app/api/websites/[websiteId]/export/route.ts @@ -0,0 +1,64 @@ +import JSZip from 'jszip'; +import Papa from 'papaparse'; +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { dateRangeParams, pagingParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventMetrics, getPageviewMetrics, getSessionMetrics } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...dateRangeParams, + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([ + getEventMetrics(websiteId, { type: 'event' }, filters), + getPageviewMetrics(websiteId, { type: 'path' }, filters), + getPageviewMetrics(websiteId, { type: 'referrer' }, filters), + getSessionMetrics(websiteId, { type: 'browser' }, filters), + getSessionMetrics(websiteId, { type: 'os' }, filters), + getSessionMetrics(websiteId, { type: 'device' }, filters), + getSessionMetrics(websiteId, { type: 'country' }, filters), + ]); + + const zip = new JSZip(); + + const parse = (data: any) => { + return Papa.unparse(data, { + header: true, + skipEmptyLines: true, + }); + }; + + zip.file('events.csv', parse(events)); + zip.file('pages.csv', parse(pages)); + zip.file('referrers.csv', parse(referrers)); + zip.file('browsers.csv', parse(browsers)); + zip.file('os.csv', parse(os)); + zip.file('devices.csv', parse(devices)); + zip.file('countries.csv', parse(countries)); + + const content = await zip.generateAsync({ type: 'nodebuffer' }); + const base64 = content.toString('base64'); + + return json({ zip: base64 }); +} diff --git a/src/app/api/websites/[websiteId]/metrics/expanded/route.ts b/src/app/api/websites/[websiteId]/metrics/expanded/route.ts new file mode 100644 index 0000000..d52c177 --- /dev/null +++ b/src/app/api/websites/[websiteId]/metrics/expanded/route.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; +import { EVENT_COLUMNS, EVENT_TYPE, SESSION_COLUMNS } from '@/lib/constants'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams, searchParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { + getChannelExpandedMetrics, + getEventExpandedMetrics, + getPageviewExpandedMetrics, + getSessionExpandedMetrics, +} from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + type: z.string(), + limit: z.coerce.number().optional(), + offset: z.coerce.number().optional(), + ...dateRangeParams, + ...searchParams, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { type, limit, offset, search } = query; + const filters = await getQueryFilters(query, websiteId); + + if (search) { + filters[type] = `c.${search}`; + } + + if (SESSION_COLUMNS.includes(type)) { + const data = await getSessionExpandedMetrics(websiteId, { type, limit, offset }, filters); + + return json(data); + } + + if (EVENT_COLUMNS.includes(type)) { + if (type === 'event') { + filters.eventType = EVENT_TYPE.customEvent; + return json(await getEventExpandedMetrics(websiteId, { type, limit, offset }, filters)); + } else { + return json(await getPageviewExpandedMetrics(websiteId, { type, limit, offset }, filters)); + } + } + + if (type === 'channel') { + return json(await getChannelExpandedMetrics(websiteId, filters)); + } + + return badRequest(); +} diff --git a/src/app/api/websites/[websiteId]/metrics/route.ts b/src/app/api/websites/[websiteId]/metrics/route.ts new file mode 100644 index 0000000..12784ad --- /dev/null +++ b/src/app/api/websites/[websiteId]/metrics/route.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; +import { EVENT_COLUMNS, EVENT_TYPE, SESSION_COLUMNS } from '@/lib/constants'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams, searchParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { + getChannelMetrics, + getEventMetrics, + getPageviewMetrics, + getSessionMetrics, +} from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + type: z.string(), + limit: z.coerce.number().optional(), + offset: z.coerce.number().optional(), + ...dateRangeParams, + ...searchParams, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { type, limit, offset, search } = query; + const filters = await getQueryFilters(query, websiteId); + + if (search) { + filters[type] = `c.${search}`; + } + + if (SESSION_COLUMNS.includes(type)) { + const data = await getSessionMetrics(websiteId, { type, limit, offset }, filters); + + return json(data); + } + + if (EVENT_COLUMNS.includes(type)) { + if (type === 'event') { + filters.eventType = EVENT_TYPE.customEvent; + return json(await getEventMetrics(websiteId, { type, limit, offset }, filters)); + } else { + return json(await getPageviewMetrics(websiteId, { type, limit, offset }, filters)); + } + } + + if (type === 'channel') { + return json(await getChannelMetrics(websiteId, filters)); + } + + return badRequest(); +} diff --git a/src/app/api/websites/[websiteId]/pageviews/route.ts b/src/app/api/websites/[websiteId]/pageviews/route.ts new file mode 100644 index 0000000..af59bce --- /dev/null +++ b/src/app/api/websites/[websiteId]/pageviews/route.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; +import { getCompareDate } from '@/lib/date'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getPageviewStats, getSessionStats } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...dateRangeParams, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const [pageviews, sessions] = await Promise.all([ + getPageviewStats(websiteId, filters), + getSessionStats(websiteId, filters), + ]); + + if (filters.compare) { + const { startDate: compareStartDate, endDate: compareEndDate } = getCompareDate( + filters.compare, + filters.startDate, + filters.endDate, + ); + + const [comparePageviews, compareSessions] = await Promise.all([ + getPageviewStats(websiteId, { + ...filters, + startDate: compareStartDate, + endDate: compareEndDate, + }), + getSessionStats(websiteId, { + ...filters, + startDate: compareStartDate, + endDate: compareEndDate, + }), + ]); + + return json({ + pageviews, + sessions, + startDate: filters.startDate, + endDate: filters.endDate, + compare: { + pageviews: comparePageviews, + sessions: compareSessions, + startDate: compareStartDate, + endDate: compareEndDate, + }, + }); + } + + return json({ pageviews, sessions }); +} diff --git a/src/app/api/websites/[websiteId]/reports/route.ts b/src/app/api/websites/[websiteId]/reports/route.ts new file mode 100644 index 0000000..93e7ab4 --- /dev/null +++ b/src/app/api/websites/[websiteId]/reports/route.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, pagingParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getReports } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, + filters: { type: string }, +) { + const schema = z.object({ + ...filterParams, + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { page, pageSize, search } = query; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getReports( + { + where: { + websiteId, + type: filters.type, + }, + }, + { + page, + pageSize, + search, + }, + ); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/reset/route.ts b/src/app/api/websites/[websiteId]/reset/route.ts new file mode 100644 index 0000000..e0be5a5 --- /dev/null +++ b/src/app/api/websites/[websiteId]/reset/route.ts @@ -0,0 +1,25 @@ +import { parseRequest } from '@/lib/request'; +import { ok, unauthorized } from '@/lib/response'; +import { canUpdateWebsite } from '@/permissions'; +import { resetWebsite } from '@/queries/prisma'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + await resetWebsite(websiteId); + + return ok(); +} diff --git a/src/app/api/websites/[websiteId]/route.ts b/src/app/api/websites/[websiteId]/route.ts new file mode 100644 index 0000000..b4c0e7e --- /dev/null +++ b/src/app/api/websites/[websiteId]/route.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; +import { SHARE_ID_REGEX } from '@/lib/constants'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; +import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions'; +import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const website = await getWebsite(websiteId); + + return json(website); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + name: z.string().optional(), + domain: z.string().optional(), + shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { name, domain, shareId } = body; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + try { + const website = await updateWebsite(websiteId, { name, domain, shareId }); + + return Response.json(website); + } catch (e: any) { + if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) { + return badRequest({ message: 'That share ID is already taken.' }); + } + + return serverError(e); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canDeleteWebsite(auth, websiteId))) { + return unauthorized(); + } + + await deleteWebsite(websiteId); + + return ok(); +} diff --git a/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts b/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts new file mode 100644 index 0000000..b51f783 --- /dev/null +++ b/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts @@ -0,0 +1,92 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, notFound, ok, unauthorized } from '@/lib/response'; +import { anyObjectParam, segmentTypeParam } from '@/lib/schema'; +import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions'; +import { deleteSegment, getSegment, updateSegment } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; segmentId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, segmentId } = await params; + + const segment = await getSegment(segmentId); + + if (websiteId && !(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + return json(segment); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string; segmentId: string }> }, +) { + const schema = z.object({ + type: segmentTypeParam, + name: z.string().max(200), + parameters: anyObjectParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId, segmentId } = await params; + const { type, name, parameters } = body; + + const segment = await getSegment(segmentId); + + if (!segment) { + return notFound(); + } + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const result = await updateSegment(segmentId, { + type, + name, + parameters, + } as any); + + return json(result); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ websiteId: string; segmentId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, segmentId } = await params; + + const segment = await getSegment(segmentId); + + if (!segment) { + return notFound(); + } + + if (!(await canDeleteWebsite(auth, websiteId))) { + return unauthorized(); + } + + await deleteSegment(segmentId); + + return ok(); +} diff --git a/src/app/api/websites/[websiteId]/segments/route.ts b/src/app/api/websites/[websiteId]/segments/route.ts new file mode 100644 index 0000000..4592765 --- /dev/null +++ b/src/app/api/websites/[websiteId]/segments/route.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { anyObjectParam, searchParams, segmentTypeParam } from '@/lib/schema'; +import { canUpdateWebsite, canViewWebsite } from '@/permissions'; +import { createSegment, getWebsiteSegments } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + type: segmentTypeParam, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { type } = query; + + if (websiteId && !(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query); + + const segments = await getWebsiteSegments(websiteId, type, filters); + + return json(segments); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + type: segmentTypeParam, + name: z.string().max(200), + parameters: anyObjectParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { type, name, parameters } = body; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const result = await createSegment({ + id: uuid(), + websiteId, + type, + name, + parameters, + } as any); + + return json(result); +} diff --git a/src/app/api/websites/[websiteId]/session-data/properties/route.ts b/src/app/api/websites/[websiteId]/session-data/properties/route.ts new file mode 100644 index 0000000..2d8db15 --- /dev/null +++ b/src/app/api/websites/[websiteId]/session-data/properties/route.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getSessionDataProperties } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getSessionDataProperties(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/session-data/values/route.ts b/src/app/api/websites/[websiteId]/session-data/values/route.ts new file mode 100644 index 0000000..7d06870 --- /dev/null +++ b/src/app/api/websites/[websiteId]/session-data/values/route.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getSessionDataValues } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + propertyName: z.string().optional(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { propertyName } = query; + const filters = await getQueryFilters(query, websiteId); + + const data = await getSessionDataValues(websiteId, { + ...filters, + propertyName, + }); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts new file mode 100644 index 0000000..41b766d --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getSessionActivity } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; sessionId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId, sessionId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getSessionActivity(websiteId, sessionId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts new file mode 100644 index 0000000..6b5c241 --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts @@ -0,0 +1,25 @@ +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getSessionData } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; sessionId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, sessionId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getSessionData(websiteId, sessionId); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts new file mode 100644 index 0000000..1091663 --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts @@ -0,0 +1,25 @@ +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteSession } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; sessionId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, sessionId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getWebsiteSession(websiteId, sessionId); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/sessions/route.ts b/src/app/api/websites/[websiteId]/sessions/route.ts new file mode 100644 index 0000000..ed4757a --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/route.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams, pagingParams, searchParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteSessions } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...dateRangeParams, + ...filterParams, + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getWebsiteSessions(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/sessions/stats/route.ts b/src/app/api/websites/[websiteId]/sessions/stats/route.ts new file mode 100644 index 0000000..459830e --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/stats/route.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteSessionStats } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const metrics = await getWebsiteSessionStats(websiteId, filters); + + const data = Object.keys(metrics[0]).reduce((obj, key) => { + obj[key] = { + value: Number(metrics[0][key]) || 0, + }; + return obj; + }, {}); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/sessions/weekly/route.ts b/src/app/api/websites/[websiteId]/sessions/weekly/route.ts new file mode 100644 index 0000000..b9ccf3e --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/weekly/route.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, timezoneParam } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWeeklyTraffic } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + timezone: timezoneParam, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getWeeklyTraffic(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/stats/route.ts b/src/app/api/websites/[websiteId]/stats/route.ts new file mode 100644 index 0000000..07c8b96 --- /dev/null +++ b/src/app/api/websites/[websiteId]/stats/route.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; +import { getCompareDate } from '@/lib/date'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteStats } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...dateRangeParams, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getWebsiteStats(websiteId, filters); + + const { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate); + + const comparison = await getWebsiteStats(websiteId, { + ...filters, + startDate, + endDate, + }); + + return json({ ...data, comparison }); +} diff --git a/src/app/api/websites/[websiteId]/transfer/route.ts b/src/app/api/websites/[websiteId]/transfer/route.ts new file mode 100644 index 0000000..df2fed2 --- /dev/null +++ b/src/app/api/websites/[websiteId]/transfer/route.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { canTransferWebsiteToTeam, canTransferWebsiteToUser } from '@/permissions'; +import { updateWebsite } from '@/queries/prisma'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + userId: z.uuid().optional(), + teamId: z.uuid().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { userId, teamId } = body; + + if (userId) { + if (!(await canTransferWebsiteToUser(auth, websiteId, userId))) { + return unauthorized(); + } + + const website = await updateWebsite(websiteId, { + userId, + teamId: null, + }); + + return json(website); + } else if (teamId) { + if (!(await canTransferWebsiteToTeam(auth, websiteId, teamId))) { + return unauthorized(); + } + + const website = await updateWebsite(websiteId, { + userId: null, + teamId, + }); + + return json(website); + } + + return badRequest(); +} diff --git a/src/app/api/websites/[websiteId]/values/route.ts b/src/app/api/websites/[websiteId]/values/route.ts new file mode 100644 index 0000000..172325e --- /dev/null +++ b/src/app/api/websites/[websiteId]/values/route.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { EVENT_COLUMNS, FILTER_COLUMNS, SEGMENT_TYPES, SESSION_COLUMNS } from '@/lib/constants'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { dateRangeParams, fieldsParam, searchParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteSegments } from '@/queries/prisma'; +import { getValues } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + type: fieldsParam, + ...dateRangeParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { type } = query; + + if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type) && !SEGMENT_TYPES[type]) { + return badRequest(); + } + + let values: any[]; + + if (SEGMENT_TYPES[type]) { + values = (await getWebsiteSegments(websiteId, type))?.data?.map(segment => ({ + value: segment.name, + })); + } else { + const filters = await getQueryFilters(query, websiteId); + values = await getValues(websiteId, FILTER_COLUMNS[type], filters); + } + + return json(values.filter(n => n).sort()); +} diff --git a/src/app/api/websites/route.ts b/src/app/api/websites/route.ts new file mode 100644 index 0000000..e2b26c1 --- /dev/null +++ b/src/app/api/websites/route.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import redis from '@/lib/redis'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; +import { createWebsite, getWebsiteCount } from '@/queries/prisma'; +import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website'; + +const CLOUD_WEBSITE_LIMIT = 3; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + includeTeams: z.string().optional(), + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const userId = auth.user.id; + + const filters = await getQueryFilters(query); + + if (query.includeTeams) { + return json(await getAllUserWebsitesIncludingTeamOwner(userId, filters)); + } + + return json(await getUserWebsites(userId, filters)); +} + +export async function POST(request: Request) { + const schema = z.object({ + name: z.string().max(100), + domain: z.string().max(500), + shareId: z.string().max(50).nullable().optional(), + teamId: z.uuid().nullable().optional(), + id: z.uuid().nullable().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { id, name, domain, shareId, teamId } = body; + + if (process.env.CLOUD_MODE && !teamId) { + const account = await redis.client.get(`account:${auth.user.id}`); + + if (!account?.hasSubscription) { + const count = await getWebsiteCount(auth.user.id); + + if (count >= CLOUD_WEBSITE_LIMIT) { + return unauthorized({ message: 'Website limit reached.' }); + } + } + } + + if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) { + return unauthorized(); + } + + const data: any = { + id: id ?? uuid(), + createdBy: auth.user.id, + name, + domain, + shareId, + teamId, + }; + + if (!teamId) { + data.userId = auth.user.id; + } + + const website = await createWebsite(data); + + return json(website); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..afcbfc6 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from 'next'; +import { Suspense } from 'react'; +import { Providers } from './Providers'; +import '@fontsource/inter/300.css'; +import '@fontsource/inter/400.css'; +import '@fontsource/inter/500.css'; +import '@fontsource/inter/700.css'; +import '@umami/react-zen/styles.css'; +import '@/styles/global.css'; +import '@/styles/variables.css'; + +export default function ({ children }) { + if (process.env.DISABLE_UI) { + return ( + + + + ); + } + + return ( + + + + + + + + + + + + + + + + {children} + + + + ); +} + +export const metadata: Metadata = { + title: { + template: '%s | Umami', + default: 'Umami', + }, +}; diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx new file mode 100644 index 0000000..26d78dd --- /dev/null +++ b/src/app/login/LoginForm.tsx @@ -0,0 +1,70 @@ +import { + Column, + Form, + FormButtons, + FormField, + FormSubmitButton, + Heading, + Icon, + PasswordField, + TextField, +} from '@umami/react-zen'; +import { useRouter } from 'next/navigation'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { Logo } from '@/components/svg'; +import { setClientAuthToken } from '@/lib/client'; +import { setUser } from '@/store/app'; + +export function LoginForm() { + const { formatMessage, labels, getErrorMessage } = useMessages(); + const router = useRouter(); + const { mutateAsync, error } = useUpdateQuery('/auth/login'); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async ({ token, user }) => { + setClientAuthToken(token); + setUser(user); + router.push('/'); + }, + }); + }; + + return ( + + + + + umami +
+ + + + + + + + + + {formatMessage(labels.login)} + + +
+
+ ); +} diff --git a/src/app/login/LoginPage.tsx b/src/app/login/LoginPage.tsx new file mode 100644 index 0000000..6f485e3 --- /dev/null +++ b/src/app/login/LoginPage.tsx @@ -0,0 +1,11 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { LoginForm } from './LoginForm'; + +export function LoginPage() { + return ( + + + + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..ea27735 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next'; +import { LoginPage } from './LoginPage'; + +export default async function () { + if (process.env.DISABLE_LOGIN || process.env.CLOUD_MODE) { + return null; + } + + return ; +} + +export const metadata: Metadata = { + title: 'Login', +}; diff --git a/src/app/logout/LogoutPage.tsx b/src/app/logout/LogoutPage.tsx new file mode 100644 index 0000000..33e1615 --- /dev/null +++ b/src/app/logout/LogoutPage.tsx @@ -0,0 +1,25 @@ +'use client'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { useApi } from '@/components/hooks'; +import { removeClientAuthToken } from '@/lib/client'; +import { setUser } from '@/store/app'; + +export function LogoutPage() { + const router = useRouter(); + const { post } = useApi(); + + useEffect(() => { + async function logout() { + await post('/auth/logout'); + + window.location.href = `${process.env.basePath || ''}/login`; + } + + removeClientAuthToken(); + setUser(null); + logout(); + }, [router, post]); + + return null; +} diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx new file mode 100644 index 0000000..2095278 --- /dev/null +++ b/src/app/logout/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next'; +import { LogoutPage } from './LogoutPage'; + +export default function () { + if (process.env.DISABLE_LOGIN || process.env.CLOUD_MODE) { + return null; + } + + return ; +} + +export const metadata: Metadata = { + title: 'Logout', +}; diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..b376151 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,13 @@ +'use client'; +import { Flexbox } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; + +export default function () { + const { formatMessage, labels } = useMessages(); + + return ( + +

{formatMessage(labels.pageNotFound)}

+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..6f0033d --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,19 @@ +'use client'; +import { redirect } from 'next/navigation'; +import { useEffect } from 'react'; +import { LAST_TEAM_CONFIG } from '@/lib/constants'; +import { getItem } from '@/lib/storage'; + +export default function RootPage() { + useEffect(() => { + const lastTeam = getItem(LAST_TEAM_CONFIG); + + if (lastTeam) { + redirect(`/teams/${lastTeam}/websites`); + } else { + redirect(`/websites`); + } + }, []); + + return null; +} diff --git a/src/app/share/[...shareId]/Footer.tsx b/src/app/share/[...shareId]/Footer.tsx new file mode 100644 index 0000000..f294862 --- /dev/null +++ b/src/app/share/[...shareId]/Footer.tsx @@ -0,0 +1,12 @@ +import { Row, Text } from '@umami/react-zen'; +import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants'; + +export function Footer() { + return ( + + + umami {`v${CURRENT_VERSION}`} + + + ); +} diff --git a/src/app/share/[...shareId]/Header.tsx b/src/app/share/[...shareId]/Header.tsx new file mode 100644 index 0000000..d7b7dcb --- /dev/null +++ b/src/app/share/[...shareId]/Header.tsx @@ -0,0 +1,24 @@ +import { Icon, Row, Text, ThemeButton } from '@umami/react-zen'; +import { LanguageButton } from '@/components/input/LanguageButton'; +import { PreferencesButton } from '@/components/input/PreferencesButton'; +import { Logo } from '@/components/svg'; + +export function Header() { + return ( + + + + + + + umami + + + + + + + + + ); +} diff --git a/src/app/share/[...shareId]/SharePage.tsx b/src/app/share/[...shareId]/SharePage.tsx new file mode 100644 index 0000000..7ed0667 --- /dev/null +++ b/src/app/share/[...shareId]/SharePage.tsx @@ -0,0 +1,41 @@ +'use client'; +import { Column, useTheme } from '@umami/react-zen'; +import { useEffect } from 'react'; +import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader'; +import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage'; +import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider'; +import { PageBody } from '@/components/common/PageBody'; +import { useShareTokenQuery } from '@/components/hooks'; +import { Footer } from './Footer'; +import { Header } from './Header'; + +export function SharePage({ shareId }) { + const { shareToken, isLoading } = useShareTokenQuery(shareId); + const { setTheme } = useTheme(); + + useEffect(() => { + const url = new URL(window?.location?.href); + const theme = url.searchParams.get('theme'); + + if (theme === 'light' || theme === 'dark') { + setTheme(theme); + } + }, []); + + if (isLoading || !shareToken) { + return null; + } + + return ( + + +
+ + + + +