From 308ae98472e9542a0416096988d033793e6a7727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Sat, 20 Jan 2024 22:54:38 +0100 Subject: [PATCH] migrate to app dir and ssr --- README.md | 25 +- apps/web/package.json | 11 +- apps/web/public/logo.svg | 15 + .../[dashboardId]/list-reports.tsx | 137 +++++++++ .../[projectId]/[dashboardId]/page.tsx | 42 +++ .../[projectId]/events/event-icon.tsx | 46 +++ .../[projectId]/events/event-list-item.tsx | 71 +++++ .../[projectId]/events/list-events.tsx | 61 ++++ .../[projectId]/events/page.tsx | 19 ++ .../[projectId]/header-dashboards.tsx | 32 ++ .../[projectId]/list-dashboards.tsx | 94 ++++++ .../[organizationId]/[projectId]/page.tsx | 25 ++ .../[profileId]/list-profile-events.tsx | 61 ++++ .../[projectId]/profiles/[profileId]/page.tsx | 87 ++++++ .../[projectId]/profiles/list-profiles.tsx | 52 ++++ .../[projectId]/profiles/page.tsx | 19 ++ .../profiles/profile-list-item.tsx | 42 +++ .../[projectId]/reports/[reportId]/page.tsx | 32 ++ .../[projectId]/reports/page.tsx | 27 ++ .../[projectId]/reports/report-editor.tsx | 88 ++++++ .../(app)/[organizationId]/list-projects.tsx | 49 +++ .../src/app/(app)/[organizationId]/page.tsx | 17 ++ .../settings/clients/list-clients.tsx | 38 +++ .../settings/clients/page.tsx | 20 ++ .../organization/edit-organization.tsx | 73 +++++ .../settings/organization/invited-users.tsx | 111 +++++++ .../settings/organization/page.tsx | 26 ++ .../(app)/[organizationId]/settings/page.tsx | 1 + .../settings/profile/edit-profile.tsx | 81 +++++ .../settings/profile/logout.tsx | 28 ++ .../settings/profile/page.tsx | 26 ++ .../settings/projects/list-projects.tsx | 40 +++ .../settings/projects/page.tsx | 20 ++ apps/web/src/app/(app)/layout-menu.tsx | 122 ++++++++ .../(app)/layout-organization-selector.tsx | 30 ++ .../src/app/(app)/layout-project-selector.tsx | 46 +++ apps/web/src/app/(app)/layout-sidebar.tsx | 72 +++++ .../app/(app)/layout-sticky-below-header.tsx | 22 ++ apps/web/src/app/(app)/layout.tsx | 24 ++ apps/web/src/app/(app)/list-organizations.tsx | 25 ++ apps/web/src/app/(app)/page-layout.tsx | 34 +++ apps/web/src/app/(app)/page.tsx | 26 ++ apps/web/src/app/_trpc/client.tsx | 40 +++ .../src/app/api/auth/[...nextauth]/route.ts | 6 + apps/web/src/app/api/cookie/route.ts | 20 ++ apps/web/src/app/api/fml/route.ts | 10 + apps/web/src/{pages => app}/api/setup.ts | 23 +- apps/web/src/app/api/trpc/[trpc]/route.ts | 18 ++ apps/web/src/app/auth.tsx | 87 ++++++ apps/web/src/app/cookie-provider.tsx | 24 ++ apps/web/src/app/layout.tsx | 52 ++++ apps/web/src/app/providers.tsx | 72 +++++ apps/web/src/components/Card.tsx | 4 +- apps/web/src/components/Content.tsx | 2 + apps/web/src/components/DataTable.tsx | 2 + apps/web/src/components/Logo.tsx | 8 + apps/web/src/components/Pagination.tsx | 4 +- apps/web/src/components/Syntax.tsx | 2 + apps/web/src/components/Widget.tsx | 43 +++ .../src/components/clients/ClientActions.tsx | 15 +- .../web/src/components/events/EventsTable.tsx | 95 ------ .../src/components/events/ListProperties.tsx | 35 +++ .../src/components/forms/InputWithLabel.tsx | 14 +- .../components/general/ExpandableListItem.tsx | 54 ++++ .../web/src/components/layouts/MainLayout.tsx | 52 ---- .../src/components/layouts/SettingsLayout.tsx | 55 ---- .../web/src/components/navbar/Breadcrumbs.tsx | 20 +- .../src/components/navbar/NavbarCreate.tsx | 2 +- apps/web/src/components/navbar/NavbarMenu.tsx | 18 +- .../components/navbar/NavbarUserDropdown.tsx | 8 +- .../src/components/profiles/ProfileAvatar.tsx | 53 ++++ .../components/projects/ProjectActions.tsx | 15 +- apps/web/src/components/projects/table.tsx | 2 +- .../src/components/report/ReportChartType.tsx | 14 +- .../src/components/report/ReportInterval.tsx | 14 +- .../src/components/report/ReportLineType.tsx | 34 +++ .../components/report/ReportSaveButton.tsx | 17 +- .../report/chart/ChartAnimation.tsx | 5 +- .../src/components/report/chart/LazyChart.tsx | 2 + .../report/chart/ReportAreaChart.tsx | 104 +++++++ .../report/chart/ReportBarChart.tsx | 34 +-- ...hartTooltip.tsx => ReportChartTooltip.tsx} | 43 +-- .../report/chart/ReportHistogramChart.tsx | 98 +++--- .../report/chart/ReportLineChart.tsx | 98 +++--- .../report/chart/ReportMetricChart.tsx | 79 +++++ .../report/chart/ReportPieChart.tsx | 81 +++++ .../components/report/chart/ReportTable.tsx | 71 +++-- .../components/report/chart/chart-utils.ts | 5 + .../web/src/components/report/chart/index.tsx | 42 ++- .../components/report/hooks/useReportId.ts | 9 - apps/web/src/components/report/reportSlice.ts | 43 ++- .../report/sidebar/ReportBreakdowns.tsx | 13 +- .../report/sidebar/ReportEventFilters.tsx | 19 +- .../report/sidebar/ReportEvents.tsx | 12 +- apps/web/src/components/ui/RenderDots.tsx | 2 + apps/web/src/components/ui/alert-dialog.tsx | 2 + apps/web/src/components/ui/alert.tsx | 59 ++++ apps/web/src/components/ui/aspect-ratio.tsx | 2 + apps/web/src/components/ui/avatar.tsx | 2 + apps/web/src/components/ui/badge.tsx | 2 + apps/web/src/components/ui/button.tsx | 5 +- apps/web/src/components/ui/checkbox.tsx | 2 + .../src/components/ui/combobox-advanced.tsx | 44 +-- apps/web/src/components/ui/combobox-multi.tsx | 2 + apps/web/src/components/ui/combobox.tsx | 40 +-- apps/web/src/components/ui/command.tsx | 2 + apps/web/src/components/ui/dialog.tsx | 2 + apps/web/src/components/ui/dropdown-menu.tsx | 2 + apps/web/src/components/ui/input.tsx | 11 +- apps/web/src/components/ui/label.tsx | 2 + apps/web/src/components/ui/popover.tsx | 2 + apps/web/src/components/ui/radio-group.tsx | 2 + apps/web/src/components/ui/scroll-area.tsx | 35 +-- apps/web/src/components/ui/sheet.tsx | 2 + apps/web/src/components/ui/table.tsx | 8 +- apps/web/src/components/ui/toast.tsx | 2 + apps/web/src/components/ui/toaster.tsx | 2 + apps/web/src/components/ui/tooltip.tsx | 4 +- apps/web/src/components/ui/use-toast.ts | 4 +- .../src/components/user/ChangePassword.tsx | 2 +- apps/web/src/hooks/useAppParams.ts | 16 + apps/web/src/hooks/useEventNames.ts | 12 + apps/web/src/hooks/useOrganizationParams.ts | 14 - apps/web/src/hooks/useRechartDataModel.ts | 34 +++ apps/web/src/hooks/useSetCookie.ts | 16 + apps/web/src/hooks/useVisibleSeries.ts | 28 ++ apps/web/src/modals/AddClient.tsx | 27 +- apps/web/src/modals/AddDashboard.tsx | 22 +- apps/web/src/modals/AddProject.tsx | 28 +- apps/web/src/modals/Confirm.tsx | 2 + apps/web/src/modals/EditClient.tsx | 46 ++- apps/web/src/modals/EditDashboard.tsx | 49 ++- apps/web/src/modals/EditProject.tsx | 49 ++- apps/web/src/modals/EditReport.tsx | 6 +- apps/web/src/modals/Modal/Container.tsx | 19 +- apps/web/src/modals/SaveReport.tsx | 43 ++- apps/web/src/modals/index.tsx | 16 +- .../[organization]/[project]/[dashboard].tsx | 161 ---------- .../pages/[organization]/[project]/events.tsx | 56 ---- .../pages/[organization]/[project]/index.tsx | 110 ------- .../[project]/profiles/[profileId].tsx | 68 ----- .../[project]/profiles/index.tsx | 114 ------- .../[project]/reports/[reportId].tsx | 5 - .../[project]/reports/index.tsx | 100 ------- apps/web/src/pages/[organization]/index.tsx | 46 --- .../pages/[organization]/settings/clients.tsx | 27 -- .../[organization]/settings/organization.tsx | 90 ------ .../pages/[organization]/settings/profile.tsx | 88 ------ .../[organization]/settings/projects.tsx | 27 -- apps/web/src/pages/_app.tsx | 43 --- apps/web/src/pages/api/auth/[...nextauth].ts | 4 - apps/web/src/pages/api/trpc/[trpc].ts | 24 -- apps/web/src/pages/index.tsx | 12 - apps/web/src/redux/index.ts | 19 +- apps/web/src/server/api/routers/chart.ts | 141 +++++---- apps/web/src/server/api/routers/client.ts | 13 +- apps/web/src/server/api/routers/dashboard.ts | 97 +++--- apps/web/src/server/api/routers/event.ts | 34 ++- .../src/server/api/routers/organization.ts | 19 +- apps/web/src/server/api/routers/profile.ts | 36 ++- apps/web/src/server/api/routers/project.ts | 38 +-- apps/web/src/server/api/routers/report.ts | 86 ++---- apps/web/src/server/api/routers/user.ts | 15 + apps/web/src/server/api/trpc.ts | 9 +- apps/web/src/server/auth.ts | 30 +- apps/web/src/server/db.ts | 39 +++ apps/web/src/server/getServerSideProps.ts | 75 ----- .../src/server/services/clients.service.ts | 12 + .../src/server/services/dashboard.service.ts | 81 ++++- .../server/services/organization.service.ts | 22 +- .../src/server/services/profile.service.ts | 26 ++ .../src/server/services/project.service.ts | 41 ++- .../src/server/services/reports.service.ts | 75 +++++ apps/web/src/server/services/user.service.ts | 20 ++ apps/web/src/styles/globals.css | 7 + apps/web/src/types/index.ts | 4 +- apps/web/src/utils/api.ts | 86 ------ apps/web/src/utils/constants.ts | 37 +++ apps/web/src/utils/getters.ts | 2 +- apps/web/src/utils/theme.ts | 2 +- apps/web/src/utils/validation.ts | 15 +- apps/web/tailwind.config.js | 10 +- apps/web/tsconfig.json | 16 +- .../migration.sql | 110 +++++++ .../migration.sql | 15 + .../migration.sql | 18 ++ .../migration.sql | 5 + .../migration.sql | 8 + .../20240116183124_add_invite/migration.sql | 14 + .../migration.sql | 2 + packages/db/prisma/schema.prisma | 76 +++-- pnpm-lock.yaml | 283 +++++++++++++++++- tooling/eslint/nextjs.js | 4 +- tooling/eslint/package.json | 3 +- 194 files changed, 4706 insertions(+), 2194 deletions(-) create mode 100644 apps/web/public/logo.svg create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/[dashboardId]/list-reports.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/[dashboardId]/page.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/events/list-events.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/header-dashboards.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/list-dashboards.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/list-profile-events.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/list-profiles.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/page.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list-item.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/reports/[reportId]/page.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/reports/page.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/reports/report-editor.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/list-projects.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/page.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/settings/clients/list-clients.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/settings/clients/page.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/settings/organization/edit-organization.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/settings/organization/invited-users.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/settings/organization/page.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/settings/page.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/settings/profile/edit-profile.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/settings/profile/logout.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/settings/profile/page.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/settings/projects/list-projects.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/settings/projects/page.tsx create mode 100644 apps/web/src/app/(app)/layout-menu.tsx create mode 100644 apps/web/src/app/(app)/layout-organization-selector.tsx create mode 100644 apps/web/src/app/(app)/layout-project-selector.tsx create mode 100644 apps/web/src/app/(app)/layout-sidebar.tsx create mode 100644 apps/web/src/app/(app)/layout-sticky-below-header.tsx create mode 100644 apps/web/src/app/(app)/layout.tsx create mode 100644 apps/web/src/app/(app)/list-organizations.tsx create mode 100644 apps/web/src/app/(app)/page-layout.tsx create mode 100644 apps/web/src/app/(app)/page.tsx create mode 100644 apps/web/src/app/_trpc/client.tsx create mode 100644 apps/web/src/app/api/auth/[...nextauth]/route.ts create mode 100644 apps/web/src/app/api/cookie/route.ts create mode 100644 apps/web/src/app/api/fml/route.ts rename apps/web/src/{pages => app}/api/setup.ts (70%) create mode 100644 apps/web/src/app/api/trpc/[trpc]/route.ts create mode 100644 apps/web/src/app/auth.tsx create mode 100644 apps/web/src/app/cookie-provider.tsx create mode 100644 apps/web/src/app/layout.tsx create mode 100644 apps/web/src/app/providers.tsx create mode 100644 apps/web/src/components/Logo.tsx create mode 100644 apps/web/src/components/Widget.tsx delete mode 100644 apps/web/src/components/events/EventsTable.tsx create mode 100644 apps/web/src/components/events/ListProperties.tsx create mode 100644 apps/web/src/components/general/ExpandableListItem.tsx delete mode 100644 apps/web/src/components/layouts/MainLayout.tsx delete mode 100644 apps/web/src/components/layouts/SettingsLayout.tsx create mode 100644 apps/web/src/components/profiles/ProfileAvatar.tsx create mode 100644 apps/web/src/components/report/ReportLineType.tsx create mode 100644 apps/web/src/components/report/chart/ReportAreaChart.tsx rename apps/web/src/components/report/chart/{ReportLineChartTooltip.tsx => ReportChartTooltip.tsx} (57%) create mode 100644 apps/web/src/components/report/chart/ReportMetricChart.tsx create mode 100644 apps/web/src/components/report/chart/ReportPieChart.tsx create mode 100644 apps/web/src/components/report/chart/chart-utils.ts delete mode 100644 apps/web/src/components/report/hooks/useReportId.ts create mode 100644 apps/web/src/components/ui/alert.tsx create mode 100644 apps/web/src/hooks/useAppParams.ts create mode 100644 apps/web/src/hooks/useEventNames.ts delete mode 100644 apps/web/src/hooks/useOrganizationParams.ts create mode 100644 apps/web/src/hooks/useRechartDataModel.ts create mode 100644 apps/web/src/hooks/useSetCookie.ts create mode 100644 apps/web/src/hooks/useVisibleSeries.ts delete mode 100644 apps/web/src/pages/[organization]/[project]/[dashboard].tsx delete mode 100644 apps/web/src/pages/[organization]/[project]/events.tsx delete mode 100644 apps/web/src/pages/[organization]/[project]/index.tsx delete mode 100644 apps/web/src/pages/[organization]/[project]/profiles/[profileId].tsx delete mode 100644 apps/web/src/pages/[organization]/[project]/profiles/index.tsx delete mode 100644 apps/web/src/pages/[organization]/[project]/reports/[reportId].tsx delete mode 100644 apps/web/src/pages/[organization]/[project]/reports/index.tsx delete mode 100644 apps/web/src/pages/[organization]/index.tsx delete mode 100644 apps/web/src/pages/[organization]/settings/clients.tsx delete mode 100644 apps/web/src/pages/[organization]/settings/organization.tsx delete mode 100644 apps/web/src/pages/[organization]/settings/profile.tsx delete mode 100644 apps/web/src/pages/[organization]/settings/projects.tsx delete mode 100644 apps/web/src/pages/_app.tsx delete mode 100644 apps/web/src/pages/api/auth/[...nextauth].ts delete mode 100644 apps/web/src/pages/api/trpc/[trpc].ts delete mode 100644 apps/web/src/pages/index.tsx delete mode 100644 apps/web/src/server/getServerSideProps.ts create mode 100644 apps/web/src/server/services/clients.service.ts create mode 100644 apps/web/src/server/services/reports.service.ts create mode 100644 apps/web/src/server/services/user.service.ts delete mode 100644 apps/web/src/utils/api.ts create mode 100644 packages/db/prisma/migrations/20240113095542_remove_slug_2/migration.sql create mode 100644 packages/db/prisma/migrations/20240116100132_add_recent_dashboards/migration.sql create mode 100644 packages/db/prisma/migrations/20240116101051_fix_recent_dashboards/migration.sql create mode 100644 packages/db/prisma/migrations/20240116101524_add_relations_to_recent/migration.sql create mode 100644 packages/db/prisma/migrations/20240116101723_remove_name_from_recent_dashboards/migration.sql create mode 100644 packages/db/prisma/migrations/20240116183124_add_invite/migration.sql create mode 100644 packages/db/prisma/migrations/20240117210232_add_line_type/migration.sql diff --git a/README.md b/README.md index 20400406..841a2fe0 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,23 @@ Mixan is a simple analytics tool for logging events on web and react-native. My ### Speed/Benchmark -As of today (2023-12-12) I have more then 1.2 million events in PSQL and performance is smooth as butter 🧈. Only thing that is slow (2s response time) is to get all unique events. Solved now with cache but can probably make better with `indexes` and avoid using `distinct`. +As of today (~~2023-12-12~~ 2024-01-16) I have more then ~~1.2~~ 2.8 million events and 20 thousand profiles in postgres and performance is smooth as butter\* 🧈. Only thing that is slow (2s response time) is to get all unique events. Solved now with cache but can probably make better with `indexes` and avoid using `distinct`. + +\* Smooth as butter is somewhat exaggerated but I would say it still fast! It takes 1.4 sec to search through all events (3 million) with advanced where clause. I think this performance is absolutly good enough. ### GUI +- [x] Fix design for report editor +- [x] Fix profiles + - [x] Pagination + - [x] Filter by event name +- [x] Fix [profileId] + - [x] Add events + - [x] Improve design for properties and linked profiles +- [x] New design for events +- [ ] Map events to convertions +- [ ] Map ids +- [x] Fix menu links when projectId is undefined - [x] Fix tables on settings - [x] Rename event label - [ ] Common web dashboard @@ -36,14 +49,16 @@ As of today (2023-12-12) I have more then 1.2 million events in PSQL and perform - [x] View events in a list - [x] Simple filters - [x] View profiles in a list -- [ ] Invite users +- [x] Invite users - [ ] Drag n Drop reports on dashboard - [x] Manage dashboards -- [ ] Support more chart types +- [x] Support more chart types - [x] Bar - [x] Histogram - - [ ] Pie - - [ ] Area + - [x] Pie + - [x] Area + - [x] Metric + - [x] Line - [ ] Support funnels - [ ] Support multiple breakdowns - [x] Aggregations (sum, average...) diff --git a/apps/web/package.json b/apps/web/package.json index 3ccb662d..b96a50ef 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "pnpm with-env next dev", + "dev": "rm -rf .next && pnpm with-env next dev", "build": "next build", "start": "next start", "lint": "eslint .", @@ -40,16 +40,19 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "cmdk": "^0.2.0", + "hamburger-react": "^2.5.0", "lodash.debounce": "^4.0.8", "lottie-react": "^2.4.0", "lucide-react": "^0.286.0", "mitt": "^3.0.1", - "next": "13.4", + "next": "~14.0.4", "next-auth": "^4.23.0", + "nuqs": "^1.15.2", "prisma-error-enum": "^0.1.3", "ramda": "^0.29.1", "random-animal-name": "^0.1.1", "react": "18.2.0", + "react-animate-height": "^3.2.3", "react-dom": "18.2.0", "react-hook-form": "^7.47.0", "react-in-viewport": "1.0.0-alpha.30", @@ -93,8 +96,8 @@ "root": true, "extends": [ "@mixan/eslint-config/base", - "@mixan/eslint-config/nextjs", - "@mixan/eslint-config/react" + "@mixan/eslint-config/react", + "@mixan/eslint-config/nextjs" ] }, "prettier": "@mixan/prettier-config" diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg new file mode 100644 index 00000000..264890a2 --- /dev/null +++ b/apps/web/public/logo.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/[dashboardId]/list-reports.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/[dashboardId]/list-reports.tsx new file mode 100644 index 00000000..d4c5059b --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/[dashboardId]/list-reports.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; +import { LazyChart } from '@/components/report/chart/LazyChart'; +import { Button } from '@/components/ui/button'; +import { Combobox } from '@/components/ui/combobox'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useAppParams } from '@/hooks/useAppParams'; +import type { getReportsByDashboardId } from '@/server/services/reports.service'; +import type { IChartRange } from '@/types'; +import { cn } from '@/utils/cn'; +import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants'; +import { ChevronRight, MoreHorizontal, PlusIcon, Trash } from 'lucide-react'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; + +interface ListReportsProps { + reports: Awaited>; +} + +export function ListReports({ reports }: ListReportsProps) { + const router = useRouter(); + const params = useAppParams<{ dashboardId: string }>(); + const [range, setRange] = useState(null); + + return ( + <> + + { + setRange((p) => (p === value ? null : value)); + }} + items={Object.values(timeRanges).map((key) => ({ + label: key, + value: key, + }))} + /> + + +
+ {reports.map((report) => { + const chartRange = report.range; // timeRanges[report.range]; + return ( +
+ +
+
{report.name}
+ {chartRange !== null && ( +
+ + {chartRange} + + {range !== null && {range}} +
+ )} +
+
+ + + + + + + { + // event.stopPropagation(); + // deletion.mutate({ + // reportId: report.id, + // }); + }} + > + + Delete + + + + + +
+ +
+ +
+
+ ); + })} +
+ + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/[dashboardId]/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/[dashboardId]/page.tsx new file mode 100644 index 00000000..7740aeab --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/[dashboardId]/page.tsx @@ -0,0 +1,42 @@ +import PageLayout from '@/app/(app)/page-layout'; +import { getSession } from '@/server/auth'; +import { + createRecentDashboard, + getDashboardById, +} from '@/server/services/dashboard.service'; +import { getReportsByDashboardId } from '@/server/services/reports.service'; +import { revalidateTag } from 'next/cache'; + +import { ListReports } from './list-reports'; + +interface PageProps { + params: { + organizationId: string; + projectId: string; + dashboardId: string; + }; +} + +export default async function Page({ + params: { organizationId, projectId, dashboardId }, +}: PageProps) { + const session = await getSession(); + const dashboard = await getDashboardById(dashboardId); + const reports = await getReportsByDashboardId(dashboardId); + const userId = session?.user.id; + if (userId && dashboard) { + await createRecentDashboard({ + userId, + organizationId, + projectId, + dashboardId, + }); + revalidateTag(`recentDashboards__${userId}`); + } + + return ( + + + + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx new file mode 100644 index 00000000..fa6a44c1 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-icon.tsx @@ -0,0 +1,46 @@ +import { cn } from '@/utils/cn'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; +import { ActivityIcon, BotIcon, MonitorPlayIcon } from 'lucide-react'; + +const variants = cva('flex items-center justify-center shrink-0', { + variants: { + size: { + sm: 'w-6 h-6 rounded', + default: 'w-12 h-12 rounded-xl', + }, + }, + defaultVariants: { + size: 'default', + }, +}); + +type EventIconProps = VariantProps & { + name: string; + className?: string; +}; + +const records = { + default: { Icon: BotIcon, text: 'text-chart-0', bg: 'bg-chart-0/10' }, + screen_view: { + Icon: MonitorPlayIcon, + text: 'text-chart-3', + bg: 'bg-chart-3/10', + }, + session_start: { + Icon: ActivityIcon, + text: 'text-chart-2', + bg: 'bg-chart-2/10', + }, +}; + +export function EventIcon({ className, name, size }: EventIconProps) { + const { Icon, text, bg } = + name in records ? records[name as keyof typeof records] : records.default; + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx new file mode 100644 index 00000000..9eb88849 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/event-list-item.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useMemo } from 'react'; +import type { RouterOutputs } from '@/app/_trpc/client'; +import { ListProperties } from '@/components/events/ListProperties'; +import { ExpandableListItem } from '@/components/general/ExpandableListItem'; +import { ProfileAvatar } from '@/components/profiles/ProfileAvatar'; +import { useAppParams } from '@/hooks/useAppParams'; +import { cn } from '@/utils/cn'; +import { formatDateTime } from '@/utils/date'; +import { getProfileName } from '@/utils/getters'; +import { round } from '@/utils/math'; +import { Activity, BotIcon, MonitorPlay } from 'lucide-react'; +import Link from 'next/link'; + +import { EventIcon } from './event-icon'; + +type EventListItemProps = RouterOutputs['event']['list'][number]; + +export function EventListItem({ + profile, + createdAt, + name, + properties, +}: EventListItemProps) { + const params = useAppParams(); + + const bullets = useMemo(() => { + const bullets: React.ReactNode[] = [ + {formatDateTime(createdAt)}, + ]; + + if (profile) { + bullets.push( + + + {getProfileName(profile)} + + ); + } + + if (typeof properties.duration === 'number') { + bullets.push(`${round(properties.duration / 1000, 1)}s`); + } + + switch (name) { + case 'screen_view': { + const route = (properties?.route || properties?.path) as string; + if (route) { + bullets.push(route); + } + break; + } + } + + return bullets; + }, [name, createdAt, profile, properties, params]); + + return ( + } + > + + + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/list-events.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/list-events.tsx new file mode 100644 index 00000000..515d7ea7 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/list-events.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { api } from '@/app/_trpc/client'; +import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; +import { Pagination, usePagination } from '@/components/Pagination'; +import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; + +import { EventListItem } from './event-list-item'; + +interface ListEventsProps { + projectId: string; +} +export function ListEvents({ projectId }: ListEventsProps) { + const pagination = usePagination(); + const [eventFilters, setEventFilters] = useState([]); + const eventsQuery = api.event.list.useQuery( + { + events: eventFilters, + projectId: projectId, + ...pagination, + }, + { + keepPreviousData: true, + } + ); + const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]); + + const filterEventsQuery = api.chart.events.useQuery({ + projectId: projectId, + }); + const filterEvents = (filterEventsQuery.data ?? []).map((item) => ({ + value: item.name, + label: item.name, + })); + + return ( + <> + +
+ +
+
+
+
+ {events.map((item) => ( + + ))} +
+
+ +
+
+ + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx new file mode 100644 index 00000000..9c8c2b49 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/events/page.tsx @@ -0,0 +1,19 @@ +import PageLayout from '@/app/(app)/page-layout'; + +import { ListEvents } from './list-events'; + +interface PageProps { + params: { + organizationId: string; + projectId: string; + }; +} +export default function Page({ + params: { organizationId, projectId }, +}: PageProps) { + return ( + + + + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/header-dashboards.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/header-dashboards.tsx new file mode 100644 index 00000000..5ff73200 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/header-dashboards.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { pushModal } from '@/modals'; +import { PlusIcon } from 'lucide-react'; + +import { StickyBelowHeader } from '../../layout-sticky-below-header'; + +interface HeaderDashboardsProps { + projectId: string; +} + +export function HeaderDashboards({ projectId }: HeaderDashboardsProps) { + return ( + +
+
+ +
+ + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/list-dashboards.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/list-dashboards.tsx new file mode 100644 index 00000000..c1c78023 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/list-dashboards.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { api, handleError, handleErrorToastOptions } from '@/app/_trpc/client'; +import { Card, CardActions, CardActionsItem } from '@/components/Card'; +import { ToastAction } from '@/components/ui/toast'; +import { toast } from '@/components/ui/use-toast'; +import { pushModal } from '@/modals'; +import type { getDashboardsByProjectId } from '@/server/services/dashboard.service'; +import { Pencil, Plus, Trash } from 'lucide-react'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; + +interface ListDashboardsProps { + dashboards: Awaited>; +} + +export function ListDashboards({ dashboards }: ListDashboardsProps) { + const router = useRouter(); + const params = useParams(); + const { organizationId, projectId } = params; + const deletion = api.dashboard.delete.useMutation({ + onError: (error, variables) => { + return handleErrorToastOptions({ + action: ( + { + deletion.mutate({ + forceDelete: true, + id: variables.id, + }); + }} + > + Force delete + + ), + })(error); + }, + onSuccess() { + router.refresh(); + toast({ + title: 'Success', + description: 'Dashboard deleted.', + }); + }, + }); + + return ( + <> +
+ {dashboards.map((item) => ( + +
+ + {item.name} + + {item.project.name} + + +
+ + + + + + + + + +
+ ))} +
+ + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx new file mode 100644 index 00000000..77e7f4ba --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/page.tsx @@ -0,0 +1,25 @@ +import PageLayout from '@/app/(app)/page-layout'; +import { getDashboardsByProjectId } from '@/server/services/dashboard.service'; + +import { HeaderDashboards } from './header-dashboards'; +import { ListDashboards } from './list-dashboards'; + +interface PageProps { + params: { + organizationId: string; + projectId: string; + }; +} + +export default async function Page({ + params: { organizationId, projectId }, +}: PageProps) { + const dashboards = await getDashboardsByProjectId(projectId); + + return ( + + + + + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/list-profile-events.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/list-profile-events.tsx new file mode 100644 index 00000000..0f18e4ca --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/list-profile-events.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { useMemo } from 'react'; +import { api } from '@/app/_trpc/client'; +import { Pagination, usePagination } from '@/components/Pagination'; +import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; +import { useEventNames } from '@/hooks/useEventNames'; +import { parseAsJson, useQueryState } from 'nuqs'; + +import { EventListItem } from '../../events/event-list-item'; + +interface ListProfileEvents { + projectId: string; + profileId: string; +} + +export default function ListProfileEvents({ + projectId, + profileId, +}: ListProfileEvents) { + const pagination = usePagination(); + const [eventFilters, setEventFilters] = useQueryState( + 'events', + parseAsJson().withDefault([]) + ); + + const eventNames = useEventNames(projectId); + const eventsQuery = api.event.list.useQuery( + { + projectId, + profileId, + events: eventFilters, + ...pagination, + }, + { + keepPreviousData: true, + } + ); + const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]); + + return ( + <> +
+ +
+
+ {events.map((item) => ( + + ))} +
+
+ +
+ + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx new file mode 100644 index 00000000..ce7fdf29 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/[profileId]/page.tsx @@ -0,0 +1,87 @@ +import PageLayout from '@/app/(app)/page-layout'; +import { ListProperties } from '@/components/events/ListProperties'; +import { ProfileAvatar } from '@/components/profiles/ProfileAvatar'; +import { Avatar } from '@/components/ui/avatar'; +import { Widget, WidgetBody, WidgetHead } from '@/components/Widget'; +import { + getProfileById, + getProfilesByExternalId, +} from '@/server/services/profile.service'; +import { formatDateTime } from '@/utils/date'; +import { getProfileName } from '@/utils/getters'; + +import ListProfileEvents from './list-profile-events'; + +interface PageProps { + params: { + organizationId: string; + projectId: string; + profileId: string; + }; +} + +export default async function Page({ + params: { organizationId, projectId, profileId }, +}: PageProps) { + const profile = await getProfileById(profileId); + const profiles = ( + await getProfilesByExternalId(profile.external_id, profile.project_id) + ).filter((item) => item.id !== profile.id); + return ( + + + {getProfileName(profile)} +
+ } + organizationId={organizationId} + > +
+
+ + + Properties + + + + + + Linked profile + + {profiles.length > 0 ? ( +
+ {profiles.map((profile) => ( +
+ + +
+
+ {getProfileName(profile)} +
+
+ {profile.id} + {formatDateTime(profile.createdAt)} +
+
+
+ +
+ ))} +
+ ) : ( +
No linked profiles
+ )} +
+
+ +
+ + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/list-profiles.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/list-profiles.tsx new file mode 100644 index 00000000..a5bd3f21 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/list-profiles.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useMemo } from 'react'; +import { api } from '@/app/_trpc/client'; +import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; +import { Pagination, usePagination } from '@/components/Pagination'; +import { Input } from '@/components/ui/input'; +import { useQueryState } from 'nuqs'; + +import { ProfileListItem } from './profile-list-item'; + +interface ListProfilesProps { + projectId: string; + organizationId: string; +} +export function ListProfiles({ organizationId, projectId }: ListProfilesProps) { + const [query, setQuery] = useQueryState('q'); + const pagination = usePagination(); + const eventsQuery = api.profile.list.useQuery( + { + projectId, + query, + ...pagination, + }, + { + keepPreviousData: true, + } + ); + const profiles = useMemo(() => eventsQuery.data ?? [], [eventsQuery]); + + return ( + <> + + setQuery(event.target.value || null)} + /> + +
+
+ {profiles.map((item) => ( + + ))} +
+
+ +
+
+ + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/page.tsx new file mode 100644 index 00000000..dcb0c41d --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/page.tsx @@ -0,0 +1,19 @@ +import PageLayout from '@/app/(app)/page-layout'; + +import { ListProfiles } from './list-profiles'; + +interface PageProps { + params: { + organizationId: string; + projectId: string; + }; +} +export default function Page({ + params: { organizationId, projectId }, +}: PageProps) { + return ( + + + + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list-item.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list-item.tsx new file mode 100644 index 00000000..374ea9b1 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/profiles/profile-list-item.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useMemo } from 'react'; +import type { RouterOutputs } from '@/app/_trpc/client'; +import { ListProperties } from '@/components/events/ListProperties'; +import { ExpandableListItem } from '@/components/general/ExpandableListItem'; +import { ProfileAvatar } from '@/components/profiles/ProfileAvatar'; +import { useAppParams } from '@/hooks/useAppParams'; +import { formatDateTime } from '@/utils/date'; +import { getProfileName } from '@/utils/getters'; +import Link from 'next/link'; + +type ProfileListItemProps = RouterOutputs['profile']['list'][number]; + +export function ProfileListItem(props: ProfileListItemProps) { + const { id, properties, createdAt } = props; + const params = useAppParams(); + + const bullets = useMemo(() => { + const bullets: React.ReactNode[] = [ + {formatDateTime(createdAt)}, + + See profile + , + ]; + + return bullets; + }, [createdAt, id, params]); + + return ( + } + > + + + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/[reportId]/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/[reportId]/page.tsx new file mode 100644 index 00000000..e2674c05 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/[reportId]/page.tsx @@ -0,0 +1,32 @@ +import PageLayout from '@/app/(app)/page-layout'; +import { getReportById } from '@/server/services/reports.service'; +import { Pencil } from 'lucide-react'; + +import ReportEditor from '../report-editor'; + +interface PageProps { + params: { + organizationId: string; + projectId: string; + reportId: string; + }; +} + +export default async function Page({ + params: { organizationId, reportId }, +}: PageProps) { + const report = await getReportById(reportId); + return ( + + {report.name} + + + } + organizationId={organizationId} + > + + + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/page.tsx new file mode 100644 index 00000000..2b3308ce --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/page.tsx @@ -0,0 +1,27 @@ +import PageLayout from '@/app/(app)/page-layout'; +import { Pencil } from 'lucide-react'; + +import ReportEditor from './report-editor'; + +interface PageProps { + params: { + organizationId: string; + projectId: string; + }; +} + +export default function Page({ params: { organizationId } }: PageProps) { + return ( + + Unnamed report + + + } + organizationId={organizationId} + > + + + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/report-editor.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/report-editor.tsx new file mode 100644 index 00000000..0687f444 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/reports/report-editor.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { api } from '@/app/_trpc/client'; +import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; +import { Chart } from '@/components/report/chart'; +import { ReportChartType } from '@/components/report/ReportChartType'; +import { ReportInterval } from '@/components/report/ReportInterval'; +import { ReportLineType } from '@/components/report/ReportLineType'; +import { ReportSaveButton } from '@/components/report/ReportSaveButton'; +import { + changeDateRanges, + ready, + reset, + setReport, +} from '@/components/report/reportSlice'; +import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar'; +import { Button } from '@/components/ui/button'; +import { Combobox } from '@/components/ui/combobox'; +import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; +import { useDispatch, useSelector } from '@/redux'; +import type { IServiceReport } from '@/server/services/reports.service'; +import { timeRanges } from '@/utils/constants'; +import { GanttChartSquareIcon } from 'lucide-react'; + +interface ReportEditorProps { + report: IServiceReport | null; +} + +export default function ReportEditor({ + report: initialReport, +}: ReportEditorProps) { + const dispatch = useDispatch(); + const report = useSelector((state) => state.report); + + // Set report if reportId exists + useEffect(() => { + if (initialReport) { + dispatch(setReport(initialReport)); + } else { + dispatch(ready()); + } + + return () => { + dispatch(reset()); + }; + }, [initialReport, dispatch]); + + return ( + + + +
+ +
+
+
+ { + dispatch(changeDateRanges(value)); + }} + items={Object.values(timeRanges).map((key) => ({ + label: key, + value: key, + }))} + /> + + + +
+
+ +
+
+
+ {report.ready && } +
+ + + +
+ ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/list-projects.tsx b/apps/web/src/app/(app)/[organizationId]/list-projects.tsx new file mode 100644 index 00000000..4817c8ed --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/list-projects.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { Card } from '@/components/Card'; +import { pushModal } from '@/modals'; +import type { getProjectsByOrganizationId } from '@/server/services/project.service'; +import { Plus } from 'lucide-react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; + +interface ListProjectsProps { + projects: Awaited>; +} + +export function ListProjects({ projects }: ListProjectsProps) { + const params = useParams(); + const organizationId = params.organizationId as string; + + return ( + <> +
+ {projects.map((item) => ( + +
+ + {item.name} + +
+
+ ))} + + + +
+ + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/page.tsx b/apps/web/src/app/(app)/[organizationId]/page.tsx new file mode 100644 index 00000000..f20d5435 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/page.tsx @@ -0,0 +1,17 @@ +import { getFirstProjectByOrganizationId } from '@/server/services/project.service'; +import { redirect } from 'next/navigation'; + +interface PageProps { + params: { + organizationId: string; + }; +} + +export default async function Page({ params: { organizationId } }: PageProps) { + const project = await getFirstProjectByOrganizationId(organizationId); + if (project) { + return redirect(`/${organizationId}/${project.id}`); + } + + return

List projects maybe?

; +} diff --git a/apps/web/src/app/(app)/[organizationId]/settings/clients/list-clients.tsx b/apps/web/src/app/(app)/[organizationId]/settings/clients/list-clients.tsx new file mode 100644 index 00000000..fb2d18f5 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/settings/clients/list-clients.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; +import { columns } from '@/components/clients/table'; +import { ContentHeader } from '@/components/Content'; +import { DataTable } from '@/components/DataTable'; +import { Button } from '@/components/ui/button'; +import { pushModal } from '@/modals'; +import type { getClientsByOrganizationId } from '@/server/services/clients.service'; +import { KeySquareIcon, PlusIcon } from 'lucide-react'; +import { useParams } from 'next/navigation'; + +interface ListClientsProps { + clients: Awaited>; +} +export default function ListClients({ clients }: ListClientsProps) { + const organizationId = useParams().organizationId as string; + + return ( + <> + +
+
+ +
+ +
+ +
+ + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/settings/clients/page.tsx b/apps/web/src/app/(app)/[organizationId]/settings/clients/page.tsx new file mode 100644 index 00000000..497f900c --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/settings/clients/page.tsx @@ -0,0 +1,20 @@ +import PageLayout from '@/app/(app)/page-layout'; +import { getClientsByOrganizationId } from '@/server/services/clients.service'; + +import ListClients from './list-clients'; + +interface PageProps { + params: { + organizationId: string; + }; +} + +export default async function Page({ params: { organizationId } }: PageProps) { + const clients = await getClientsByOrganizationId(organizationId); + + return ( + + + + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/settings/organization/edit-organization.tsx b/apps/web/src/app/(app)/[organizationId]/settings/organization/edit-organization.tsx new file mode 100644 index 00000000..c5671767 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/settings/organization/edit-organization.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { api, handleError } from '@/app/_trpc/client'; +import { InputWithLabel } from '@/components/forms/InputWithLabel'; +import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui/use-toast'; +import { Widget, WidgetBody, WidgetHead } from '@/components/Widget'; +import type { getOrganizationById } from '@/server/services/organization.service'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const validator = z.object({ + id: z.string().min(2), + name: z.string().min(2), +}); + +type IForm = z.infer; +interface EditOrganizationProps { + organization: Awaited>; +} +export default function EditOrganization({ + organization, +}: EditOrganizationProps) { + const router = useRouter(); + + const { register, handleSubmit, formState, reset } = useForm({ + defaultValues: organization, + }); + + const mutation = api.organization.update.useMutation({ + onSuccess(res) { + toast({ + title: 'Organization updated', + description: 'Your organization has been updated.', + }); + reset(res); + router.refresh(); + }, + onError: handleError, + }); + + return ( +
{ + mutation.mutate(values); + })} + > + + + Org. details + + + + + + +
+ ); +} + +// + +// diff --git a/apps/web/src/app/(app)/[organizationId]/settings/organization/invited-users.tsx b/apps/web/src/app/(app)/[organizationId]/settings/organization/invited-users.tsx new file mode 100644 index 00000000..cc579f9d --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/settings/organization/invited-users.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { api, handleError } from '@/app/_trpc/client'; +import { InputWithLabel } from '@/components/forms/InputWithLabel'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { toast } from '@/components/ui/use-toast'; +import { Widget, WidgetBody, WidgetHead } from '@/components/Widget'; +import type { IServiceInvite } from '@/server/services/user.service'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const validator = z.object({ + email: z.string().email(), +}); + +type IForm = z.infer; + +interface InvitedUsersProps { + invites: IServiceInvite[]; + organizationId: string; +} +export default function InvitedUsers({ + invites, + organizationId, +}: InvitedUsersProps) { + const router = useRouter(); + + const { register, handleSubmit, formState, reset } = useForm({ + resolver: zodResolver(validator), + defaultValues: { + email: '', + }, + }); + + const mutation = api.user.invite.useMutation({ + onSuccess() { + toast({ + title: 'User invited', + description: "We have sent an invitation to the user's email", + }); + reset(); + router.refresh(); + }, + onError: handleError, + }); + + return ( +
{ + mutation.mutate({ + ...values, + organizationId, + }); + })} + > + + + Invites + + + + + +
Invited users
+ + + + Email + Accepted + + + + {invites.map((item) => { + return ( + + {item.email} + {item.accepted ? 'Yes' : 'No'} + + ); + })} + + {invites.length === 0 && ( + + + No invites + + + )} + +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/settings/organization/page.tsx b/apps/web/src/app/(app)/[organizationId]/settings/organization/page.tsx new file mode 100644 index 00000000..f70e44a6 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/settings/organization/page.tsx @@ -0,0 +1,26 @@ +import PageLayout from '@/app/(app)/page-layout'; +import { getOrganizationById } from '@/server/services/organization.service'; +import { getInvitesByOrganizationId } from '@/server/services/user.service'; + +import EditOrganization from './edit-organization'; +import InvitedUsers from './invited-users'; + +interface PageProps { + params: { + organizationId: string; + }; +} + +export default async function Page({ params: { organizationId } }: PageProps) { + const organization = await getOrganizationById(organizationId); + const invites = await getInvitesByOrganizationId(organizationId); + + return ( + +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/settings/page.tsx b/apps/web/src/app/(app)/[organizationId]/settings/page.tsx new file mode 100644 index 00000000..22b9c8b1 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/settings/page.tsx @@ -0,0 +1 @@ +export { default } from './organization/page'; diff --git a/apps/web/src/app/(app)/[organizationId]/settings/profile/edit-profile.tsx b/apps/web/src/app/(app)/[organizationId]/settings/profile/edit-profile.tsx new file mode 100644 index 00000000..600f1791 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/settings/profile/edit-profile.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { api, handleError } from '@/app/_trpc/client'; +import { ContentHeader, ContentSection } from '@/components/Content'; +import { InputError } from '@/components/forms/InputError'; +import { InputWithLabel } from '@/components/forms/InputWithLabel'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { toast } from '@/components/ui/use-toast'; +import { Widget, WidgetBody, WidgetHead } from '@/components/Widget'; +import type { getOrganizationById } from '@/server/services/organization.service'; +import type { getProfileById } from '@/server/services/profile.service'; +import type { getUserById } from '@/server/services/user.service'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const validator = z.object({ + name: z.string().min(2), + email: z.string().email(), +}); + +type IForm = z.infer; +interface EditProfileProps { + profile: Awaited>; +} +export default function EditProfile({ profile }: EditProfileProps) { + const router = useRouter(); + + const { register, handleSubmit, reset, formState } = useForm({ + resolver: zodResolver(validator), + defaultValues: { + name: profile.name ?? '', + email: profile.email ?? '', + }, + }); + + const mutation = api.user.update.useMutation({ + onSuccess(res) { + toast({ + title: 'Profile updated', + description: 'Your profile has been updated.', + }); + reset(res); + router.refresh(); + }, + onError: handleError, + }); + + return ( +
{ + mutation.mutate(values); + })} + > + + + Your profile + + + + + + + +
+ ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/settings/profile/logout.tsx b/apps/web/src/app/(app)/[organizationId]/settings/profile/logout.tsx new file mode 100644 index 00000000..af14ce10 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/settings/profile/logout.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { Widget, WidgetBody, WidgetHead } from '@/components/Widget'; +import { signOut } from 'next-auth/react'; + +export function Logout() { + return ( + + + Sad part + + +

+ Sometime's you need to go. See you next time +

+ +
+
+ ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/settings/profile/page.tsx b/apps/web/src/app/(app)/[organizationId]/settings/profile/page.tsx new file mode 100644 index 00000000..7487073c --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/settings/profile/page.tsx @@ -0,0 +1,26 @@ +import PageLayout from '@/app/(app)/page-layout'; +import { getSession } from '@/server/auth'; +import { getUserById } from '@/server/services/user.service'; + +import EditProfile from './edit-profile'; +import { Logout } from './logout'; + +interface PageProps { + params: { + organizationId: string; + }; +} + +export default async function Page({ params: { organizationId } }: PageProps) { + const session = await getSession(); + const profile = await getUserById(session?.user.id!); + + return ( + +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/settings/projects/list-projects.tsx b/apps/web/src/app/(app)/[organizationId]/settings/projects/list-projects.tsx new file mode 100644 index 00000000..78b7515d --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/settings/projects/list-projects.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { StickyBelowHeader } from '@/app/(app)/layout-sticky-below-header'; +import { DataTable } from '@/components/DataTable'; +import { columns } from '@/components/projects/table'; +import { Button } from '@/components/ui/button'; +import { pushModal } from '@/modals'; +import type { getProjectsByOrganizationId } from '@/server/services/project.service'; +import { PlusIcon, WarehouseIcon } from 'lucide-react'; +import { useParams } from 'next/navigation'; + +interface ListProjectsProps { + projects: Awaited>; +} +export default function ListProjects({ projects }: ListProjectsProps) { + const organizationId = useParams().organizationId as string; + return ( + <> + +
+
+ +
+ +
+ +
+ + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/settings/projects/page.tsx b/apps/web/src/app/(app)/[organizationId]/settings/projects/page.tsx new file mode 100644 index 00000000..b80e357f --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/settings/projects/page.tsx @@ -0,0 +1,20 @@ +import PageLayout from '@/app/(app)/page-layout'; +import { getProjectsByOrganizationId } from '@/server/services/project.service'; + +import ListProjects from './list-projects'; + +interface PageProps { + params: { + organizationId: string; + }; +} + +export default async function Page({ params: { organizationId } }: PageProps) { + const projects = await getProjectsByOrganizationId(organizationId); + + return ( + + + + ); +} diff --git a/apps/web/src/app/(app)/layout-menu.tsx b/apps/web/src/app/(app)/layout-menu.tsx new file mode 100644 index 00000000..4d6eeb8c --- /dev/null +++ b/apps/web/src/app/(app)/layout-menu.tsx @@ -0,0 +1,122 @@ +'use client'; + +import type { IServiceRecentDashboards } from '@/server/services/dashboard.service'; +import { + BuildingIcon, + CogIcon, + GanttChartIcon, + KeySquareIcon, + LayoutPanelTopIcon, + UserIcon, + UsersIcon, + WarehouseIcon, +} from 'lucide-react'; +import type { LucideProps } from 'lucide-react'; +import Link from 'next/link'; +import { useParams, usePathname } from 'next/navigation'; + +function LinkWithIcon({ + href, + icon: Icon, + label, +}: { + href: string; + icon: React.ElementType; + label: React.ReactNode; +}) { + return ( + + + {label} + + ); +} + +interface LayoutMenuProps { + recentDashboards: IServiceRecentDashboards; + fallbackProjectId: string | null; +} +export default function LayoutMenu({ + recentDashboards, + fallbackProjectId, +}: LayoutMenuProps) { + const pathname = usePathname(); + const params = useParams(); + const projectId = ( + !params.projectId || params.projectId === 'undefined' + ? fallbackProjectId + : params.projectId + ) as string | null; + + return ( + <> + + + + + {pathname.includes('/settings/') && ( +
+ + + + +
+ )} + {recentDashboards.length > 0 && ( +
+
Recent dashboards
+ {recentDashboards.map((item) => ( + + {item.dashboard.name} + + {item.project.name} + +
+ } + href={`/${item.organization_id}/${item.project_id}/${item.dashboard_id}`} + /> + ))} +
+ )} + + ); +} diff --git a/apps/web/src/app/(app)/layout-organization-selector.tsx b/apps/web/src/app/(app)/layout-organization-selector.tsx new file mode 100644 index 00000000..012803d2 --- /dev/null +++ b/apps/web/src/app/(app)/layout-organization-selector.tsx @@ -0,0 +1,30 @@ +'use client'; + +import type { IServiceOrganization } from '@/server/services/organization.service'; +import { Building } from 'lucide-react'; +import { useParams } from 'next/navigation'; + +interface LayoutOrganizationSelectorProps { + organizations: IServiceOrganization[]; +} + +export default function LayoutOrganizationSelector({ + organizations, +}: LayoutOrganizationSelectorProps) { + const params = useParams(); + + const organization = organizations.find( + (item) => item.id === params.organizationId + ); + + if (!organization) { + return null; + } + + return ( +
+ + {organization.name} +
+ ); +} diff --git a/apps/web/src/app/(app)/layout-project-selector.tsx b/apps/web/src/app/(app)/layout-project-selector.tsx new file mode 100644 index 00000000..5b83d3c8 --- /dev/null +++ b/apps/web/src/app/(app)/layout-project-selector.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { Combobox } from '@/components/ui/combobox'; +import type { getProjectsByOrganizationId } from '@/server/services/project.service'; +import { useParams, usePathname, useRouter } from 'next/navigation'; + +interface LayoutProjectSelectorProps { + projects: Awaited>; + organizationId: string | null; +} +export default function LayoutProjectSelector({ + projects, + organizationId, +}: LayoutProjectSelectorProps) { + const router = useRouter(); + const params = useParams<{ projectId: string }>(); + const projectId = params?.projectId ? params.projectId : null; + const pathname = usePathname() || ''; + + return ( +
+ { + // If we are on a page with only organizationId and projectId (as params) + // we know its safe to just replace the current projectId + // since the rest of the url is to a static page + // e.g. /[organizationId]/[projectId]/events + if (params && projectId && Object.keys(params).length === 2) { + router.push(pathname.replace(projectId, value)); + } else { + router.push(`/${organizationId}/${value}`); + } + }} + value={projectId} + items={ + projects.map((item) => ({ + label: item.name, + value: item.id, + })) ?? [] + } + /> +
+ ); +} diff --git a/apps/web/src/app/(app)/layout-sidebar.tsx b/apps/web/src/app/(app)/layout-sidebar.tsx new file mode 100644 index 00000000..2bc74c3a --- /dev/null +++ b/apps/web/src/app/(app)/layout-sidebar.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Logo } from '@/components/Logo'; +import type { IServiceRecentDashboards } from '@/server/services/dashboard.service'; +import type { IServiceOrganization } from '@/server/services/organization.service'; +import { cn } from '@/utils/cn'; +import { Rotate as Hamburger } from 'hamburger-react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +import LayoutMenu from './layout-menu'; +import LayoutOrganizationSelector from './layout-organization-selector'; + +interface LayoutSidebarProps { + recentDashboards: IServiceRecentDashboards; + organizations: IServiceOrganization[]; +} +export function LayoutSidebar({ + organizations, + recentDashboards, +}: LayoutSidebarProps) { + const [active, setActive] = useState(false); + const fallbackProjectId = recentDashboards[0]?.project_id ?? null; + const pathname = usePathname(); + useEffect(() => { + setActive(false); + }, [pathname]); + return ( + <> + + + No account?{' '} + Sign up here! + + + + +

Terms & conditions

+
+ ); +} diff --git a/apps/web/src/app/cookie-provider.tsx b/apps/web/src/app/cookie-provider.tsx new file mode 100644 index 00000000..06133d04 --- /dev/null +++ b/apps/web/src/app/cookie-provider.tsx @@ -0,0 +1,24 @@ +import { createContext, useContext } from 'react'; +import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies'; + +type ICookies = Record; + +const context = createContext({}); + +export const CookieProvider = ({ + value, + children, +}: { + children: React.ReactNode; + value: RequestCookie[]; +}) => { + const cookies = value.reduce((acc, cookie) => { + return { + ...acc, + [cookie.name]: cookie.value, + }; + }, {} as ICookies); + return {children}; +}; + +export const useCookies = (): ICookies => useContext(context); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx new file mode 100644 index 00000000..f0ef12ed --- /dev/null +++ b/apps/web/src/app/layout.tsx @@ -0,0 +1,52 @@ +import { cn } from '@/utils/cn'; +import { Space_Grotesk } from 'next/font/google'; + +import Providers from './providers'; + +import '@/styles/globals.css'; + +import { getSession } from '@/server/auth'; +import { cookies } from 'next/headers'; +import { redirect } from 'next/navigation'; + +import Auth from './auth'; + +// import { cookies } from 'next/headers'; + +const font = Space_Grotesk({ + subsets: ['latin'], + display: 'swap', + variable: '--text', +}); + +export const metadata = {}; + +export const viewport = { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: 1, +}; + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getSession(); + + return ( + + + + {session ? children : } + + + + ); +} diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx new file mode 100644 index 00000000..b367014e --- /dev/null +++ b/apps/web/src/app/providers.tsx @@ -0,0 +1,72 @@ +'use client'; + +import React, { useRef, useState } from 'react'; +import { api } from '@/app/_trpc/client'; +import { Toaster } from '@/components/ui/toaster'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import { ModalProvider } from '@/modals'; +import type { AppStore } from '@/redux'; +import makeStore from '@/redux'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { httpBatchLink } from '@trpc/client'; +import type { Session } from 'next-auth'; +import { SessionProvider } from 'next-auth/react'; +import type { RequestCookie } from 'next/dist/compiled/@edge-runtime/cookies'; +import { Provider as ReduxProvider } from 'react-redux'; +import superjson from 'superjson'; + +import { CookieProvider } from './cookie-provider'; + +export default function Providers({ + children, + session, + cookies, +}: { + children: React.ReactNode; + session: Session | null; + cookies: RequestCookie[]; +}) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + refetchOnMount: true, + refetchOnWindowFocus: false, + }, + }, + }) + ); + const [trpcClient] = useState(() => + api.createClient({ + transformer: superjson, + links: [ + httpBatchLink({ + url: 'http://localhost:3000/api/trpc', + }), + ], + }) + ); + + const storeRef = useRef(); + if (!storeRef.current) { + // Create the store instance the first time this renders + storeRef.current = makeStore(); + } + + return ( + + + + + + {children} + + + + + + + + ); +} diff --git a/apps/web/src/components/Card.tsx b/apps/web/src/components/Card.tsx index f4ce4090..281856a1 100644 --- a/apps/web/src/components/Card.tsx +++ b/apps/web/src/components/Card.tsx @@ -1,3 +1,5 @@ +'use client'; + import { DropdownMenu, DropdownMenuContent, @@ -17,7 +19,7 @@ export function Card({ children, hover, className }: CardProps) { return (
+ + openpanel +
+ ); +} diff --git a/apps/web/src/components/Pagination.tsx b/apps/web/src/components/Pagination.tsx index a2fc8efe..2193e95b 100644 --- a/apps/web/src/components/Pagination.tsx +++ b/apps/web/src/components/Pagination.tsx @@ -12,6 +12,7 @@ export function usePagination(take = 100) { take, canPrev: skip > 0, canNext: true, + page: skip / take + 1, }), [skip, setSkip, take] ); @@ -21,7 +22,8 @@ export type PaginationProps = ReturnType; export function Pagination(props: PaginationProps) { return ( -
+
+
Page: {props.page}
+ ); +} + +interface WidgetBodyProps { + children: React.ReactNode; + className?: string; +} +export function WidgetBody({ children, className }: WidgetBodyProps) { + return
{children}
; +} + +interface WidgetProps { + children: React.ReactNode; + className?: string; +} +export function Widget({ children, className }: WidgetProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/src/components/clients/ClientActions.tsx b/apps/web/src/components/clients/ClientActions.tsx index 3198ec01..ba02ff46 100644 --- a/apps/web/src/components/clients/ClientActions.tsx +++ b/apps/web/src/components/clients/ClientActions.tsx @@ -1,9 +1,11 @@ -import { useRefetchActive } from '@/hooks/useRefetchActive'; +'use client'; + +import { api } from '@/app/_trpc/client'; import { pushModal, showConfirm } from '@/modals'; import type { IClientWithProject } from '@/types'; -import { api } from '@/utils/api'; import { clipboard } from '@/utils/clipboard'; import { MoreHorizontal } from 'lucide-react'; +import { useRouter } from 'next/navigation'; import { Button } from '../ui/button'; import { @@ -16,15 +18,16 @@ import { } from '../ui/dropdown-menu'; import { toast } from '../ui/use-toast'; -export function ClientActions({ id }: IClientWithProject) { - const refetch = useRefetchActive(); +export function ClientActions(client: IClientWithProject) { + const { id } = client; + const router = useRouter(); const deletion = api.client.remove.useMutation({ onSuccess() { toast({ title: 'Success', description: 'Client revoked, incoming requests will be rejected.', }); - refetch(); + router.refresh(); }, }); return ( @@ -42,7 +45,7 @@ export function ClientActions({ id }: IClientWithProject) { { - pushModal('EditClient', { id }); + pushModal('EditClient', client); }} > Edit diff --git a/apps/web/src/components/events/EventsTable.tsx b/apps/web/src/components/events/EventsTable.tsx deleted file mode 100644 index c9b92209..00000000 --- a/apps/web/src/components/events/EventsTable.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useMemo } from 'react'; -import { DataTable } from '@/components/DataTable'; -import { Avatar, AvatarFallback } from '@/components/ui/avatar'; -import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table'; -import { useOrganizationParams } from '@/hooks/useOrganizationParams'; -import type { RouterOutputs } from '@/utils/api'; -import { formatDateTime } from '@/utils/date'; -import { toDots } from '@/utils/object'; -import { AvatarImage } from '@radix-ui/react-avatar'; -import { createColumnHelper } from '@tanstack/react-table'; -import Link from 'next/link'; - -const columnHelper = - createColumnHelper(); - -interface EventsTableProps { - data: RouterOutputs['event']['list']; -} - -export function EventsTable({ data }: EventsTableProps) { - const params = useOrganizationParams(); - const columns = useMemo(() => { - return [ - columnHelper.accessor((row) => row.createdAt, { - id: 'createdAt', - header: () => 'Created At', - cell(info) { - return formatDateTime(info.getValue()); - }, - footer: () => 'Created At', - }), - columnHelper.accessor((row) => row.name, { - id: 'event', - header: () => 'Event', - cell(info) { - return {info.getValue()}; - }, - footer: () => 'Created At', - }), - columnHelper.accessor((row) => row.profile, { - id: 'profile', - header: () => 'Profile', - cell(info) { - const profile = info.getValue(); - return ( - - - {profile?.avatar && } - - {profile?.first_name?.at(0)} - - - {`${profile?.first_name} ${profile?.last_name ?? ''}`} - - ); - }, - footer: () => 'Created At', - }), - columnHelper.accessor((row) => row.properties, { - id: 'properties', - header: () => 'Properties', - cell(info) { - const dots = toDots(info.getValue() as Record); - return ( - - - {Object.keys(dots).map((key) => { - return ( - - {key} - - {typeof dots[key] === 'boolean' - ? dots[key] - ? 'true' - : 'false' - : dots[key]} - - - ); - })} - -
- ); - }, - footer: () => 'Created At', - }), - ]; - }, [params]); - - return ; -} diff --git a/apps/web/src/components/events/ListProperties.tsx b/apps/web/src/components/events/ListProperties.tsx new file mode 100644 index 00000000..d5020ba5 --- /dev/null +++ b/apps/web/src/components/events/ListProperties.tsx @@ -0,0 +1,35 @@ +import { toDots } from '@/utils/object'; + +import { Table, TableBody, TableCell, TableRow } from '../ui/table'; + +interface ListPropertiesProps { + data: any; + className?: string; +} + +export function ListProperties({ + data, + className = 'mini', +}: ListPropertiesProps) { + const dots = toDots(data); + return ( + + + {Object.keys(dots).map((key) => { + return ( + + {key} + + {typeof dots[key] === 'boolean' + ? dots[key] + ? 'true' + : 'false' + : dots[key]} + + + ); + })} + +
+ ); +} diff --git a/apps/web/src/components/forms/InputWithLabel.tsx b/apps/web/src/components/forms/InputWithLabel.tsx index 47df69df..32fbbdb1 100644 --- a/apps/web/src/components/forms/InputWithLabel.tsx +++ b/apps/web/src/components/forms/InputWithLabel.tsx @@ -6,15 +6,23 @@ import { Label } from '../ui/label'; type InputWithLabelProps = InputProps & { label: string; + error?: string | undefined; }; export const InputWithLabel = forwardRef( ({ label, className, ...props }, ref) => { return (
- +
+ + {props.error && ( + + {props.error} + + )} +
); diff --git a/apps/web/src/components/general/ExpandableListItem.tsx b/apps/web/src/components/general/ExpandableListItem.tsx new file mode 100644 index 00000000..5b676540 --- /dev/null +++ b/apps/web/src/components/general/ExpandableListItem.tsx @@ -0,0 +1,54 @@ +import React, { useState } from 'react'; +import { cn } from '@/utils/cn'; +import { ChevronUp } from 'lucide-react'; +import AnimateHeight from 'react-animate-height'; + +import { Button } from '../ui/button'; + +interface ExpandableListItemProps { + children: React.ReactNode; + bullets: React.ReactNode[]; + title: string; + image?: React.ReactNode; + initialOpen?: boolean; +} +export function ExpandableListItem({ + title, + bullets, + image, + initialOpen = false, + children, +}: ExpandableListItemProps) { + const [open, setOpen] = useState(initialOpen ?? false); + return ( +
+
+
{image}
+
+ {title} +
+ {bullets.map((bullet) => ( + {bullet} + ))} +
+
+ +
+ +
{children}
+
+
+ ); +} diff --git a/apps/web/src/components/layouts/MainLayout.tsx b/apps/web/src/components/layouts/MainLayout.tsx deleted file mode 100644 index 49ed82e7..00000000 --- a/apps/web/src/components/layouts/MainLayout.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useState } from 'react'; -import { cn } from '@/utils/cn'; -import { MenuIcon } from 'lucide-react'; -import Link from 'next/link'; - -import { Container } from '../Container'; -import { Breadcrumbs } from '../navbar/Breadcrumbs'; -import { NavbarMenu } from '../navbar/NavbarMenu'; - -interface MainLayoutProps { - children: React.ReactNode; - className?: string; -} - -export function MainLayout({ children, className }: MainLayoutProps) { - const [visible, setVisible] = useState(false); - return ( - <> -
- - -
{children}
- - ); -} diff --git a/apps/web/src/components/layouts/SettingsLayout.tsx b/apps/web/src/components/layouts/SettingsLayout.tsx deleted file mode 100644 index 7c495b6a..00000000 --- a/apps/web/src/components/layouts/SettingsLayout.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useOrganizationParams } from '@/hooks/useOrganizationParams'; -import { cn } from '@/utils/cn'; -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -import { Container } from '../Container'; -import { PageTitle } from '../PageTitle'; -import { Sidebar, WithSidebar } from '../WithSidebar'; -import { MainLayout } from './MainLayout'; - -interface SettingsLayoutProps { - children: React.ReactNode; - className?: string; -} - -export function SettingsLayout({ children, className }: SettingsLayoutProps) { - const params = useOrganizationParams(); - const pathname = usePathname(); - const links = [ - { - href: `/${params.organization}/settings/organization`, - label: 'Organization', - }, - { href: `/${params.organization}/settings/projects`, label: 'Projects' }, - { href: `/${params.organization}/settings/clients`, label: 'Clients' }, - { href: `/${params.organization}/settings/profile`, label: 'Profile' }, - ]; - return ( - - - Settings - - - {links.map(({ href, label }) => ( - - {label} - - ))} - -
{children}
-
-
-
- ); -} diff --git a/apps/web/src/components/navbar/Breadcrumbs.tsx b/apps/web/src/components/navbar/Breadcrumbs.tsx index 88a67fcf..983b1baf 100644 --- a/apps/web/src/components/navbar/Breadcrumbs.tsx +++ b/apps/web/src/components/navbar/Breadcrumbs.tsx @@ -1,5 +1,5 @@ +import { api } from '@/app/_trpc/client'; import { useOrganizationParams } from '@/hooks/useOrganizationParams'; -import { api } from '@/utils/api'; import { ChevronRight, HomeIcon } from 'lucide-react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; @@ -11,30 +11,30 @@ export function Breadcrumbs() { const org = api.organization.get.useQuery( { - slug: params.organization, + id: params.organizationId, }, { - enabled: !!params.organization, + enabled: !!params.organizationId, staleTime: Infinity, } ); const pro = api.project.get.useQuery( { - slug: params.project, + id: params.projectId, }, { - enabled: !!params.project, + enabled: !!params.projectId, staleTime: Infinity, } ); const dashboard = api.dashboard.get.useQuery( { - slug: params.dashboard, + id: params.dashboardId, }, { - enabled: !!params.dashboard, + enabled: !!params.dashboardId, staleTime: Infinity, } ); @@ -48,7 +48,7 @@ export function Breadcrumbs() { {org.data && ( <> - + {org.data.name} @@ -57,7 +57,7 @@ export function Breadcrumbs() { {org.data && pro.data && ( <> - + {pro.data.name} @@ -68,7 +68,7 @@ export function Breadcrumbs() { {dashboard.data.name} diff --git a/apps/web/src/components/navbar/NavbarCreate.tsx b/apps/web/src/components/navbar/NavbarCreate.tsx index ae1ca0c9..50a28d1c 100644 --- a/apps/web/src/components/navbar/NavbarCreate.tsx +++ b/apps/web/src/components/navbar/NavbarCreate.tsx @@ -25,7 +25,7 @@ export function NavbarCreate() { Create a report diff --git a/apps/web/src/components/navbar/NavbarMenu.tsx b/apps/web/src/components/navbar/NavbarMenu.tsx index 08760372..374dad6e 100644 --- a/apps/web/src/components/navbar/NavbarMenu.tsx +++ b/apps/web/src/components/navbar/NavbarMenu.tsx @@ -26,27 +26,27 @@ export function NavbarMenu() { const params = useOrganizationParams(); return (
- {params.project && ( - + {params.projectId && ( + Dashboards )} - {params.project && ( - + {params.projectId && ( + Events )} - {params.project && ( - + {params.projectId && ( + Profiles )} - {params.project && ( + {params.projectId && ( diff --git a/apps/web/src/components/navbar/NavbarUserDropdown.tsx b/apps/web/src/components/navbar/NavbarUserDropdown.tsx index 323ef5df..5ac5eb2c 100644 --- a/apps/web/src/components/navbar/NavbarUserDropdown.tsx +++ b/apps/web/src/components/navbar/NavbarUserDropdown.tsx @@ -28,7 +28,7 @@ export function NavbarUserDropdown() { @@ -36,19 +36,19 @@ export function NavbarUserDropdown() { - + Projects - + Clients - + Profile diff --git a/apps/web/src/components/profiles/ProfileAvatar.tsx b/apps/web/src/components/profiles/ProfileAvatar.tsx new file mode 100644 index 00000000..385d841e --- /dev/null +++ b/apps/web/src/components/profiles/ProfileAvatar.tsx @@ -0,0 +1,53 @@ +'use client'; + +import type { IServiceProfile } from '@/server/services/profile.service'; +import { cn } from '@/utils/cn'; +import { AvatarImage } from '@radix-ui/react-avatar'; +import type { VariantProps } from 'class-variance-authority'; +import { cva } from 'class-variance-authority'; + +import { Avatar, AvatarFallback } from '../ui/avatar'; + +interface ProfileAvatarProps + extends VariantProps, + Partial> { + className?: string; +} + +const variants = cva('', { + variants: { + size: { + default: 'h-12 w-12 rounded-xl [&>span]:rounded-xl', + sm: 'h-6 w-6 rounded [&>span]:rounded', + xs: 'h-4 w-4 rounded [&>span]:rounded', + }, + }, + defaultVariants: { + size: 'default', + }, +}); + +export function ProfileAvatar({ + avatar, + first_name, + className, + size, +}: ProfileAvatarProps) { + return ( + + {avatar && } + + {first_name?.at(0) ?? '🫣'} + + + ); +} diff --git a/apps/web/src/components/projects/ProjectActions.tsx b/apps/web/src/components/projects/ProjectActions.tsx index 99c78ced..1b104d22 100644 --- a/apps/web/src/components/projects/ProjectActions.tsx +++ b/apps/web/src/components/projects/ProjectActions.tsx @@ -1,9 +1,11 @@ -import { useRefetchActive } from '@/hooks/useRefetchActive'; +'use client'; + +import { api } from '@/app/_trpc/client'; import { pushModal, showConfirm } from '@/modals'; import type { IProject } from '@/types'; -import { api } from '@/utils/api'; import { clipboard } from '@/utils/clipboard'; import { MoreHorizontal } from 'lucide-react'; +import { useRouter } from 'next/navigation'; import { Button } from '../ui/button'; import { @@ -16,15 +18,16 @@ import { } from '../ui/dropdown-menu'; import { toast } from '../ui/use-toast'; -export function ProjectActions({ id }: IProject) { - const refetch = useRefetchActive(); +export function ProjectActions(project: IProject) { + const { id } = project; + const router = useRouter(); const deletion = api.project.remove.useMutation({ onSuccess() { toast({ title: 'Success', description: 'Project deleted successfully.', }); - refetch(); + router.refresh(); }, }); @@ -43,7 +46,7 @@ export function ProjectActions({ id }: IProject) { { - pushModal('EditProject', { id }); + pushModal('EditProject', project); }} > Edit diff --git a/apps/web/src/components/projects/table.tsx b/apps/web/src/components/projects/table.tsx index abb4813d..bff79da3 100644 --- a/apps/web/src/components/projects/table.tsx +++ b/apps/web/src/components/projects/table.tsx @@ -1,3 +1,4 @@ +import { IServiceProject } from '@/server/services/project.service'; import { formatDate } from '@/utils/date'; import type { ColumnDef } from '@tanstack/react-table'; @@ -6,7 +7,6 @@ import type { Project as IProject } from '@mixan/db'; import { ProjectActions } from './ProjectActions'; export type Project = IProject; - export const columns: ColumnDef[] = [ { accessorKey: 'name', diff --git a/apps/web/src/components/report/ReportChartType.tsx b/apps/web/src/components/report/ReportChartType.tsx index 2e3a632f..c2c316fb 100644 --- a/apps/web/src/components/report/ReportChartType.tsx +++ b/apps/web/src/components/report/ReportChartType.tsx @@ -1,23 +1,27 @@ import { useDispatch, useSelector } from '@/redux'; -import type { IChartType } from '@/types'; import { chartTypes } from '@/utils/constants'; +import { objectToZodEnums } from '@/utils/validation'; import { Combobox } from '../ui/combobox'; import { changeChartType } from './reportSlice'; -export function ReportChartType() { +interface ReportChartTypeProps { + className?: string; +} +export function ReportChartType({ className }: ReportChartTypeProps) { const dispatch = useDispatch(); const type = useSelector((state) => state.report.chartType); return ( { - dispatch(changeChartType(value as IChartType)); + dispatch(changeChartType(value)); }} value={type} - items={Object.entries(chartTypes).map(([key, value]) => ({ - label: value, + items={objectToZodEnums(chartTypes).map((key) => ({ + label: chartTypes[key], value: key, }))} /> diff --git a/apps/web/src/components/report/ReportInterval.tsx b/apps/web/src/components/report/ReportInterval.tsx index 60eadff4..fbfe8a2a 100644 --- a/apps/web/src/components/report/ReportInterval.tsx +++ b/apps/web/src/components/report/ReportInterval.tsx @@ -1,21 +1,28 @@ import { useDispatch, useSelector } from '@/redux'; import type { IInterval } from '@/types'; -import { isMinuteIntervalEnabledByRange } from '@/utils/constants'; +import { + isHourIntervalEnabledByRange, + isMinuteIntervalEnabledByRange, +} from '@/utils/constants'; import { Combobox } from '../ui/combobox'; import { changeInterval } from './reportSlice'; -export function ReportInterval() { +interface ReportIntervalProps { + className?: string; +} +export function ReportInterval({ className }: ReportIntervalProps) { const dispatch = useDispatch(); const interval = useSelector((state) => state.report.interval); const range = useSelector((state) => state.report.range); const chartType = useSelector((state) => state.report.chartType); - if (chartType !== 'linear') { + if (chartType !== 'linear' && chartType !== 'histogram') { return null; } return ( { dispatch(changeInterval(value as IInterval)); @@ -30,6 +37,7 @@ export function ReportInterval() { { value: 'hour', label: 'Hour', + disabled: !isHourIntervalEnabledByRange(range), }, { value: 'day', diff --git a/apps/web/src/components/report/ReportLineType.tsx b/apps/web/src/components/report/ReportLineType.tsx new file mode 100644 index 00000000..fa74f2be --- /dev/null +++ b/apps/web/src/components/report/ReportLineType.tsx @@ -0,0 +1,34 @@ +import { useDispatch, useSelector } from '@/redux'; +import { lineTypes } from '@/utils/constants'; +import { objectToZodEnums } from '@/utils/validation'; + +import { Combobox } from '../ui/combobox'; +import { changeLineType } from './reportSlice'; + +interface ReportLineTypeProps { + className?: string; +} +export function ReportLineType({ className }: ReportLineTypeProps) { + const dispatch = useDispatch(); + const chartType = useSelector((state) => state.report.chartType); + const type = useSelector((state) => state.report.lineType); + + if (chartType != 'linear' && chartType != 'area') { + return null; + } + + return ( + { + dispatch(changeLineType(value)); + }} + value={type} + items={objectToZodEnums(lineTypes).map((key) => ({ + label: lineTypes[key], + value: key, + }))} + /> + ); +} diff --git a/apps/web/src/components/report/ReportSaveButton.tsx b/apps/web/src/components/report/ReportSaveButton.tsx index 03cdc067..4a9b5b49 100644 --- a/apps/web/src/components/report/ReportSaveButton.tsx +++ b/apps/web/src/components/report/ReportSaveButton.tsx @@ -1,15 +1,20 @@ +'use client'; + +import { api, handleError } from '@/app/_trpc/client'; import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui/use-toast'; import { pushModal } from '@/modals'; import { useDispatch, useSelector } from '@/redux'; -import { api, handleError } from '@/utils/api'; import { SaveIcon } from 'lucide-react'; +import { useParams } from 'next/navigation'; -import { useReportId } from './hooks/useReportId'; import { resetDirty } from './reportSlice'; -export function ReportSaveButton() { - const { reportId } = useReportId(); +interface ReportSaveButtonProps { + className?: string; +} +export function ReportSaveButton({ className }: ReportSaveButtonProps) { + const { reportId } = useParams(); const dispatch = useDispatch(); const update = api.report.update.useMutation({ onSuccess() { @@ -26,11 +31,12 @@ export function ReportSaveButton() { if (reportId) { return ( - - -
-
- -
-
- - -
-
- - - - -
-
-
- -
- - - - - - - ); -} diff --git a/apps/web/src/pages/[organization]/index.tsx b/apps/web/src/pages/[organization]/index.tsx deleted file mode 100644 index b015cc4c..00000000 --- a/apps/web/src/pages/[organization]/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Card } from '@/components/Card'; -import { Container } from '@/components/Container'; -import { MainLayout } from '@/components/layouts/MainLayout'; -import { PageTitle } from '@/components/PageTitle'; -import { useOrganizationParams } from '@/hooks/useOrganizationParams'; -import { createServerSideProps } from '@/server/getServerSideProps'; -import { api } from '@/utils/api'; -import Link from 'next/link'; - -export const getServerSideProps = createServerSideProps(); - -export default function Home() { - const params = useOrganizationParams(); - - const query = api.project.list.useQuery( - { - organizationSlug: params.organization, - }, - { - enabled: !!params.organization, - } - ); - - const projects = query.data ?? []; - - return ( - - - Projects -
- {projects.map((item) => ( - - - {item.name} - - - ))} -
-
-
- ); -} diff --git a/apps/web/src/pages/[organization]/settings/clients.tsx b/apps/web/src/pages/[organization]/settings/clients.tsx deleted file mode 100644 index b62f621e..00000000 --- a/apps/web/src/pages/[organization]/settings/clients.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { columns } from '@/components/clients/table'; -import { ContentHeader } from '@/components/Content'; -import { DataTable } from '@/components/DataTable'; -import { SettingsLayout } from '@/components/layouts/SettingsLayout'; -import { Button } from '@/components/ui/button'; -import { useOrganizationParams } from '@/hooks/useOrganizationParams'; -import { pushModal } from '@/modals'; -import { createServerSideProps } from '@/server/getServerSideProps'; -import { api } from '@/utils/api'; - -export const getServerSideProps = createServerSideProps(); - -export default function Clients() { - const params = useOrganizationParams(); - const query = api.client.list.useQuery({ - organizationSlug: params.organization, - }); - const data = query.data ?? []; - return ( - - - - - - - ); -} diff --git a/apps/web/src/pages/[organization]/settings/organization.tsx b/apps/web/src/pages/[organization]/settings/organization.tsx deleted file mode 100644 index c0963cfe..00000000 --- a/apps/web/src/pages/[organization]/settings/organization.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useEffect } from 'react'; -import { ContentHeader, ContentSection } from '@/components/Content'; -import { InputError } from '@/components/forms/InputError'; -import { SettingsLayout } from '@/components/layouts/SettingsLayout'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { toast } from '@/components/ui/use-toast'; -import { useOrganizationParams } from '@/hooks/useOrganizationParams'; -import { createServerSideProps } from '@/server/getServerSideProps'; -import { api, handleError } from '@/utils/api'; -import { useRouter } from 'next/router'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -export const getServerSideProps = createServerSideProps(); - -const validator = z.object({ - id: z.string().min(2), - name: z.string().min(2), -}); - -type IForm = z.infer; - -export default function Organization() { - const router = useRouter(); - const params = useOrganizationParams(); - const query = api.organization.get.useQuery({ - slug: params.organization, - }); - const mutation = api.organization.update.useMutation({ - onSuccess(res) { - toast({ - title: 'Organization updated', - description: 'Your organization has been updated.', - }); - query.refetch(); - router.replace(`/${res.slug}/settings/organization`); - }, - onError: handleError, - }); - const data = query.data; - - const { register, handleSubmit, reset, formState } = useForm({ - defaultValues: { - id: '', - name: '', - }, - }); - - useEffect(() => { - if (data) { - reset(data); - } - }, [data, reset]); - - return ( - - { - mutation.mutate(values); - })} - className="flex flex-col divide-y divide-border" - > - - - - , - ]} - > - - - - - - - - ); -} diff --git a/apps/web/src/pages/[organization]/settings/profile.tsx b/apps/web/src/pages/[organization]/settings/profile.tsx deleted file mode 100644 index 854499b5..00000000 --- a/apps/web/src/pages/[organization]/settings/profile.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useEffect } from 'react'; -import { ContentHeader, ContentSection } from '@/components/Content'; -import { InputError } from '@/components/forms/InputError'; -import { SettingsLayout } from '@/components/layouts/SettingsLayout'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { toast } from '@/components/ui/use-toast'; -import { ChangePassword } from '@/components/user/ChangePassword'; -import { createServerSideProps } from '@/server/getServerSideProps'; -import { api, handleError } from '@/utils/api'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -export const getServerSideProps = createServerSideProps(); - -const validator = z.object({ - name: z.string().min(2), - email: z.string().email(), -}); - -type IForm = z.infer; - -export default function Profile() { - const query = api.user.current.useQuery(); - const mutation = api.user.update.useMutation({ - onSuccess() { - toast({ - title: 'Profile updated', - description: 'Your profile has been updated.', - }); - query.refetch(); - }, - onError: handleError, - }); - const data = query.data; - - const { register, handleSubmit, reset, formState } = useForm({ - resolver: zodResolver(validator), - defaultValues: { - name: '', - email: '', - }, - }); - - useEffect(() => { - if (data) { - reset(data); - } - }, [data, reset]); - - return ( - -
mutation.mutate(values))} - className="flex flex-col divide-y divide-border" - > - - - - , - ]} - > - - - , - ]} - > - - -
- -
- -
-
- ); -} diff --git a/apps/web/src/pages/[organization]/settings/projects.tsx b/apps/web/src/pages/[organization]/settings/projects.tsx deleted file mode 100644 index 75abde0a..00000000 --- a/apps/web/src/pages/[organization]/settings/projects.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { ContentHeader } from '@/components/Content'; -import { DataTable } from '@/components/DataTable'; -import { SettingsLayout } from '@/components/layouts/SettingsLayout'; -import { columns } from '@/components/projects/table'; -import { Button } from '@/components/ui/button'; -import { useOrganizationParams } from '@/hooks/useOrganizationParams'; -import { pushModal } from '@/modals'; -import { createServerSideProps } from '@/server/getServerSideProps'; -import { api } from '@/utils/api'; - -export const getServerSideProps = createServerSideProps(); - -export default function Projects() { - const params = useOrganizationParams(); - const query = api.project.list.useQuery({ - organizationSlug: params.organization, - }); - const data = query.data ?? []; - return ( - - - - - - - ); -} diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx deleted file mode 100644 index 62741a5b..00000000 --- a/apps/web/src/pages/_app.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Suspense } from 'react'; -import { Toaster } from '@/components/ui/toaster'; -import store from '@/redux'; -import { api } from '@/utils/api'; -import type { Session } from 'next-auth'; -import { SessionProvider } from 'next-auth/react'; -import type { AppType } from 'next/app'; -import { Space_Grotesk } from 'next/font/google'; -import { Provider as ReduxProvider } from 'react-redux'; - -import '@/styles/globals.css'; - -import { TooltipProvider } from '@/components/ui/tooltip'; -import { ModalProvider } from '@/modals'; - -const font = Space_Grotesk({ - subsets: ['latin'], - display: 'swap', - variable: '--text', -}); - -const MixanApp: AppType<{ session: Session | null }> = ({ - Component, - pageProps: { session, ...pageProps }, -}) => { - return ( -
- - - - - - - - - - - -
- ); -}; - -export default api.withTRPC(MixanApp); diff --git a/apps/web/src/pages/api/auth/[...nextauth].ts b/apps/web/src/pages/api/auth/[...nextauth].ts deleted file mode 100644 index 1e8a60d5..00000000 --- a/apps/web/src/pages/api/auth/[...nextauth].ts +++ /dev/null @@ -1,4 +0,0 @@ -import { authOptions } from '@/server/auth'; -import NextAuth from 'next-auth'; - -export default NextAuth(authOptions); diff --git a/apps/web/src/pages/api/trpc/[trpc].ts b/apps/web/src/pages/api/trpc/[trpc].ts deleted file mode 100644 index f8dac78a..00000000 --- a/apps/web/src/pages/api/trpc/[trpc].ts +++ /dev/null @@ -1,24 +0,0 @@ -import { env } from '@/env.mjs'; -import { appRouter } from '@/server/api/root'; -import { createTRPCContext } from '@/server/api/trpc'; -import { createNextApiHandler } from '@trpc/server/adapters/next'; - -export const config = { - api: { - responseLimit: false, - }, -}; - -// export API handler -export default createNextApiHandler({ - router: appRouter, - createContext: createTRPCContext, - onError: - env.NODE_ENV === 'development' - ? ({ path, error }) => { - console.error( - `❌ tRPC failed on ${path ?? ''}: ${error.message}` - ); - } - : undefined, -}); diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx deleted file mode 100644 index 3b6bc812..00000000 --- a/apps/web/src/pages/index.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { MainLayout } from '@/components/layouts/MainLayout'; -import { createServerSideProps } from '@/server/getServerSideProps'; - -export const getServerSideProps = createServerSideProps(); - -export default function Home() { - return ( - -
- - ); -} diff --git a/apps/web/src/redux/index.ts b/apps/web/src/redux/index.ts index e20c2610..47aff75d 100644 --- a/apps/web/src/redux/index.ts +++ b/apps/web/src/redux/index.ts @@ -6,16 +6,19 @@ import { } from 'react-redux'; import type { TypedUseSelectorHook } from 'react-redux'; -const store = configureStore({ - reducer: { - report: reportSlice, - }, -}); +const makeStore = () => + configureStore({ + reducer: { + report: reportSlice, + }, + }); -export type RootState = ReturnType; +export type AppStore = ReturnType; -export type AppDispatch = typeof store.dispatch; +export type RootState = ReturnType; + +export type AppDispatch = AppStore['dispatch']; export const useDispatch: () => AppDispatch = useBaseDispatch; export const useSelector: TypedUseSelectorHook = useBaseSelector; -export default store; +export default makeStore; diff --git a/apps/web/src/server/api/routers/chart.ts b/apps/web/src/server/api/routers/chart.ts index e30643a7..5e78218b 100644 --- a/apps/web/src/server/api/routers/chart.ts +++ b/apps/web/src/server/api/routers/chart.ts @@ -4,29 +4,28 @@ import { getChartSql } from '@/server/chart-sql/getChartSql'; import { isJsonPath, selectJsonPath } from '@/server/chart-sql/helpers'; import { db } from '@/server/db'; import { getUniqueEvents } from '@/server/services/event.service'; -import { getProjectBySlug } from '@/server/services/project.service'; import type { IChartEvent, IChartRange, IGetChartDataInput, IInterval, } from '@/types'; +import { alphabetIds } from '@/utils/constants'; import { getDaysOldDate } from '@/utils/date'; -import { average, isFloat, round, sum } from '@/utils/math'; +import { average, round, sum } from '@/utils/math'; import { toDots } from '@/utils/object'; import { zChartInputWithDates } from '@/utils/validation'; -import { last, pipe, sort, uniq } from 'ramda'; +import { pipe, sort, uniq } from 'ramda'; import { z } from 'zod'; export const chartRouter = createTRPCRouter({ events: protectedProcedure - .input(z.object({ projectSlug: z.string() })) - .query(async ({ input: { projectSlug } }) => { - const project = await getProjectBySlug(projectSlug); + .input(z.object({ projectId: z.string() })) + .query(async ({ input: { projectId } }) => { const events = await cache.getOr( - `events_${project.id}`, + `events_${projectId}`, 1000 * 60 * 60 * 24, - () => getUniqueEvents({ projectId: project.id }) + () => getUniqueEvents({ projectId: projectId }) ); return [ @@ -38,17 +37,16 @@ export const chartRouter = createTRPCRouter({ }), properties: protectedProcedure - .input(z.object({ event: z.string().optional(), projectSlug: z.string() })) - .query(async ({ input: { projectSlug, event } }) => { - const project = await getProjectBySlug(projectSlug); + .input(z.object({ event: z.string().optional(), projectId: z.string() })) + .query(async ({ input: { projectId, event } }) => { const events = await cache.getOr( - `events_${project.id}_${event ?? 'all'}`, + `events_${projectId}_${event ?? 'all'}`, 1000 * 60 * 60, () => db.event.findMany({ take: 500, where: { - project_id: project.id, + project_id: projectId, ...(event ? { name: event, @@ -78,19 +76,16 @@ export const chartRouter = createTRPCRouter({ z.object({ event: z.string(), property: z.string(), - projectSlug: z.string(), + projectId: z.string(), }) ) - .query(async ({ input: { event, property, projectSlug } }) => { + .query(async ({ input: { event, property, projectId } }) => { const intervalInDays = 180; - const project = await getProjectBySlug(projectSlug); if (isJsonPath(property)) { const events = await db.$queryRawUnsafe<{ value: string }[]>( `SELECT ${selectJsonPath( property - )} AS value from events WHERE project_id = '${ - project.id - }' AND name = '${event}' AND "createdAt" >= NOW() - INTERVAL '${intervalInDays} days'` + )} AS value from events WHERE project_id = '${projectId}' AND name = '${event}' AND "createdAt" >= NOW() - INTERVAL '${intervalInDays} days'` ); return { @@ -99,7 +94,7 @@ export const chartRouter = createTRPCRouter({ } else { const events = await db.event.findMany({ where: { - project_id: project.id, + project_id: projectId, name: event, [property]: { not: null, @@ -123,8 +118,8 @@ export const chartRouter = createTRPCRouter({ }), chart: protectedProcedure - .input(zChartInputWithDates.merge(z.object({ projectSlug: z.string() }))) - .query(async ({ input: { projectSlug, events, ...input } }) => { + .input(zChartInputWithDates.merge(z.object({ projectId: z.string() }))) + .query(async ({ input: { projectId, events, ...input } }) => { const { startDate, endDate } = input.startDate && input.endDate ? { @@ -132,18 +127,16 @@ export const chartRouter = createTRPCRouter({ endDate: input.endDate, } : getDatesFromRange(input.range); - const project = await getProjectBySlug(projectSlug); const series: Awaited> = []; for (const event of events) { - series.push( - ...(await getChartData({ - ...input, - startDate, - endDate, - event, - projectId: project.id, - })) - ); + const result = await getChartData({ + ...input, + startDate, + endDate, + event, + projectId: projectId, + }); + series.push(...result); } const sorted = [...series].sort((a, b) => { @@ -152,13 +145,18 @@ export const chartRouter = createTRPCRouter({ const sumB = b.data.reduce((acc, item) => acc + item.count, 0); return sumB - sumA; } else { - return b.metrics.total - a.metrics.total; + return b.metrics.sum - a.metrics.sum; } }); - const meta = { - highest: sorted[0]?.metrics.total ?? 0, - lowest: last(sorted)?.metrics.total ?? 0, + const metrics = { + max: Math.max(...sorted.map((item) => item.metrics.max)), + min: Math.min(...sorted.map((item) => item.metrics.min)), + sum: sum(sorted.map((item) => item.metrics.sum, 0)), + averge: round( + average(sorted.map((item) => item.metrics.average, 0)), + 2 + ), }; return { @@ -166,9 +164,9 @@ export const chartRouter = createTRPCRouter({ series.reduce( (acc, item) => { if (acc[item.event.id]) { - acc[item.event.id] += item.metrics.total; + acc[item.event.id] += item.metrics.sum; } else { - acc[item.event.id] = item.metrics.total; + acc[item.event.id] = item.metrics.sum; } return acc; }, @@ -180,8 +178,12 @@ export const chartRouter = createTRPCRouter({ })), series: sorted.map((item) => ({ ...item, - meta, + metrics: { + ...item.metrics, + totalMetrics: metrics, + }, })), + metrics, }; }), }); @@ -282,36 +284,47 @@ async function getChartData(payload: IGetChartDataInput) { ); return Object.keys(series).map((key) => { - const legend = payload.breakdowns.length - ? key - : getEventLegend(payload.event); - const data = series[key] ?? []; + // If we have breakdowns, we want to use the breakdown key as the legend + // But only if it successfully broke it down, otherwise we use the getEventLabel + const legend = + payload.breakdowns.length && !alphabetIds.includes(key as 'A') + ? key + : getEventLegend(payload.event); + const data = + payload.chartType === 'area' || + payload.chartType === 'linear' || + payload.chartType === 'histogram' || + payload.chartType === 'metric' + ? fillEmptySpotsInTimeline( + series[key] ?? [], + payload.interval, + payload.startDate, + payload.endDate + ).map((item) => { + return { + label: legend, + count: round(item.count), + date: new Date(item.date).toISOString(), + }; + }) + : (series[key] ?? []).map((item) => ({ + label: item.label, + count: round(item.count), + date: new Date(item.date).toISOString(), + })); + + const counts = data.map((item) => item.count); return { name: legend, - event: { - id: payload.event.id, - name: payload.event.name, - }, + event: payload.event, metrics: { - total: sum(data.map((item) => item.count)), - average: round(average(data.map((item) => item.count))), + sum: sum(counts), + average: round(average(counts)), + max: Math.max(...counts), + min: Math.min(...counts), }, - data: - payload.chartType === 'linear' || payload.chartType === 'histogram' - ? fillEmptySpotsInTimeline( - data, - payload.interval, - payload.startDate, - payload.endDate - ).map((item) => { - return { - label: legend, - count: round(item.count), - date: new Date(item.date).toISOString(), - }; - }) - : [], + data, }; }); } diff --git a/apps/web/src/server/api/routers/client.ts b/apps/web/src/server/api/routers/client.ts index 786a7649..69660333 100644 --- a/apps/web/src/server/api/routers/client.ts +++ b/apps/web/src/server/api/routers/client.ts @@ -2,21 +2,19 @@ import { randomUUID } from 'crypto'; import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; import { db } from '@/server/db'; import { hashPassword } from '@/server/services/hash.service'; -import { getOrganizationBySlug } from '@/server/services/organization.service'; import { z } from 'zod'; export const clientRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ - organizationSlug: z.string(), + organizationId: z.string(), }) ) - .query(async ({ input }) => { - const organization = await getOrganizationBySlug(input.organizationSlug); + .query(async ({ input: { organizationId } }) => { return db.client.findMany({ where: { - organization_id: organization.id, + organization_id: organizationId, }, include: { project: true, @@ -60,16 +58,15 @@ export const clientRouter = createTRPCRouter({ z.object({ name: z.string(), projectId: z.string(), - organizationSlug: z.string(), + organizationId: z.string(), withCors: z.boolean().default(true), }) ) .mutation(async ({ input }) => { - const organization = await getOrganizationBySlug(input.organizationSlug); const secret = randomUUID(); const client = await db.client.create({ data: { - organization_id: organization.id, + organization_id: input.organizationId, project_id: input.projectId, name: input.name, secret: input.withCors ? null : await hashPassword(secret), diff --git a/apps/web/src/server/api/routers/dashboard.ts b/apps/web/src/server/api/routers/dashboard.ts index 616aba3c..7ae5cdf7 100644 --- a/apps/web/src/server/api/routers/dashboard.ts +++ b/apps/web/src/server/api/routers/dashboard.ts @@ -1,75 +1,72 @@ import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; import { db } from '@/server/db'; -import { getDashboardBySlug } from '@/server/services/dashboard.service'; -import { getProjectBySlug } from '@/server/services/project.service'; -import { slug } from '@/utils/slug'; import { PrismaError } from 'prisma-error-enum'; import { z } from 'zod'; -import { Prisma } from '@mixan/db'; +import type { Prisma } from '@mixan/db'; export const dashboardRouter = createTRPCRouter({ get: protectedProcedure - .input( - z - .object({ - slug: z.string(), - }) - .or(z.object({ id: z.string() })) - ) + .input(z.object({ id: z.string() })) .query(async ({ input }) => { - if ('id' in input) { - return db.dashboard.findUnique({ - where: { - id: input.id, - }, - }); - } else { - return getDashboardBySlug(input.slug); - } + return db.dashboard.findUnique({ + where: { + id: input.id, + }, + }); }), list: protectedProcedure .input( z .object({ - projectSlug: z.string(), + projectId: z.string(), }) .or( z.object({ - projectId: z.string(), + organizationId: z.string(), }) ) ) .query(async ({ input }) => { - let projectId = null; if ('projectId' in input) { - projectId = input.projectId; + return db.dashboard.findMany({ + where: { + project_id: input.projectId, + }, + orderBy: { + createdAt: 'desc', + }, + include: { + project: true, + }, + }); } else { - projectId = (await getProjectBySlug(input.projectSlug)).id; + return db.dashboard.findMany({ + where: { + project: { + organization_id: input.organizationId, + }, + }, + include: { + project: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); } - - return db.dashboard.findMany({ - where: { - project_id: projectId, - }, - orderBy: { - createdAt: 'desc', - }, - }); }), create: protectedProcedure .input( z.object({ name: z.string(), - projectSlug: z.string(), + projectId: z.string(), }) ) - .mutation(async ({ input: { projectSlug, name } }) => { - const project = await getProjectBySlug(projectSlug); + .mutation(async ({ input: { projectId, name } }) => { return db.dashboard.create({ data: { - slug: slug(name), - project_id: project.id, + project_id: projectId, name, }, }); @@ -95,17 +92,33 @@ export const dashboardRouter = createTRPCRouter({ .input( z.object({ id: z.string(), + forceDelete: z.boolean().optional(), }) ) - .mutation(async ({ input: { id } }) => { + .mutation(async ({ input: { id, forceDelete } }) => { try { + if (forceDelete) { + await db.report.deleteMany({ + where: { + dashboard_id: id, + }, + }); + } + await db.recentDashboards.deleteMany({ + where: { + dashboard_id: id, + }, + }); await db.dashboard.delete({ where: { id, }, }); - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + } catch (e) { + // Below does not work... + // error instanceof Prisma.PrismaClientKnownRequestError + if (typeof e === 'object' && e && 'code' in e) { + const error = e as Prisma.PrismaClientKnownRequestError; switch (error.code) { case PrismaError.ForeignConstraintViolation: throw new Error( diff --git a/apps/web/src/server/api/routers/event.ts b/apps/web/src/server/api/routers/event.ts index ccef5100..865926ee 100644 --- a/apps/web/src/server/api/routers/event.ts +++ b/apps/web/src/server/api/routers/event.ts @@ -2,29 +2,37 @@ import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; import { db } from '@/server/db'; import { z } from 'zod'; +import type { Event, Profile } from '@mixan/db'; + +function transformEvent( + event: Event & { + profile: Profile; + } +) { + return { + ...event, + properties: event.properties as Record, + }; +} + export const eventRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ - projectSlug: z.string(), + projectId: z.string(), take: z.number().default(100), skip: z.number().default(0), profileId: z.string().optional(), events: z.array(z.string()).optional(), }) ) - .query( - async ({ input: { take, skip, projectSlug, profileId, events } }) => { - const project = await db.project.findUniqueOrThrow({ - where: { - slug: projectSlug, - }, - }); - return db.event.findMany({ + .query(async ({ input: { take, skip, projectId, profileId, events } }) => { + return db.event + .findMany({ take, skip, where: { - project_id: project.id, + project_id: projectId, profile_id: profileId, ...(events && events.length > 0 ? { @@ -40,7 +48,7 @@ export const eventRouter = createTRPCRouter({ include: { profile: true, }, - }); - } - ), + }) + .then((events) => events.map(transformEvent)); + }), }); diff --git a/apps/web/src/server/api/routers/organization.ts b/apps/web/src/server/api/routers/organization.ts index f40f8295..99a0cca1 100644 --- a/apps/web/src/server/api/routers/organization.ts +++ b/apps/web/src/server/api/routers/organization.ts @@ -1,10 +1,20 @@ import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; import { db } from '@/server/db'; -import { getOrganizationBySlug } from '@/server/services/organization.service'; -import { slug } from '@/utils/slug'; +import { getOrganizationById } from '@/server/services/organization.service'; import { z } from 'zod'; export const organizationRouter = createTRPCRouter({ + list: protectedProcedure.query(({ ctx }) => { + return db.organization.findMany({ + where: { + users: { + some: { + id: ctx.session.user.id, + }, + }, + }, + }); + }), first: protectedProcedure.query(({ ctx }) => { return db.organization.findFirst({ where: { @@ -19,11 +29,11 @@ export const organizationRouter = createTRPCRouter({ get: protectedProcedure .input( z.object({ - slug: z.string(), + id: z.string(), }) ) .query(({ input }) => { - return getOrganizationBySlug(input.slug); + return getOrganizationById(input.id); }), update: protectedProcedure .input( @@ -39,7 +49,6 @@ export const organizationRouter = createTRPCRouter({ }, data: { name: input.name, - slug: slug(input.name), }, }); }), diff --git a/apps/web/src/server/api/routers/profile.ts b/apps/web/src/server/api/routers/profile.ts index a423665c..fce67fd4 100644 --- a/apps/web/src/server/api/routers/profile.ts +++ b/apps/web/src/server/api/routers/profile.ts @@ -6,22 +6,42 @@ export const profileRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ - projectSlug: z.string(), + query: z.string().nullable(), + projectId: z.string(), take: z.number().default(100), skip: z.number().default(0), }) ) - .query(async ({ input: { take, skip, projectSlug } }) => { - const project = await db.project.findUniqueOrThrow({ - where: { - slug: projectSlug, - }, - }); + .query(async ({ input: { take, skip, projectId, query } }) => { return db.profile.findMany({ take, skip, where: { - project_id: project.id, + project_id: projectId, + ...(query + ? { + OR: [ + { + first_name: { + contains: query, + mode: 'insensitive', + }, + }, + { + last_name: { + contains: query, + mode: 'insensitive', + }, + }, + { + email: { + contains: query, + mode: 'insensitive', + }, + }, + ], + } + : {}), }, orderBy: { createdAt: 'desc', diff --git a/apps/web/src/server/api/routers/project.ts b/apps/web/src/server/api/routers/project.ts index 975a77fd..5665842b 100644 --- a/apps/web/src/server/api/routers/project.ts +++ b/apps/web/src/server/api/routers/project.ts @@ -1,40 +1,34 @@ import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; -import { db } from '@/server/db'; -import { getOrganizationBySlug } from '@/server/services/organization.service'; -import { getProjectBySlug } from '@/server/services/project.service'; +import { db, getId } from '@/server/db'; +import { slug } from '@/utils/slug'; import { z } from 'zod'; export const projectRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ - organizationSlug: z.string(), + organizationId: z.string().nullable(), }) ) - .query(async ({ input }) => { - const organization = await getOrganizationBySlug(input.organizationSlug); + .query(async ({ input: { organizationId } }) => { + if (organizationId === null) return []; + return db.project.findMany({ where: { - organization_id: organization.id, + organization_id: organizationId, }, }); }), get: protectedProcedure .input( - z - .object({ - id: z.string(), - }) - .or(z.object({ slug: z.string() })) + z.object({ + id: z.string(), + }) ) - .query(({ input }) => { - if ('slug' in input) { - return getProjectBySlug(input.slug); - } - + .query(({ input: { id } }) => { return db.project.findUniqueOrThrow({ where: { - id: input.id, + id, }, }); }), @@ -58,15 +52,15 @@ export const projectRouter = createTRPCRouter({ create: protectedProcedure .input( z.object({ - name: z.string(), - organizationSlug: z.string(), + name: z.string().min(1), + organizationId: z.string(), }) ) .mutation(async ({ input }) => { - const organization = await getOrganizationBySlug(input.organizationSlug); return db.project.create({ data: { - organization_id: organization.id, + id: await getId('project', input.name), + organization_id: input.organizationId, name: input.name, }, }); diff --git a/apps/web/src/server/api/routers/report.ts b/apps/web/src/server/api/routers/report.ts index ba320337..83a516ce 100644 --- a/apps/web/src/server/api/routers/report.ts +++ b/apps/web/src/server/api/routers/report.ts @@ -1,58 +1,9 @@ import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc'; import { db } from '@/server/db'; -import { getDashboardBySlug } from '@/server/services/dashboard.service'; -import { getProjectBySlug } from '@/server/services/project.service'; -import type { - IChartBreakdown, - IChartEvent, - IChartEventFilter, - IChartInput, - IChartRange, -} from '@/types'; -import { alphabetIds, timeRanges } from '@/utils/constants'; +import { transformReport } from '@/server/services/reports.service'; import { zChartInput } from '@/utils/validation'; import { z } from 'zod'; -import type { Report as DbReport } from '@mixan/db'; - -function transformFilter( - filter: Partial, - index: number -): IChartEventFilter { - return { - id: filter.id ?? alphabetIds[index]!, - name: filter.name ?? 'Unknown Filter', - operator: filter.operator ?? 'is', - value: - typeof filter.value === 'string' ? [filter.value] : filter.value ?? [], - }; -} - -function transformEvent( - event: Partial, - index: number -): IChartEvent { - return { - segment: event.segment ?? 'event', - filters: (event.filters ?? []).map(transformFilter), - id: event.id ?? alphabetIds[index]!, - name: event.name || 'unknown_event', - displayName: event.displayName, - }; -} - -function transformReport(report: DbReport): IChartInput & { id: string } { - return { - id: report.id, - events: (report.events as IChartEvent[]).map(transformEvent), - breakdowns: report.breakdowns as IChartBreakdown[], - chartType: report.chart_type, - interval: report.interval, - name: report.name || 'Untitled', - range: (report.range as IChartRange) ?? timeRanges['1m'], - }; -} - export const reportRouter = createTRPCRouter({ get: protectedProcedure .input( @@ -72,22 +23,27 @@ export const reportRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ - projectSlug: z.string(), - dashboardSlug: z.string(), + projectId: z.string(), + dashboardId: z.string(), }) ) - .query(async ({ input: { projectSlug, dashboardSlug } }) => { - const project = await getProjectBySlug(projectSlug); - const dashboard = await getDashboardBySlug(dashboardSlug); - const reports = await db.report.findMany({ - where: { - project_id: project.id, - dashboard_id: dashboard.id, - }, - orderBy: { - createdAt: 'desc', - }, - }); + .query(async ({ input: { projectId, dashboardId } }) => { + const [dashboard, reports] = await db.$transaction([ + db.dashboard.findUniqueOrThrow({ + where: { + id: dashboardId, + }, + }), + db.report.findMany({ + where: { + project_id: projectId, + dashboard_id: dashboardId, + }, + orderBy: { + createdAt: 'desc', + }, + }), + ]); return { reports: reports.map(transformReport), @@ -116,6 +72,7 @@ export const reportRouter = createTRPCRouter({ interval: report.interval, breakdowns: report.breakdowns, chart_type: report.chartType, + line_type: report.lineType, range: report.range, }, }); @@ -138,6 +95,7 @@ export const reportRouter = createTRPCRouter({ interval: report.interval, breakdowns: report.breakdowns, chart_type: report.chartType, + line_type: report.lineType, range: report.range, }, }); diff --git a/apps/web/src/server/api/routers/user.ts b/apps/web/src/server/api/routers/user.ts index bacfd0c6..5fad162b 100644 --- a/apps/web/src/server/api/routers/user.ts +++ b/apps/web/src/server/api/routers/user.ts @@ -56,4 +56,19 @@ export const userRouter = createTRPCRouter({ }, }); }), + invite: protectedProcedure + .input( + z.object({ + email: z.string().email(), + organizationId: z.string(), + }) + ) + .mutation(async ({ input, ctx }) => { + await db.invite.create({ + data: { + organization_id: input.organizationId, + email: input.email, + }, + }); + }), }); diff --git a/apps/web/src/server/api/trpc.ts b/apps/web/src/server/api/trpc.ts index e7046f81..b5949653 100644 --- a/apps/web/src/server/api/trpc.ts +++ b/apps/web/src/server/api/trpc.ts @@ -7,8 +7,7 @@ * need to use are documented accordingly near the end. */ -import { getServerAuthSession } from '@/server/auth'; -import { db } from '@/server/db'; +import { getSession } from '@/server/auth'; import { initTRPC, TRPCError } from '@trpc/server'; import type { CreateNextContextOptions } from '@trpc/server/adapters/next'; import type { Session } from 'next-auth'; @@ -37,10 +36,9 @@ interface CreateContextOptions { * * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts */ -const createInnerTRPCContext = (opts: CreateContextOptions) => { +export const createInnerTRPCContext = (opts: CreateContextOptions) => { return { session: opts.session, - db, }; }; @@ -51,9 +49,8 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => { * @see https://trpc.io/docs/context */ export const createTRPCContext = async (opts: CreateNextContextOptions) => { - const { req, res } = opts; // Get the session from the server using the getServerSession wrapper function - const session = await getServerAuthSession({ req, res }); + const session = await getSession(); return createInnerTRPCContext({ session, diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index d7b07739..9f2e13bd 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -1,10 +1,7 @@ +import { cache } from 'react'; import { db } from '@/server/db'; import { verifyPassword } from '@/server/services/hash.service'; -import type { - GetServerSidePropsContext, - NextApiRequest, - NextApiResponse, -} from 'next'; +import type { NextApiRequest, NextApiResponse } from 'next'; import { getServerSession } from 'next-auth'; import type { DefaultSession, NextAuthOptions } from 'next-auth'; import Credentials from 'next-auth/providers/credentials'; @@ -78,29 +75,12 @@ export const authOptions: NextAuthOptions = { }; }, }), - /** - * ...add more providers here. - * - * Most other providers require a bit more work than the Discord provider. For example, the - * GitHub provider requires you to add the `refresh_token_expires_in` field to the Account - * model. Refer to the NextAuth.js docs for the provider you want to use. Example: - * - * @see https://next-auth.js.org/providers/github - */ ], }; -/** - * Wrapper for `getServerSession` so that you don't need to import the `authOptions` in every file. - * - * @see https://next-auth.js.org/configuration/nextjs - */ -export const getServerAuthSession = (ctx: { - req: GetServerSidePropsContext['req']; - res: GetServerSidePropsContext['res']; -}) => { - return getServerSession(ctx.req, ctx.res, authOptions); -}; +export const getSession = cache( + async () => await getServerSession(authOptions) +); export async function validateSdkRequest( req: NextApiRequest, diff --git a/apps/web/src/server/db.ts b/apps/web/src/server/db.ts index e765a404..f56cc6ed 100644 --- a/apps/web/src/server/db.ts +++ b/apps/web/src/server/db.ts @@ -1 +1,40 @@ +import { slug } from '@/utils/slug'; + +import { db } from '@mixan/db'; + export { db } from '@mixan/db'; + +export async function getId( + tableName: 'project' | 'organization' | 'dashboard', + name: string +) { + const newId = slug(name); + if (!db[tableName]) { + throw new Error('Table does not exists'); + } + + if (!('findUnique' in db[tableName])) { + throw new Error('findUnique does not exists'); + } + + // @ts-expect-error + const existingProject = await db[tableName]!.findUnique({ + where: { + id: newId, + }, + }); + + function random(str: string) { + const numbers = Math.floor(1000 + Math.random() * 9000); + if (str.match(/-\d{4}$/g)) { + return str.replace(/-\d{4}$/g, `-${numbers}`); + } + return `${str}-${numbers}`; + } + + if (existingProject) { + return getId(tableName, random(name)); + } + + return newId; +} diff --git a/apps/web/src/server/getServerSideProps.ts b/apps/web/src/server/getServerSideProps.ts deleted file mode 100644 index 002e9e94..00000000 --- a/apps/web/src/server/getServerSideProps.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'; - -import { getServerAuthSession } from './auth'; -import { db } from './db'; - -export function createServerSideProps( - cb?: (context: GetServerSidePropsContext) => Promise -) { - return async function getServerSideProps( - context: GetServerSidePropsContext - ): Promise> { - const session = await getServerAuthSession(context); - - if (!session) { - return { - redirect: { - destination: '/api/auth/signin', - permanent: false, - }, - }; - } - - if (context.params?.organization) { - const user = await db.user.findFirst({ - where: { - id: session.user.id, - organization: { - slug: context.params.organization as string, - }, - }, - }); - - if (!user) { - return { - notFound: true, - }; - } - } else { - const user = await db.user.findFirst({ - where: { - id: session.user.id, - }, - include: { - organization: true, - }, - }); - - if (!user) { - return { - notFound: true, - }; - } - - if (user.organization) { - return { - redirect: { - destination: `/${user.organization.slug}`, - permanent: false, - }, - }; - } - } - - const res = await (typeof cb === 'function' - ? cb(context) - : Promise.resolve({})); - return { - ...(res ?? {}), - props: { - session, - ...(res?.props ?? {}), - }, - }; - }; -} diff --git a/apps/web/src/server/services/clients.service.ts b/apps/web/src/server/services/clients.service.ts new file mode 100644 index 00000000..1a6111ef --- /dev/null +++ b/apps/web/src/server/services/clients.service.ts @@ -0,0 +1,12 @@ +import { db } from '@mixan/db'; + +export function getClientsByOrganizationId(organizationId: string) { + return db.client.findMany({ + where: { + organization_id: organizationId, + }, + include: { + project: true, + }, + }); +} diff --git a/apps/web/src/server/services/dashboard.service.ts b/apps/web/src/server/services/dashboard.service.ts index ad80ae53..acf59b54 100644 --- a/apps/web/src/server/services/dashboard.service.ts +++ b/apps/web/src/server/services/dashboard.service.ts @@ -1,9 +1,86 @@ +import { unstable_cache } from 'next/cache'; + import { db } from '../db'; -export function getDashboardBySlug(slug: string) { +export type IServiceRecentDashboards = Awaited< + ReturnType +>; +export type IServiceDashboard = Awaited>; +export type IServiceDashboardWithProject = Awaited< + ReturnType +>[number]; + +export function getDashboardById(id: string) { return db.dashboard.findUniqueOrThrow({ where: { - slug, + id, + }, + }); +} + +export function getDashboardsByProjectId(projectId: string) { + return db.dashboard.findMany({ + where: { + project_id: projectId, + }, + include: { + project: true, + }, + }); +} + +export async function getRecentDashboardsByUserId(userId: string) { + const tag = `recentDashboards_${userId}`; + + return unstable_cache( + async (userId: string) => { + return db.recentDashboards.findMany({ + where: { + user_id: userId, + }, + orderBy: { + createdAt: 'desc', + }, + include: { + project: true, + dashboard: true, + }, + take: 5, + }); + }, + tag.split('_'), + { + revalidate: 3600, + tags: [tag], + } + )(userId); +} + +export async function createRecentDashboard({ + organizationId, + projectId, + dashboardId, + userId, +}: { + organizationId: string; + projectId: string; + dashboardId: string; + userId: string; +}) { + await db.recentDashboards.deleteMany({ + where: { + user_id: userId, + project_id: projectId, + dashboard_id: dashboardId, + organization_id: organizationId, + }, + }); + return db.recentDashboards.create({ + data: { + user_id: userId, + organization_id: organizationId, + project_id: projectId, + dashboard_id: dashboardId, }, }); } diff --git a/apps/web/src/server/services/organization.service.ts b/apps/web/src/server/services/organization.service.ts index fb75d404..56d483aa 100644 --- a/apps/web/src/server/services/organization.service.ts +++ b/apps/web/src/server/services/organization.service.ts @@ -1,9 +1,25 @@ import { db } from '../db'; -export function getOrganizationBySlug(slug: string) { - return db.organization.findUniqueOrThrow({ +export type IServiceOrganization = Awaited< + ReturnType +>[number]; + +export function getOrganizations() { + return db.organization.findMany({ where: { - slug, + // users: { + // some: { + // id: '1', + // }, + // } + }, + }); +} + +export function getOrganizationById(id: string) { + return db.organization.findUniqueOrThrow({ + where: { + id, }, }); } diff --git a/apps/web/src/server/services/profile.service.ts b/apps/web/src/server/services/profile.service.ts index abcc5dec..1a640cbd 100644 --- a/apps/web/src/server/services/profile.service.ts +++ b/apps/web/src/server/services/profile.service.ts @@ -1,6 +1,32 @@ import { db } from '@/server/db'; import { HttpError } from '@/server/exceptions'; +export type IServiceProfile = Awaited>; + +export function getProfileById(id: string) { + return db.profile.findUniqueOrThrow({ + where: { + id, + }, + }); +} + +export function getProfilesByExternalId( + externalId: string | null, + projectId: string +) { + if (externalId === null) { + return []; + } + + return db.profile.findMany({ + where: { + external_id: externalId, + project_id: projectId, + }, + }); +} + export function getProfile(id: string) { return db.profile.findUniqueOrThrow({ where: { diff --git a/apps/web/src/server/services/project.service.ts b/apps/web/src/server/services/project.service.ts index 6777cf16..a0623162 100644 --- a/apps/web/src/server/services/project.service.ts +++ b/apps/web/src/server/services/project.service.ts @@ -1,9 +1,44 @@ +import { unstable_cache } from 'next/cache'; + import { db } from '../db'; -export function getProjectBySlug(slug: string) { - return db.project.findUniqueOrThrow({ +export type IServiceProject = Awaited>; + +export function getProjectById(id: string) { + return db.project.findUnique({ where: { - slug, + id, }, }); } + +export function getProjectsByOrganizationId(organizationId: string) { + return db.project.findMany({ + where: { + organization_id: organizationId, + }, + }); +} + +export function getFirstProjectByOrganizationId(organizationId: string) { + const tag = `getFirstProjectByOrganizationId_${organizationId}`; + return unstable_cache( + async (organizationId: string) => { + return db.project.findFirst({ + where: { + organization_id: organizationId, + }, + orderBy: { + events: { + _count: 'desc', + }, + }, + }); + }, + tag.split('_'), + { + tags: [tag], + revalidate: 3600 * 24, + } + )(organizationId); +} diff --git a/apps/web/src/server/services/reports.service.ts b/apps/web/src/server/services/reports.service.ts new file mode 100644 index 00000000..b7e62487 --- /dev/null +++ b/apps/web/src/server/services/reports.service.ts @@ -0,0 +1,75 @@ +import type { + IChartBreakdown, + IChartEvent, + IChartEventFilter, + IChartInput, + IChartLineType, + IChartRange, +} from '@/types'; +import { alphabetIds, timeRanges } from '@/utils/constants'; + +import { db } from '@mixan/db'; +import type { Report as DbReport } from '@mixan/db'; + +export type IServiceReport = Awaited>; + +export function transformFilter( + filter: Partial, + index: number +): IChartEventFilter { + return { + id: filter.id ?? alphabetIds[index]!, + name: filter.name ?? 'Unknown Filter', + operator: filter.operator ?? 'is', + value: + typeof filter.value === 'string' ? [filter.value] : filter.value ?? [], + }; +} + +export function transformEvent( + event: Partial, + index: number +): IChartEvent { + return { + segment: event.segment ?? 'event', + filters: (event.filters ?? []).map(transformFilter), + id: event.id ?? alphabetIds[index]!, + name: event.name || 'unknown_event', + displayName: event.displayName, + }; +} + +export function transformReport( + report: DbReport +): IChartInput & { id: string } { + return { + id: report.id, + events: (report.events as IChartEvent[]).map(transformEvent), + breakdowns: report.breakdowns as IChartBreakdown[], + chartType: report.chart_type, + lineType: (report.line_type ?? 'kuk') as IChartLineType, + interval: report.interval, + name: report.name || 'Untitled', + range: (report.range as IChartRange) ?? timeRanges['1m'], + }; +} + +export function getReportsByDashboardId(dashboardId: string) { + return db.report + .findMany({ + where: { + dashboard_id: dashboardId, + }, + }) + .then((reports) => reports.map(transformReport)); +} + +export function getReportById(id: string) { + return db.report + .findUniqueOrThrow({ + where: { + id, + }, + }) + .then(transformReport); +} diff --git a/apps/web/src/server/services/user.service.ts b/apps/web/src/server/services/user.service.ts new file mode 100644 index 00000000..5f379f3c --- /dev/null +++ b/apps/web/src/server/services/user.service.ts @@ -0,0 +1,20 @@ +import { db } from '@/server/db'; + +export function getUserById(id: string) { + return db.user.findUniqueOrThrow({ + where: { + id, + }, + }); +} + +export type IServiceInvite = Awaited< + ReturnType +>[number]; +export function getInvitesByOrganizationId(organizationId: string) { + return db.invite.findMany({ + where: { + organization_id: organizationId, + }, + }); +} diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index 52e84e75..9278d1a1 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -157,3 +157,10 @@ -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ } + +/* Rechart */ + +.recharts-wrapper .recharts-cartesian-grid-horizontal line:first-child, +.recharts-wrapper .recharts-cartesian-grid-horizontal line:last-child { + stroke-opacity: 0; +} diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index 9643aa64..7b9fc763 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -1,4 +1,3 @@ -import type { RouterOutputs } from '@/utils/api'; import type { timeRanges } from '@/utils/constants'; import type { zChartBreakdown, @@ -6,6 +5,7 @@ import type { zChartInput, zChartInputWithDates, zChartType, + zLineType, zTimeInterval, } from '@/utils/validation'; import type { TooltipProps } from 'recharts'; @@ -27,7 +27,7 @@ export type IChartEventFilterValue = export type IChartBreakdown = z.infer; export type IInterval = z.infer; export type IChartType = z.infer; -export type IChartData = RouterOutputs['chart']['chart']; +export type IChartLineType = z.infer; export type IChartRange = keyof typeof timeRanges; export type IToolTipProps = Omit, 'payload'> & { payload?: T[]; diff --git a/apps/web/src/utils/api.ts b/apps/web/src/utils/api.ts deleted file mode 100644 index 71af67e6..00000000 --- a/apps/web/src/utils/api.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * This is the client-side entrypoint for your tRPC API. It is used to create the `api` object which - * contains the Next.js App-wrapper, as well as your type-safe React Query hooks. - * - * We also create a few inference helpers for input and output types. - */ -import { toast } from '@/components/ui/use-toast'; -import type { AppRouter } from '@/server/api/root'; -import { httpLink, loggerLink } from '@trpc/client'; -import type { TRPCClientErrorBase } from '@trpc/client'; -import { createTRPCNext } from '@trpc/next'; -import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; -import superjson from 'superjson'; - -const getBaseUrl = () => { - if (typeof window !== 'undefined') return ''; // browser should use relative url - if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`; // SSR should use vercel url - return `http://localhost:${process.env.PORT ?? 3000}`; // dev SSR should use localhost -}; - -/** A set of type-safe react-query hooks for your tRPC API. */ -export const api = createTRPCNext({ - config() { - return { - queryClientConfig: { - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - enabled: typeof window !== 'undefined', - }, - }, - }, - /** - * Transformer used for data de-serialization from the server. - * - * @see https://trpc.io/docs/data-transformers - */ - transformer: superjson, - - /** - * Links used to determine request flow from client to server. - * - * @see https://trpc.io/docs/links - */ - links: [ - loggerLink({ - enabled: (opts) => - process.env.NODE_ENV === 'development' || - (opts.direction === 'down' && opts.result instanceof Error), - }), - httpLink({ - url: `${getBaseUrl()}/api/trpc`, - }), - ], - }; - }, - /** - * Whether tRPC should await queries when server rendering pages. - * - * @see https://trpc.io/docs/nextjs#ssr-boolean-default-false - */ - ssr: false, -}); - -/** - * Inference helper for inputs. - * - * @example type HelloInput = RouterInputs['example']['hello'] - */ -export type RouterInputs = inferRouterInputs; - -/** - * Inference helper for outputs. - * - * @example type HelloOutput = RouterOutputs['example']['hello'] - */ -export type RouterOutputs = inferRouterOutputs; - -export function handleError(error: TRPCClientErrorBase) { - toast({ - title: 'Error', - description: error.message, - }); -} diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts index c2b1c78b..af53bc0f 100644 --- a/apps/web/src/utils/constants.ts +++ b/apps/web/src/utils/constants.ts @@ -14,6 +14,24 @@ export const chartTypes = { area: 'Area', }; +export const lineTypes = { + monotone: 'Monotone', + monotoneX: 'Monotone X', + monotoneY: 'Monotone Y', + linear: 'Linear', + natural: 'Natural', + basis: 'Basis', + step: 'Step', + stepBefore: 'Step before', + stepAfter: 'Step after', + basisClosed: 'Basis closed', + basisOpen: 'Basis open', + bumpX: 'Bump X', + bumpY: 'Bump Y', + bump: 'Bump', + linearClosed: 'Linear closed', +}; + export const intervals = { minute: 'Minute', day: 'Day', @@ -50,3 +68,22 @@ export const timeRanges = { export function isMinuteIntervalEnabledByRange(range: keyof typeof timeRanges) { return range === '30min' || range === '1h'; } + +export function isHourIntervalEnabledByRange(range: keyof typeof timeRanges) { + return ( + isMinuteIntervalEnabledByRange(range) || + range === 'today' || + range === '24h' + ); +} + +export function getDefaultIntervalByRange(range: keyof typeof timeRanges) { + if (range === '30min' || range === '1h') { + return 'minute'; + } else if (range === 'today' || range === '24h') { + return 'hour'; + } else if (range === '7d' || range === '14d' || range === '1m') { + return 'day'; + } + return 'month'; +} diff --git a/apps/web/src/utils/getters.ts b/apps/web/src/utils/getters.ts index 98efbe63..53d3e784 100644 --- a/apps/web/src/utils/getters.ts +++ b/apps/web/src/utils/getters.ts @@ -1,6 +1,6 @@ import type { Profile } from '@mixan/db'; export function getProfileName(profile: Profile | undefined | null) { - if (!profile) return ''; + if (!profile) return 'No profile'; return [profile.first_name, profile.last_name].filter(Boolean).join(' '); } diff --git a/apps/web/src/utils/theme.ts b/apps/web/src/utils/theme.ts index d67e187d..6fccc0fa 100644 --- a/apps/web/src/utils/theme.ts +++ b/apps/web/src/utils/theme.ts @@ -4,7 +4,7 @@ import tailwinConfig from '../../tailwind.config'; export const resolvedTailwindConfig = resolveConfig(tailwinConfig); -export const theme = resolvedTailwindConfig.theme; +export const theme = resolvedTailwindConfig.theme as Record; export function getChartColor(index: number): string { const colors = theme?.colors ?? {}; diff --git a/apps/web/src/utils/validation.ts b/apps/web/src/utils/validation.ts index 2281a12e..dcf8be80 100644 --- a/apps/web/src/utils/validation.ts +++ b/apps/web/src/utils/validation.ts @@ -1,8 +1,16 @@ import { z } from 'zod'; -import { chartTypes, intervals, operators, timeRanges } from './constants'; +import { + chartTypes, + intervals, + lineTypes, + operators, + timeRanges, +} from './constants'; -function objectToZodEnums(obj: Record): [K, ...K[]] { +export function objectToZodEnums( + obj: Record +): [K, ...K[]] { const [firstKey, ...otherKeys] = Object.keys(obj) as K[]; return [firstKey!, ...otherKeys]; } @@ -31,11 +39,14 @@ export const zChartBreakdowns = z.array(zChartBreakdown); export const zChartType = z.enum(objectToZodEnums(chartTypes)); +export const zLineType = z.enum(objectToZodEnums(lineTypes)); + export const zTimeInterval = z.enum(objectToZodEnums(intervals)); export const zChartInput = z.object({ name: z.string(), chartType: zChartType, + lineType: zLineType, interval: zTimeInterval, events: zChartEvents, breakdowns: zChartBreakdowns, diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index 931a5665..fba13589 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -1,5 +1,5 @@ const colors = [ - '#7856ff', + '#2563EB', '#ff7557', '#7fe1d8', '#f8bc3c', @@ -11,6 +11,7 @@ const colors = [ '#febbb2', '#cb80dc', '#5cb7af', + '#7856ff', ]; /** @type {import('tailwindcss').Config} */ @@ -23,13 +24,6 @@ const config = { './src/**/*.{ts,tsx}', ], theme: { - container: { - center: true, - padding: '2rem', - screens: { - '2xl': '1400px', - }, - }, extend: { colors: { border: 'hsl(var(--border))', diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b62436fc..9d21e044 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -3,15 +3,23 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] }, "plugins": [ { "name": "next" } ], - "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", + "strictNullChecks": true }, - "include": [".", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + ".", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/packages/db/prisma/migrations/20240113095542_remove_slug_2/migration.sql b/packages/db/prisma/migrations/20240113095542_remove_slug_2/migration.sql new file mode 100644 index 00000000..dd3e14ed --- /dev/null +++ b/packages/db/prisma/migrations/20240113095542_remove_slug_2/migration.sql @@ -0,0 +1,110 @@ +/* + Warnings: + + - The primary key for the `dashboards` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `slug` on the `dashboards` table. All the data in the column will be lost. + - The primary key for the `organizations` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `slug` on the `organizations` table. All the data in the column will be lost. + - The primary key for the `projects` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to drop the column `slug` on the `projects` table. All the data in the column will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "clients" DROP CONSTRAINT "clients_organization_id_fkey"; + +-- DropForeignKey +ALTER TABLE "clients" DROP CONSTRAINT "clients_project_id_fkey"; + +-- DropForeignKey +ALTER TABLE "dashboards" DROP CONSTRAINT "dashboards_project_id_fkey"; + +-- DropForeignKey +ALTER TABLE "events" DROP CONSTRAINT "events_project_id_fkey"; + +-- DropForeignKey +ALTER TABLE "profiles" DROP CONSTRAINT "profiles_project_id_fkey"; + +-- DropForeignKey +ALTER TABLE "projects" DROP CONSTRAINT "projects_organization_id_fkey"; + +-- DropForeignKey +ALTER TABLE "reports" DROP CONSTRAINT "reports_dashboard_id_fkey"; + +-- DropForeignKey +ALTER TABLE "reports" DROP CONSTRAINT "reports_project_id_fkey"; + +-- DropForeignKey +ALTER TABLE "users" DROP CONSTRAINT "users_organization_id_fkey"; + +-- DropIndex +DROP INDEX "dashboards_slug_key"; + +-- DropIndex +DROP INDEX "organizations_slug_key"; + +-- DropIndex +DROP INDEX "projects_slug_key"; + +-- AlterTable +ALTER TABLE "clients" ALTER COLUMN "project_id" SET DATA TYPE TEXT, +ALTER COLUMN "organization_id" SET DATA TYPE TEXT; + +-- AlterTable +ALTER TABLE "dashboards" DROP CONSTRAINT "dashboards_pkey", +DROP COLUMN "slug", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "project_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "dashboards_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "events" ALTER COLUMN "project_id" SET DATA TYPE TEXT; + +-- AlterTable +ALTER TABLE "organizations" DROP CONSTRAINT "organizations_pkey", +DROP COLUMN "slug", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ADD CONSTRAINT "organizations_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "profiles" ALTER COLUMN "project_id" SET DATA TYPE TEXT; + +-- AlterTable +ALTER TABLE "projects" DROP CONSTRAINT "projects_pkey", +DROP COLUMN "slug", +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "organization_id" SET DATA TYPE TEXT, +ADD CONSTRAINT "projects_pkey" PRIMARY KEY ("id"); + +-- AlterTable +ALTER TABLE "reports" ALTER COLUMN "project_id" SET DATA TYPE TEXT, +ALTER COLUMN "dashboard_id" SET DATA TYPE TEXT; + +-- AlterTable +ALTER TABLE "users" ALTER COLUMN "organization_id" SET DATA TYPE TEXT; + +-- AddForeignKey +ALTER TABLE "projects" ADD CONSTRAINT "projects_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "events" ADD CONSTRAINT "events_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "profiles" ADD CONSTRAINT "profiles_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "clients" ADD CONSTRAINT "clients_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "clients" ADD CONSTRAINT "clients_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reports" ADD CONSTRAINT "reports_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reports" ADD CONSTRAINT "reports_dashboard_id_fkey" FOREIGN KEY ("dashboard_id") REFERENCES "dashboards"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20240116100132_add_recent_dashboards/migration.sql b/packages/db/prisma/migrations/20240116100132_add_recent_dashboards/migration.sql new file mode 100644 index 00000000..40975cd9 --- /dev/null +++ b/packages/db/prisma/migrations/20240116100132_add_recent_dashboards/migration.sql @@ -0,0 +1,15 @@ +-- CreateTable +CREATE TABLE "recent_dashboards" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "organization_id" TEXT NOT NULL, + "dashboard_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "userId" UUID NOT NULL, + + CONSTRAINT "recent_dashboards_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "recent_dashboards" ADD CONSTRAINT "recent_dashboards_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20240116101051_fix_recent_dashboards/migration.sql b/packages/db/prisma/migrations/20240116101051_fix_recent_dashboards/migration.sql new file mode 100644 index 00000000..5b495116 --- /dev/null +++ b/packages/db/prisma/migrations/20240116101051_fix_recent_dashboards/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - You are about to drop the column `userId` on the `recent_dashboards` table. All the data in the column will be lost. + - Changed the type of `user_id` on the `recent_dashboards` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- DropForeignKey +ALTER TABLE "recent_dashboards" DROP CONSTRAINT "recent_dashboards_userId_fkey"; + +-- AlterTable +ALTER TABLE "recent_dashboards" DROP COLUMN "userId", +ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +DROP COLUMN "user_id", +ADD COLUMN "user_id" UUID NOT NULL; + +-- AddForeignKey +ALTER TABLE "recent_dashboards" ADD CONSTRAINT "recent_dashboards_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20240116101524_add_relations_to_recent/migration.sql b/packages/db/prisma/migrations/20240116101524_add_relations_to_recent/migration.sql new file mode 100644 index 00000000..c2b1850a --- /dev/null +++ b/packages/db/prisma/migrations/20240116101524_add_relations_to_recent/migration.sql @@ -0,0 +1,5 @@ +-- AddForeignKey +ALTER TABLE "recent_dashboards" ADD CONSTRAINT "recent_dashboards_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "recent_dashboards" ADD CONSTRAINT "recent_dashboards_dashboard_id_fkey" FOREIGN KEY ("dashboard_id") REFERENCES "dashboards"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20240116101723_remove_name_from_recent_dashboards/migration.sql b/packages/db/prisma/migrations/20240116101723_remove_name_from_recent_dashboards/migration.sql new file mode 100644 index 00000000..db426f26 --- /dev/null +++ b/packages/db/prisma/migrations/20240116101723_remove_name_from_recent_dashboards/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `name` on the `recent_dashboards` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "recent_dashboards" DROP COLUMN "name"; diff --git a/packages/db/prisma/migrations/20240116183124_add_invite/migration.sql b/packages/db/prisma/migrations/20240116183124_add_invite/migration.sql new file mode 100644 index 00000000..3a84e952 --- /dev/null +++ b/packages/db/prisma/migrations/20240116183124_add_invite/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "invites" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "email" TEXT NOT NULL, + "organization_id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "accepted" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "invites_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "invites" ADD CONSTRAINT "invites_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20240117210232_add_line_type/migration.sql b/packages/db/prisma/migrations/20240117210232_add_line_type/migration.sql new file mode 100644 index 00000000..f137cd2d --- /dev/null +++ b/packages/db/prisma/migrations/20240117210232_add_line_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "reports" ADD COLUMN "line_type" TEXT NOT NULL DEFAULT 'monotone'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 4d298835..045127ad 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -11,33 +11,33 @@ datasource db { } model Organization { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + id String @id @default(dbgenerated("gen_random_uuid()")) name String - slug String @unique @default(dbgenerated("gen_random_uuid()")) projects Project[] users User[] createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt clients Client[] + Invite Invite[] @@map("organizations") } model Project { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + id String @id @default(dbgenerated("gen_random_uuid()")) name String - slug String @unique @default(dbgenerated("gen_random_uuid()")) - organization_id String @db.Uuid + organization_id String organization Organization @relation(fields: [organization_id], references: [id]) events Event[] profiles Profile[] clients Client[] - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - reports Report[] - dashboards Dashboard[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + reports Report[] + dashboards Dashboard[] + RecentDashboards RecentDashboards[] @@map("projects") } @@ -47,11 +47,12 @@ model User { name String email String password String - organization_id String @db.Uuid + organization_id String organization Organization @relation(fields: [organization_id], references: [id]) - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + RecentDashboards RecentDashboards[] @@map("users") } @@ -60,7 +61,7 @@ model Event { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid name String properties Json - project_id String @db.Uuid + project_id String project Project @relation(fields: [project_id], references: [id]) profile_id String? @db.Uuid @@ -80,7 +81,7 @@ model Profile { email String? avatar String? properties Json - project_id String @db.Uuid + project_id String project Project @relation(fields: [project_id], references: [id]) events Event[] @@ -103,9 +104,9 @@ model Client { id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid name String secret String? - project_id String @db.Uuid + project_id String project Project @relation(fields: [project_id], references: [id]) - organization_id String @db.Uuid + organization_id String organization Organization @relation(fields: [organization_id], references: [id]) cors String @default("*") @@ -115,6 +116,20 @@ model Client { @@map("clients") } +model RecentDashboards { + id String @id @default(dbgenerated("gen_random_uuid()")) + project_id String + project Project @relation(fields: [project_id], references: [id]) + organization_id String + dashboard_id String + dashboard Dashboard @relation(fields: [dashboard_id], references: [id]) + user_id String @db.Uuid + user User @relation(fields: [user_id], references: [id]) + createdAt DateTime @default(now()) + + @@map("recent_dashboards") +} + enum Interval { hour day @@ -132,15 +147,15 @@ enum ChartType { } model Dashboard { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + id String @id @default(dbgenerated("gen_random_uuid()")) name String - slug String @unique @default(dbgenerated("gen_random_uuid()")) - project_id String @db.Uuid + project_id String project Project @relation(fields: [project_id], references: [id]) reports Report[] - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + RecentDashboards RecentDashboards[] @@map("dashboards") } @@ -151,12 +166,13 @@ model Report { interval Interval range String @default("1m") chart_type ChartType + line_type String @default("monotone") breakdowns Json events Json - project_id String @db.Uuid + project_id String project Project @relation(fields: [project_id], references: [id]) - dashboard_id String @db.Uuid + dashboard_id String dashboard Dashboard @relation(fields: [dashboard_id], references: [id]) createdAt DateTime @default(now()) @@ -164,3 +180,17 @@ model Report { @@map("reports") } + +model Invite { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + email String + organization_id String + organization Organization @relation(fields: [organization_id], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + accepted Boolean @default(false) + + @@map("invites") +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 753b9d69..496e4bb2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,7 +155,7 @@ importers: version: 10.43.0(@trpc/server@10.43.0) '@trpc/next': specifier: ^10.37.1 - version: 10.43.0(@tanstack/react-query@4.36.1)(@trpc/client@10.43.0)(@trpc/react-query@10.43.0)(@trpc/server@10.43.0)(next@13.4.19)(react-dom@18.2.0)(react@18.2.0) + version: 10.43.0(@tanstack/react-query@4.36.1)(@trpc/client@10.43.0)(@trpc/react-query@10.43.0)(@trpc/server@10.43.0)(next@14.0.4)(react-dom@18.2.0)(react@18.2.0) '@trpc/react-query': specifier: ^10.37.1 version: 10.43.0(@tanstack/react-query@4.36.1)(@trpc/client@10.43.0)(@trpc/server@10.43.0)(react-dom@18.2.0)(react@18.2.0) @@ -174,6 +174,9 @@ importers: cmdk: specifier: ^0.2.0 version: 0.2.0(@types/react@18.2.34)(react-dom@18.2.0)(react@18.2.0) + hamburger-react: + specifier: ^2.5.0 + version: 2.5.0(react@18.2.0) lodash.debounce: specifier: ^4.0.8 version: 4.0.8 @@ -187,11 +190,14 @@ importers: specifier: ^3.0.1 version: 3.0.1 next: - specifier: '13.4' - version: 13.4.19(react-dom@18.2.0)(react@18.2.0) + specifier: ~14.0.4 + version: 14.0.4(react-dom@18.2.0)(react@18.2.0) next-auth: specifier: ^4.23.0 - version: 4.24.4(next@13.4.19)(react-dom@18.2.0)(react@18.2.0) + version: 4.24.4(next@14.0.4)(react-dom@18.2.0)(react@18.2.0) + nuqs: + specifier: ^1.15.2 + version: 1.15.2(next@14.0.4) prisma-error-enum: specifier: ^0.1.3 version: 0.1.3 @@ -204,6 +210,9 @@ importers: react: specifier: 18.2.0 version: 18.2.0 + react-animate-height: + specifier: ^3.2.3 + version: 3.2.3(react-dom@18.2.0)(react@18.2.0) react-dom: specifier: 18.2.0 version: 18.2.0(react@18.2.0) @@ -539,8 +548,8 @@ importers: tooling/eslint: dependencies: '@next/eslint-plugin-next': - specifier: ^13.4.19 - version: 13.5.6 + specifier: ^14.0.4 + version: 14.0.4 '@types/eslint': specifier: ^8.44.2 version: 8.44.6 @@ -550,6 +559,9 @@ importers: '@typescript-eslint/parser': specifier: ^6.6.0 version: 6.9.1(eslint@8.52.0)(typescript@5.2.2) + eslint-config-next: + specifier: ^14.0.4 + version: 14.0.4(eslint@8.52.0)(typescript@5.2.2) eslint-config-prettier: specifier: ^9.0.0 version: 9.0.0(eslint@8.52.0) @@ -558,7 +570,7 @@ importers: version: 1.10.16(eslint@8.52.0) eslint-plugin-import: specifier: ^2.28.1 - version: 2.29.0(@typescript-eslint/parser@6.9.1)(eslint@8.52.0) + version: 2.29.0(@typescript-eslint/parser@6.9.1)(eslint-import-resolver-typescript@3.6.1)(eslint@8.52.0) eslint-plugin-jsx-a11y: specifier: ^6.7.1 version: 6.8.0(eslint@8.52.0) @@ -1289,8 +1301,12 @@ packages: resolution: {integrity: sha512-FsAT5x0jF2kkhNkKkukhsyYOrRqtSxrEhfliniIq0bwWbuXLgyt3Gv0Ml+b91XwjwArmuP7NxCiGd++GGKdNMQ==} dev: false - /@next/eslint-plugin-next@13.5.6: - resolution: {integrity: sha512-ng7pU/DDsxPgT6ZPvuprxrkeew3XaRf4LAT4FabaEO/hAbvVx4P7wqnqdbTdDn1kgTvsI4tpIgT4Awn/m0bGbg==} + /@next/env@14.0.4: + resolution: {integrity: sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==} + dev: false + + /@next/eslint-plugin-next@14.0.4: + resolution: {integrity: sha512-U3qMNHmEZoVmHA0j/57nRfi3AscXNvkOnxDmle/69Jz/G0o/gWjXTDdlgILZdrxQ0Lw/jv2mPW8PGy0EGIHXhQ==} dependencies: glob: 7.1.7 dev: false @@ -1304,6 +1320,15 @@ packages: dev: false optional: true + /@next/swc-darwin-arm64@14.0.4: + resolution: {integrity: sha512-mF05E/5uPthWzyYDyptcwHptucf/jj09i2SXBPwNzbgBNc+XnwzrL0U6BmPjQeOL+FiB+iG1gwBeq7mlDjSRPg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /@next/swc-darwin-x64@13.4.19: resolution: {integrity: sha512-jyzO6wwYhx6F+7gD8ddZfuqO4TtpJdw3wyOduR4fxTUCm3aLw7YmHGYNjS0xRSYGAkLpBkH1E0RcelyId6lNsw==} engines: {node: '>= 10'} @@ -1313,6 +1338,15 @@ packages: dev: false optional: true + /@next/swc-darwin-x64@14.0.4: + resolution: {integrity: sha512-IZQ3C7Bx0k2rYtrZZxKKiusMTM9WWcK5ajyhOZkYYTCc8xytmwSzR1skU7qLgVT/EY9xtXDG0WhY6fyujnI3rw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-arm64-gnu@13.4.19: resolution: {integrity: sha512-vdlnIlaAEh6H+G6HrKZB9c2zJKnpPVKnA6LBwjwT2BTjxI7e0Hx30+FoWCgi50e+YO49p6oPOtesP9mXDRiiUg==} engines: {node: '>= 10'} @@ -1322,6 +1356,15 @@ packages: dev: false optional: true + /@next/swc-linux-arm64-gnu@14.0.4: + resolution: {integrity: sha512-VwwZKrBQo/MGb1VOrxJ6LrKvbpo7UbROuyMRvQKTFKhNaXjUmKTu7wxVkIuCARAfiI8JpaWAnKR+D6tzpCcM4w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-arm64-musl@13.4.19: resolution: {integrity: sha512-aU0HkH2XPgxqrbNRBFb3si9Ahu/CpaR5RPmN2s9GiM9qJCiBBlZtRTiEca+DC+xRPyCThTtWYgxjWHgU7ZkyvA==} engines: {node: '>= 10'} @@ -1331,6 +1374,15 @@ packages: dev: false optional: true + /@next/swc-linux-arm64-musl@14.0.4: + resolution: {integrity: sha512-8QftwPEW37XxXoAwsn+nXlodKWHfpMaSvt81W43Wh8dv0gkheD+30ezWMcFGHLI71KiWmHK5PSQbTQGUiidvLQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-x64-gnu@13.4.19: resolution: {integrity: sha512-htwOEagMa/CXNykFFeAHHvMJeqZfNQEoQvHfsA4wgg5QqGNqD5soeCer4oGlCol6NGUxknrQO6VEustcv+Md+g==} engines: {node: '>= 10'} @@ -1340,6 +1392,15 @@ packages: dev: false optional: true + /@next/swc-linux-x64-gnu@14.0.4: + resolution: {integrity: sha512-/s/Pme3VKfZAfISlYVq2hzFS8AcAIOTnoKupc/j4WlvF6GQ0VouS2Q2KEgPuO1eMBwakWPB1aYFIA4VNVh667A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-linux-x64-musl@13.4.19: resolution: {integrity: sha512-4Gj4vvtbK1JH8ApWTT214b3GwUh9EKKQjY41hH/t+u55Knxi/0wesMzwQRhppK6Ddalhu0TEttbiJ+wRcoEj5Q==} engines: {node: '>= 10'} @@ -1349,6 +1410,15 @@ packages: dev: false optional: true + /@next/swc-linux-x64-musl@14.0.4: + resolution: {integrity: sha512-m8z/6Fyal4L9Bnlxde5g2Mfa1Z7dasMQyhEhskDATpqr+Y0mjOBZcXQ7G5U+vgL22cI4T7MfvgtrM2jdopqWaw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + /@next/swc-win32-arm64-msvc@13.4.19: resolution: {integrity: sha512-bUfDevQK4NsIAHXs3/JNgnvEY+LRyneDN788W2NYiRIIzmILjba7LaQTfihuFawZDhRtkYCv3JDC3B4TwnmRJw==} engines: {node: '>= 10'} @@ -1358,6 +1428,15 @@ packages: dev: false optional: true + /@next/swc-win32-arm64-msvc@14.0.4: + resolution: {integrity: sha512-7Wv4PRiWIAWbm5XrGz3D8HUkCVDMMz9igffZG4NB1p4u1KoItwx9qjATHz88kwCEal/HXmbShucaslXCQXUM5w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@next/swc-win32-ia32-msvc@13.4.19: resolution: {integrity: sha512-Y5kikILFAr81LYIFaw6j/NrOtmiM4Sf3GtOc0pn50ez2GCkr+oejYuKGcwAwq3jiTKuzF6OF4iT2INPoxRycEA==} engines: {node: '>= 10'} @@ -1367,6 +1446,15 @@ packages: dev: false optional: true + /@next/swc-win32-ia32-msvc@14.0.4: + resolution: {integrity: sha512-zLeNEAPULsl0phfGb4kdzF/cAVIfaC7hY+kt0/d+y9mzcZHsMS3hAS829WbJ31DkSlVKQeHEjZHIdhN+Pg7Gyg==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@next/swc-win32-x64-msvc@13.4.19: resolution: {integrity: sha512-YzA78jBDXMYiINdPdJJwGgPNT3YqBNNGhsthsDoWHL9p24tEJn9ViQf/ZqTbwSpX/RrkPupLfuuTH2sf73JBAw==} engines: {node: '>= 10'} @@ -1376,6 +1464,15 @@ packages: dev: false optional: true + /@next/swc-win32-x64-msvc@14.0.4: + resolution: {integrity: sha512-yEh2+R8qDlDCjxVpzOTEpBLQTEFAcP2A8fUFLaWNap9GitYKkKv1//y2S6XY6zsR4rCOPRpU7plYDR+az2n30A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2414,12 +2511,22 @@ packages: reselect: 4.1.8 dev: false + /@rushstack/eslint-patch@1.7.0: + resolution: {integrity: sha512-Jh4t/593gxs0lJZ/z3NnasKlplXT2f+4y/LZYuaKZW5KAaiVFL/fThhs+17EbUd53jUVJ0QudYCBGbN/psvaqg==} + dev: false + /@swc/helpers@0.5.1: resolution: {integrity: sha512-sJ902EfIzn1Fa+qYmjdQqh8tPsoxyBz+8yBKC2HKUxyezKJFwPGOn7pv4WY6QuQW//ySQi5lJjA/ZT9sNWWNTg==} dependencies: tslib: 2.6.2 dev: false + /@swc/helpers@0.5.2: + resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} + dependencies: + tslib: 2.6.2 + dev: false + /@t3-oss/env-core@0.7.1(typescript@5.2.2)(zod@3.22.4): resolution: {integrity: sha512-3+SQt39OlmSaRLqYVFv8uRm1BpFepM5TIiMytRqO9cjH+wB77o6BIJdeyM5h5U4qLBMEzOJWCY4MBaU/rLwbYw==} peerDependencies: @@ -2494,7 +2601,7 @@ packages: '@trpc/server': 10.43.0 dev: false - /@trpc/next@10.43.0(@tanstack/react-query@4.36.1)(@trpc/client@10.43.0)(@trpc/react-query@10.43.0)(@trpc/server@10.43.0)(next@13.4.19)(react-dom@18.2.0)(react@18.2.0): + /@trpc/next@10.43.0(@tanstack/react-query@4.36.1)(@trpc/client@10.43.0)(@trpc/react-query@10.43.0)(@trpc/server@10.43.0)(next@14.0.4)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-yakRlkvf6+uc3igYZi8nPkppJ8jFaCyd/7kvSckgsdLC8ofM+N9rrBkfqdfS5Pd53awkK/MMK8js9v/vabKb6A==} peerDependencies: '@tanstack/react-query': ^4.18.0 @@ -2509,7 +2616,7 @@ packages: '@trpc/client': 10.43.0(@trpc/server@10.43.0) '@trpc/react-query': 10.43.0(@tanstack/react-query@4.36.1)(@trpc/client@10.43.0)(@trpc/server@10.43.0)(react-dom@18.2.0)(react@18.2.0) '@trpc/server': 10.43.0 - next: 13.4.19(react-dom@18.2.0)(react@18.2.0) + next: 14.0.4(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) react-ssr-prepass: 1.5.0(react@18.2.0) @@ -3215,6 +3322,10 @@ packages: /caniuse-lite@1.0.30001559: resolution: {integrity: sha512-cPiMKZgqgkg5LY3/ntGeLFUpi6tzddBNS58A4tnTgQw1zON7u2sZMU7SzOeVH4tj20++9ggL+V6FDOFMTaFFYA==} + /caniuse-lite@1.0.30001577: + resolution: {integrity: sha512-rs2ZygrG1PNXMfmncM0B5H1hndY5ZCC9b5TkFaVNfZ+AUlyqcMyVIQtc3fsezi0NUCk5XZfDf9WS6WxMxnfdrg==} + dev: false + /chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -3652,6 +3763,14 @@ packages: engines: {node: '>= 0.8'} dev: false + /enhanced-resolve@5.15.0: + resolution: {integrity: sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==} + engines: {node: '>=10.13.0'} + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + dev: false + /es-abstract@1.22.3: resolution: {integrity: sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==} engines: {node: '>= 0.4'} @@ -3787,6 +3906,31 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + /eslint-config-next@14.0.4(eslint@8.52.0)(typescript@5.2.2): + resolution: {integrity: sha512-9/xbOHEQOmQtqvQ1UsTQZpnA7SlDMBtuKJ//S4JnoyK3oGLhILKXdBgu/UO7lQo/2xOykQULS1qQ6p2+EpHgAQ==} + peerDependencies: + eslint: ^7.23.0 || ^8.0.0 + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@next/eslint-plugin-next': 14.0.4 + '@rushstack/eslint-patch': 1.7.0 + '@typescript-eslint/parser': 6.9.1(eslint@8.52.0)(typescript@5.2.2) + eslint: 8.52.0 + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.9.1)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0)(eslint@8.52.0) + eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.9.1)(eslint-import-resolver-typescript@3.6.1)(eslint@8.52.0) + eslint-plugin-jsx-a11y: 6.8.0(eslint@8.52.0) + eslint-plugin-react: 7.33.2(eslint@8.52.0) + eslint-plugin-react-hooks: 4.6.0(eslint@8.52.0) + typescript: 5.2.2 + transitivePeerDependencies: + - eslint-import-resolver-webpack + - supports-color + dev: false + /eslint-config-prettier@9.0.0(eslint@8.52.0): resolution: {integrity: sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==} hasBin: true @@ -3815,7 +3959,30 @@ packages: - supports-color dev: false - /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.9.1)(eslint-import-resolver-node@0.3.9)(eslint@8.52.0): + /eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.9.1)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0)(eslint@8.52.0): + resolution: {integrity: sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + dependencies: + debug: 4.3.4 + enhanced-resolve: 5.15.0 + eslint: 8.52.0 + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.9.1)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.52.0) + eslint-plugin-import: 2.29.0(@typescript-eslint/parser@6.9.1)(eslint-import-resolver-typescript@3.6.1)(eslint@8.52.0) + fast-glob: 3.3.1 + get-tsconfig: 4.7.2 + is-core-module: 2.13.1 + is-glob: 4.0.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-node + - eslint-import-resolver-webpack + - supports-color + dev: false + + /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.9.1)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.52.0): resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} engines: {node: '>=4'} peerDependencies: @@ -3840,11 +4007,12 @@ packages: debug: 3.2.7 eslint: 8.52.0 eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.9.1)(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0)(eslint@8.52.0) transitivePeerDependencies: - supports-color dev: false - /eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.9.1)(eslint@8.52.0): + /eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.9.1)(eslint-import-resolver-typescript@3.6.1)(eslint@8.52.0): resolution: {integrity: sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==} engines: {node: '>=4'} peerDependencies: @@ -3863,7 +4031,7 @@ packages: doctrine: 2.1.0 eslint: 8.52.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.9.1)(eslint-import-resolver-node@0.3.9)(eslint@8.52.0) + eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.9.1)(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.52.0) hasown: 2.0.0 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -4287,6 +4455,12 @@ packages: get-intrinsic: 1.2.2 dev: false + /get-tsconfig@4.7.2: + resolution: {integrity: sha512-wuMsz4leaj5hbGgg4IvDU0bqJagpftG5l5cXIAvo8uZrqn0NJqwtfupTN00VnkQJPcIRrxYrm1Ue24btpCha2A==} + dependencies: + resolve-pkg-maps: 1.0.0 + dev: false + /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -4387,6 +4561,14 @@ packages: /graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + /hamburger-react@2.5.0(react@18.2.0): + resolution: {integrity: sha512-5GSXe+ucxTPJ0SkhIsPQ/PRDweZPIKya1lfahAuExx31SdheeUA4uOPfQIAirbKona8hvo79VDr5LJQzPXsdpw==} + peerDependencies: + react: ^16.8 || ^17 || ^18 + dependencies: + react: 18.2.0 + dev: false + /has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} dev: false @@ -5124,7 +5306,7 @@ packages: engines: {node: '>= 0.6'} dev: false - /next-auth@4.24.4(next@13.4.19)(react-dom@18.2.0)(react@18.2.0): + /next-auth@4.24.4(next@14.0.4)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-5DGffi+OpkbU62vPQIJ1z+hFnmow+ec5Qrn9m6eoglIO51m0DlrmLxBduZEwKAYDEg9k2joi1yelgmq1vqK3aQ==} peerDependencies: next: ^12.2.5 || ^13 || ^14 @@ -5139,7 +5321,7 @@ packages: '@panva/hkdf': 1.1.1 cookie: 0.5.0 jose: 4.15.4 - next: 13.4.19(react-dom@18.2.0)(react@18.2.0) + next: 14.0.4(react-dom@18.2.0)(react@18.2.0) oauth: 0.9.15 openid-client: 5.6.1 preact: 10.18.1 @@ -5189,6 +5371,46 @@ packages: - babel-plugin-macros dev: false + /next@14.0.4(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-qbwypnM7327SadwFtxXnQdGiKpkuhaRLE2uq62/nRul9cj9KhQ5LhHmlziTNqUidZotw/Q1I9OjirBROdUJNgA==} + engines: {node: '>=18.17.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + sass: + optional: true + dependencies: + '@next/env': 14.0.4 + '@swc/helpers': 0.5.2 + busboy: 1.6.0 + caniuse-lite: 1.0.30001577 + graceful-fs: 4.2.11 + postcss: 8.4.31 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + styled-jsx: 5.1.1(react@18.2.0) + watchpack: 2.4.0 + optionalDependencies: + '@next/swc-darwin-arm64': 14.0.4 + '@next/swc-darwin-x64': 14.0.4 + '@next/swc-linux-arm64-gnu': 14.0.4 + '@next/swc-linux-arm64-musl': 14.0.4 + '@next/swc-linux-x64-gnu': 14.0.4 + '@next/swc-linux-x64-musl': 14.0.4 + '@next/swc-win32-arm64-msvc': 14.0.4 + '@next/swc-win32-ia32-msvc': 14.0.4 + '@next/swc-win32-x64-msvc': 14.0.4 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + dev: false + /node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} dev: false @@ -5252,6 +5474,15 @@ packages: set-blocking: 2.0.0 dev: false + /nuqs@1.15.2(next@14.0.4): + resolution: {integrity: sha512-fCFVp+835a1sZAP1FJwmaTchaumsDm6gzuYbdYSeurigz6D8+DAtiuKLeXvGGowqwtgmKj3lvV7sJ8oNuMat4Q==} + peerDependencies: + next: '>=13.4 <14.0.2 || ^14.0.3' + dependencies: + mitt: 3.0.1 + next: 14.0.4(react-dom@18.2.0)(react@18.2.0) + dev: false + /oauth@0.9.15: resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} dev: false @@ -5682,6 +5913,17 @@ packages: unpipe: 1.0.0 dev: false + /react-animate-height@3.2.3(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-R6DSvr7ud07oeCixScyvXWEMJY/Mt2+GyOWC1KMaRc69gOBw+SsCg4TJmrp4rKUM1hyd6p+YKw90brjPH93Y2A==} + engines: {node: '>= 12.0.0'} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react-dom@18.2.0(react@18.2.0): resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -6045,6 +6287,10 @@ packages: engines: {node: '>=8'} dev: true + /resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + dev: false + /resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true @@ -6422,6 +6668,11 @@ packages: transitivePeerDependencies: - ts-node + /tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + dev: false + /tar@6.2.0: resolution: {integrity: sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==} engines: {node: '>=10'} diff --git a/tooling/eslint/nextjs.js b/tooling/eslint/nextjs.js index 278d8660..22c8e3dd 100644 --- a/tooling/eslint/nextjs.js +++ b/tooling/eslint/nextjs.js @@ -1,8 +1,8 @@ /** @type {import('eslint').Linter.Config} */ const config = { - extends: ["plugin:@next/next/recommended"], + extends: ['next'], rules: { - "@next/next/no-html-link-for-pages": "off", + '@next/next/no-html-link-for-pages': 'off', }, }; diff --git a/tooling/eslint/package.json b/tooling/eslint/package.json index b027f735..ef9c51a9 100644 --- a/tooling/eslint/package.json +++ b/tooling/eslint/package.json @@ -15,10 +15,11 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@next/eslint-plugin-next": "^13.4.19", + "@next/eslint-plugin-next": "^14.0.4", "@types/eslint": "^8.44.2", "@typescript-eslint/eslint-plugin": "^6.6.0", "@typescript-eslint/parser": "^6.6.0", + "eslint-config-next": "^14.0.4", "eslint-config-prettier": "^9.0.0", "eslint-config-turbo": "^1.10.13", "eslint-plugin-import": "^2.28.1",