From 88639dfe83a93d2314b011e267e2344aad615361 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Wed, 13 Aug 2025 20:27:54 -0700 Subject: [PATCH] New schema for pixels and links. --- .gitignore | 1 - .../migrations/11_add_segment/migration.sql | 14 --- .../12_update_report_parameter/migration.sql | 2 - .../migrations/13_add_revenue/migration.sql | 18 --- .../convert-utm-clid-columns.sql | 0 .../populate-revenue-table.sql | 0 package.json | 7 +- .../migrations/01_init/migration.sql | 0 .../migration.sql | 0 .../03_metric_performance_index/migration.sql | 0 .../migrations/04_team_redesign/migration.sql | 0 .../migrations/05_add_visit_id/migration.sql | 0 .../migrations/06_session_data/migration.sql | 0 .../migrations/07_add_tag/migration.sql | 0 .../migrations/08_add_utm_clid/migration.sql | 0 .../09_update_hostname_region/migration.sql | 0 .../10_add_distinct_id/migration.sql | 0 .../migrations/11_add_segment/migration.sql | 0 .../12_update_report_parameter/migration.sql | 0 .../migrations/13_add_revenue/migration.sql | 0 .../14_add_link_and_pixel/migration.sql | 67 +++++++++++ .../migrations/migration_lock.toml | 0 {db/postgresql => prisma}/schema.prisma | 73 ++++++++--- scripts/check-db.js | 44 +------ scripts/copy-db-files.js | 32 ----- src/app/(main)/SideNav.tsx | 6 +- src/app/(main)/boards/BoardsPage.tsx | 4 - src/app/(main)/links/LinkAddButton.tsx | 31 +++++ src/app/(main)/links/LinkDeleteButton.tsx | 55 +++++++++ src/app/(main)/links/LinkEditButton.tsx | 19 +++ src/app/(main)/links/LinkEditForm.tsx | 113 ++++++++++++++++++ src/app/(main)/links/LinksDataTable.tsx | 14 +++ src/app/(main)/links/LinksPage.tsx | 18 +-- src/app/(main)/links/LinksTable.tsx | 37 ++++++ src/app/(main)/pixels/PixelAddButton.tsx | 32 +++++ src/app/(main)/pixels/PixelAddForm.tsx | 62 ++++++++++ src/app/(main)/pixels/PixelsPage.tsx | 8 +- src/app/(main)/settings/teams/TeamsTable.tsx | 10 +- .../teams/[teamId]/TeamMembersPage.tsx | 8 +- .../settings/websites/WebsitesTable.tsx | 2 +- .../[websiteId]/settings/SettingsPage.tsx | 21 +--- src/app/actions/getConfig.ts | 9 +- src/app/api/links/[linkId]/route.ts | 84 +++++++++++++ src/app/api/links/route.ts | 64 ++++++++++ src/app/api/me/teams/route.ts | 6 +- src/app/api/me/websites/route.ts | 6 +- src/app/api/pixels/[pixelId]/route.ts | 0 src/app/api/pixels/route.ts | 0 src/app/api/teams/[teamId]/links/route.ts | 29 +++++ src/app/api/teams/[teamId]/pixels/pixels.ts | 29 +++++ src/app/api/teams/[teamId]/websites/route.ts | 6 +- src/app/api/users/[userId]/websites/route.ts | 6 +- src/components/hooks/index.ts | 8 +- src/components/hooks/queries/useLinkQuery.ts | 15 +++ src/components/hooks/queries/useLinksQuery.ts | 15 +++ src/components/hooks/queries/usePixelQuery.ts | 15 +++ .../hooks/queries/usePixelsQuery.ts | 19 +++ .../hooks/queries/useUpdateQuery.ts | 11 ++ src/components/hooks/useTeam.ts | 6 + src/components/input/PanelButton.tsx | 2 +- src/components/input/TeamsButton.tsx | 13 +- src/components/messages.ts | 5 + src/queries/index.ts | 2 + src/queries/prisma/link.ts | 71 +++++++++++ src/queries/prisma/pixel.ts | 69 +++++++++++ src/queries/prisma/team.ts | 10 +- src/queries/prisma/website.ts | 3 +- 67 files changed, 993 insertions(+), 208 deletions(-) delete mode 100644 db/mysql/migrations/11_add_segment/migration.sql delete mode 100644 db/mysql/migrations/12_update_report_parameter/migration.sql delete mode 100644 db/mysql/migrations/13_add_revenue/migration.sql rename {scripts => db/postgresql}/data-migrations/convert-utm-clid-columns.sql (100%) rename {scripts => db/postgresql}/data-migrations/populate-revenue-table.sql (100%) rename {db/postgresql => prisma}/migrations/01_init/migration.sql (100%) rename {db/postgresql => prisma}/migrations/02_report_schema_session_data/migration.sql (100%) rename {db/postgresql => prisma}/migrations/03_metric_performance_index/migration.sql (100%) rename {db/postgresql => prisma}/migrations/04_team_redesign/migration.sql (100%) rename {db/postgresql => prisma}/migrations/05_add_visit_id/migration.sql (100%) rename {db/postgresql => prisma}/migrations/06_session_data/migration.sql (100%) rename {db/postgresql => prisma}/migrations/07_add_tag/migration.sql (100%) rename {db/postgresql => prisma}/migrations/08_add_utm_clid/migration.sql (100%) rename {db/postgresql => prisma}/migrations/09_update_hostname_region/migration.sql (100%) rename {db/postgresql => prisma}/migrations/10_add_distinct_id/migration.sql (100%) rename {db/postgresql => prisma}/migrations/11_add_segment/migration.sql (100%) rename {db/postgresql => prisma}/migrations/12_update_report_parameter/migration.sql (100%) rename {db/postgresql => prisma}/migrations/13_add_revenue/migration.sql (100%) create mode 100644 prisma/migrations/14_add_link_and_pixel/migration.sql rename {db/postgresql => prisma}/migrations/migration_lock.toml (100%) rename {db/postgresql => prisma}/schema.prisma (83%) delete mode 100644 scripts/copy-db-files.js create mode 100644 src/app/(main)/links/LinkAddButton.tsx create mode 100644 src/app/(main)/links/LinkDeleteButton.tsx create mode 100644 src/app/(main)/links/LinkEditButton.tsx create mode 100644 src/app/(main)/links/LinkEditForm.tsx create mode 100644 src/app/(main)/links/LinksDataTable.tsx create mode 100644 src/app/(main)/links/LinksTable.tsx create mode 100644 src/app/(main)/pixels/PixelAddButton.tsx create mode 100644 src/app/(main)/pixels/PixelAddForm.tsx create mode 100644 src/app/api/links/[linkId]/route.ts create mode 100644 src/app/api/links/route.ts create mode 100644 src/app/api/pixels/[pixelId]/route.ts create mode 100644 src/app/api/pixels/route.ts create mode 100644 src/app/api/teams/[teamId]/links/route.ts create mode 100644 src/app/api/teams/[teamId]/pixels/pixels.ts create mode 100644 src/components/hooks/queries/useLinkQuery.ts create mode 100644 src/components/hooks/queries/useLinksQuery.ts create mode 100644 src/components/hooks/queries/usePixelQuery.ts create mode 100644 src/components/hooks/queries/usePixelsQuery.ts create mode 100644 src/components/hooks/queries/useUpdateQuery.ts create mode 100644 src/components/hooks/useTeam.ts create mode 100644 src/queries/prisma/link.ts create mode 100644 src/queries/prisma/pixel.ts diff --git a/.gitignore b/.gitignore index 95d9bf88..fef3571d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,6 @@ node_modules # next.js /.next/ /out/ -/prisma/ /src/generated/ # production diff --git a/db/mysql/migrations/11_add_segment/migration.sql b/db/mysql/migrations/11_add_segment/migration.sql deleted file mode 100644 index c79e916d..00000000 --- a/db/mysql/migrations/11_add_segment/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ --- CreateTable -CREATE TABLE `segment` ( - `segment_id` VARCHAR(36) NOT NULL, - `website_id` VARCHAR(36) NOT NULL, - `type` VARCHAR(200) NOT NULL, - `name` VARCHAR(200) NOT NULL, - `parameters` JSON NOT NULL, - `created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0), - `updated_at` TIMESTAMP(0) NULL, - - UNIQUE INDEX `segment_segment_id_key`(`segment_id`), - INDEX `segment_website_id_idx`(`website_id`), - PRIMARY KEY (`segment_id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/db/mysql/migrations/12_update_report_parameter/migration.sql b/db/mysql/migrations/12_update_report_parameter/migration.sql deleted file mode 100644 index f6a99c3f..00000000 --- a/db/mysql/migrations/12_update_report_parameter/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE `report` MODIFY `parameters` JSON NOT NULL; diff --git a/db/mysql/migrations/13_add_revenue/migration.sql b/db/mysql/migrations/13_add_revenue/migration.sql deleted file mode 100644 index 96115a33..00000000 --- a/db/mysql/migrations/13_add_revenue/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ --- CreateTable -CREATE TABLE `revenue` ( - `revenue_id` VARCHAR(36) NOT NULL, - `website_id` VARCHAR(36) NOT NULL, - `session_id` VARCHAR(36) NOT NULL, - `event_id` VARCHAR(36) NOT NULL, - `event_name` VARCHAR(50) NOT NULL, - `currency` VARCHAR(100) NOT NULL, - `revenue` DECIMAL(19, 4) NULL, - `created_at` TIMESTAMP(0) NULL DEFAULT CURRENT_TIMESTAMP(0), - - UNIQUE INDEX `revenue_revenue_id_key`(`revenue_id`), - INDEX `revenue_website_id_idx`(`website_id`), - INDEX `revenue_session_id_idx`(`session_id`), - INDEX `revenue_website_id_created_at_idx`(`website_id`, `created_at`), - INDEX `revenue_website_id_session_id_created_at_idx`(`website_id`, `session_id`, `created_at`), - PRIMARY KEY (`revenue_id`) -) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/scripts/data-migrations/convert-utm-clid-columns.sql b/db/postgresql/data-migrations/convert-utm-clid-columns.sql similarity index 100% rename from scripts/data-migrations/convert-utm-clid-columns.sql rename to db/postgresql/data-migrations/convert-utm-clid-columns.sql diff --git a/scripts/data-migrations/populate-revenue-table.sql b/db/postgresql/data-migrations/populate-revenue-table.sql similarity index 100% rename from scripts/data-migrations/populate-revenue-table.sql rename to db/postgresql/data-migrations/populate-revenue-table.sql diff --git a/package.json b/package.json index 425d48b1..9d062905 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,10 @@ "scripts": { "dev": "next dev", "dev-turbo": "next dev -p 3001 --turbopack", - "build": "npm-run-all check-env build-db check-db build-tracker build-geo build-app", - "build-turbo": "npm-run-all check-env build-db check-db build-tracker build-geo build-app-turbo", + "build": "npm-run-all check-env build-db-client check-db build-tracker build-geo build-app", + "build-turbo": "npm-run-all check-env build-db-client check-db build-tracker build-geo build-app-turbo", "start": "next start", - "build-docker": "npm-run-all build-db build-tracker build-geo build-app", + "build-docker": "npm-run-all build-db-client build-tracker build-geo build-app", "start-docker": "npm-run-all check-db update-tracker set-routes-manifest start-server", "start-env": "node scripts/start-env.js", "start-server": "node server.js", @@ -26,7 +26,6 @@ "build-components": "rollup -c rollup.components.config.js", "build-tracker": "rollup -c rollup.tracker.config.js", "build-prisma-client": "node scripts/build-prisma-client.js", - "build-db": "npm-run-all copy-db-files build-db-client", "build-lang": "npm-run-all format-lang compile-lang download-country-names download-language-names clean-lang", "build-geo": "node scripts/build-geo.js", "build-db-schema": "prisma db pull", diff --git a/db/postgresql/migrations/01_init/migration.sql b/prisma/migrations/01_init/migration.sql similarity index 100% rename from db/postgresql/migrations/01_init/migration.sql rename to prisma/migrations/01_init/migration.sql diff --git a/db/postgresql/migrations/02_report_schema_session_data/migration.sql b/prisma/migrations/02_report_schema_session_data/migration.sql similarity index 100% rename from db/postgresql/migrations/02_report_schema_session_data/migration.sql rename to prisma/migrations/02_report_schema_session_data/migration.sql diff --git a/db/postgresql/migrations/03_metric_performance_index/migration.sql b/prisma/migrations/03_metric_performance_index/migration.sql similarity index 100% rename from db/postgresql/migrations/03_metric_performance_index/migration.sql rename to prisma/migrations/03_metric_performance_index/migration.sql diff --git a/db/postgresql/migrations/04_team_redesign/migration.sql b/prisma/migrations/04_team_redesign/migration.sql similarity index 100% rename from db/postgresql/migrations/04_team_redesign/migration.sql rename to prisma/migrations/04_team_redesign/migration.sql diff --git a/db/postgresql/migrations/05_add_visit_id/migration.sql b/prisma/migrations/05_add_visit_id/migration.sql similarity index 100% rename from db/postgresql/migrations/05_add_visit_id/migration.sql rename to prisma/migrations/05_add_visit_id/migration.sql diff --git a/db/postgresql/migrations/06_session_data/migration.sql b/prisma/migrations/06_session_data/migration.sql similarity index 100% rename from db/postgresql/migrations/06_session_data/migration.sql rename to prisma/migrations/06_session_data/migration.sql diff --git a/db/postgresql/migrations/07_add_tag/migration.sql b/prisma/migrations/07_add_tag/migration.sql similarity index 100% rename from db/postgresql/migrations/07_add_tag/migration.sql rename to prisma/migrations/07_add_tag/migration.sql diff --git a/db/postgresql/migrations/08_add_utm_clid/migration.sql b/prisma/migrations/08_add_utm_clid/migration.sql similarity index 100% rename from db/postgresql/migrations/08_add_utm_clid/migration.sql rename to prisma/migrations/08_add_utm_clid/migration.sql diff --git a/db/postgresql/migrations/09_update_hostname_region/migration.sql b/prisma/migrations/09_update_hostname_region/migration.sql similarity index 100% rename from db/postgresql/migrations/09_update_hostname_region/migration.sql rename to prisma/migrations/09_update_hostname_region/migration.sql diff --git a/db/postgresql/migrations/10_add_distinct_id/migration.sql b/prisma/migrations/10_add_distinct_id/migration.sql similarity index 100% rename from db/postgresql/migrations/10_add_distinct_id/migration.sql rename to prisma/migrations/10_add_distinct_id/migration.sql diff --git a/db/postgresql/migrations/11_add_segment/migration.sql b/prisma/migrations/11_add_segment/migration.sql similarity index 100% rename from db/postgresql/migrations/11_add_segment/migration.sql rename to prisma/migrations/11_add_segment/migration.sql diff --git a/db/postgresql/migrations/12_update_report_parameter/migration.sql b/prisma/migrations/12_update_report_parameter/migration.sql similarity index 100% rename from db/postgresql/migrations/12_update_report_parameter/migration.sql rename to prisma/migrations/12_update_report_parameter/migration.sql diff --git a/db/postgresql/migrations/13_add_revenue/migration.sql b/prisma/migrations/13_add_revenue/migration.sql similarity index 100% rename from db/postgresql/migrations/13_add_revenue/migration.sql rename to prisma/migrations/13_add_revenue/migration.sql diff --git a/prisma/migrations/14_add_link_and_pixel/migration.sql b/prisma/migrations/14_add_link_and_pixel/migration.sql new file mode 100644 index 00000000..9c08fc61 --- /dev/null +++ b/prisma/migrations/14_add_link_and_pixel/migration.sql @@ -0,0 +1,67 @@ +-- AlterTable +ALTER TABLE "report" ALTER COLUMN "type" SET DATA TYPE VARCHAR(50); + +-- AlterTable +ALTER TABLE "revenue" ALTER COLUMN "currency" SET DATA TYPE VARCHAR(10); + +-- AlterTable +ALTER TABLE "segment" ALTER COLUMN "type" SET DATA TYPE VARCHAR(50); + +-- CreateTable +CREATE TABLE "link" ( + "link_id" UUID NOT NULL, + "name" VARCHAR(100) NOT NULL, + "url" VARCHAR(500) NOT NULL, + "slug" VARCHAR(100) NOT NULL, + "user_id" UUID, + "team_id" UUID, + "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + "deleted_at" TIMESTAMPTZ(6), + + CONSTRAINT "link_pkey" PRIMARY KEY ("link_id") +); + +-- CreateTable +CREATE TABLE "pixel" ( + "pixel_id" UUID NOT NULL, + "name" VARCHAR(100) NOT NULL, + "slug" VARCHAR(100) NOT NULL, + "user_id" UUID, + "team_id" UUID, + "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + "deleted_at" TIMESTAMPTZ(6), + + CONSTRAINT "pixel_pkey" PRIMARY KEY ("pixel_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "link_link_id_key" ON "link"("link_id"); + +-- CreateIndex +CREATE INDEX "link_slug_idx" ON "link"("slug"); + +-- CreateIndex +CREATE INDEX "link_user_id_idx" ON "link"("user_id"); + +-- CreateIndex +CREATE INDEX "link_team_id_idx" ON "link"("team_id"); + +-- CreateIndex +CREATE INDEX "link_created_at_idx" ON "link"("created_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "pixel_pixel_id_key" ON "pixel"("pixel_id"); + +-- CreateIndex +CREATE INDEX "pixel_slug_idx" ON "pixel"("slug"); + +-- CreateIndex +CREATE INDEX "pixel_user_id_idx" ON "pixel"("user_id"); + +-- CreateIndex +CREATE INDEX "pixel_team_id_idx" ON "pixel"("team_id"); + +-- CreateIndex +CREATE INDEX "pixel_created_at_idx" ON "pixel"("created_at"); diff --git a/db/postgresql/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml similarity index 100% rename from db/postgresql/migrations/migration_lock.toml rename to prisma/migrations/migration_lock.toml diff --git a/db/postgresql/schema.prisma b/prisma/schema.prisma similarity index 83% rename from db/postgresql/schema.prisma rename to prisma/schema.prisma index 3f5008b7..956164db 100644 --- a/db/postgresql/schema.prisma +++ b/prisma/schema.prisma @@ -20,10 +20,12 @@ model User { updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) - websiteUser Website[] @relation("user") - websiteCreateUser Website[] @relation("createUser") - teamUser TeamUser[] - report Report[] + websites Website[] @relation("user") + createdBy Website[] @relation("createUser") + links Link[] @relation("user") + pixels Pixel[] @relation("user") + teams TeamUser[] + reports Report[] @@map("user") } @@ -42,9 +44,9 @@ model Session { distinctId String? @map("distinct_id") @db.VarChar(50) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - websiteEvent WebsiteEvent[] - sessionData SessionData[] - revenue Revenue[] + websiteEvents WebsiteEvent[] + sessionData SessionData[] + revenue Revenue[] @@index([createdAt]) @@index([websiteId]) @@ -77,10 +79,10 @@ model Website { createUser User? @relation("createUser", fields: [createdBy], references: [id]) team Team? @relation(fields: [teamId], references: [id]) eventData EventData[] - report Report[] + reports Report[] revenue Revenue[] + segments Segment[] sessionData SessionData[] - segment Segment[] @@index([userId]) @@index([teamId]) @@ -192,8 +194,10 @@ model Team { updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) - website Website[] - teamUser TeamUser[] + websites Website[] + members TeamUser[] + links Link[] + pixels Pixel[] @@index([accessCode]) @@map("team") @@ -219,7 +223,7 @@ model Report { id String @id() @unique() @map("report_id") @db.Uuid userId String @map("user_id") @db.Uuid websiteId String @map("website_id") @db.Uuid - type String @db.VarChar(200) + type String @db.VarChar(50) name String @db.VarChar(200) description String @db.VarChar(500) parameters Json @@ -239,7 +243,7 @@ model Report { model Segment { id String @id() @unique() @map("segment_id") @db.Uuid websiteId String @map("website_id") @db.Uuid - type String @db.VarChar(200) + type String @db.VarChar(50) name String @db.VarChar(200) parameters Json createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) @@ -257,7 +261,7 @@ model Revenue { sessionId String @map("session_id") @db.Uuid eventId String @map("event_id") @db.Uuid eventName String @map("event_name") @db.VarChar(50) - currency String @db.VarChar(100) + currency String @db.VarChar(10) revenue Decimal? @db.Decimal(19, 4) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) @@ -270,3 +274,44 @@ model Revenue { @@index([websiteId, sessionId, createdAt]) @@map("revenue") } + +model Link { + id String @id() @unique() @map("link_id") @db.Uuid + name String @db.VarChar(100) + url String @db.VarChar(500) + slug String @db.VarChar(100) + userId String? @map("user_id") @db.Uuid + teamId String? @map("team_id") @db.Uuid + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + + user User? @relation("user", fields: [userId], references: [id]) + team Team? @relation(fields: [teamId], references: [id]) + + @@index([slug]) + @@index([userId]) + @@index([teamId]) + @@index([createdAt]) + @@map("link") +} + +model Pixel { + id String @id() @unique() @map("pixel_id") @db.Uuid + name String @db.VarChar(100) + slug String @db.VarChar(100) + userId String? @map("user_id") @db.Uuid + teamId String? @map("team_id") @db.Uuid + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) + + user User? @relation("user", fields: [userId], references: [id]) + team Team? @relation(fields: [teamId], references: [id]) + + @@index([slug]) + @@index([userId]) + @@index([teamId]) + @@index([createdAt]) + @@map("pixel") +} diff --git a/scripts/check-db.js b/scripts/check-db.js index f93135ab..f6781f1c 100644 --- a/scripts/check-db.js +++ b/scripts/check-db.js @@ -7,21 +7,13 @@ import semver from 'semver'; import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; +const MIN_VERSION = '9.4.0'; + if (process.env.SKIP_DB_CHECK) { console.log('Skipping database check.'); process.exit(0); } -function getDatabaseType(url = process.env.DATABASE_URL) { - const type = url && url.split(':')[0]; - - if (type === 'postgres') { - return 'postgresql'; - } - - return type; -} - const url = new URL(process.env.DATABASE_URL); const adapter = new PrismaPg( @@ -61,35 +53,15 @@ async function checkDatabaseVersion() { const query = await prisma.$queryRaw`select version() as version`; const version = semver.valid(semver.coerce(query[0].version)); - const databaseType = getDatabaseType(); - const minVersion = databaseType === 'postgresql' ? '9.4.0' : '5.7.0'; - - if (semver.lt(version, minVersion)) { + if (semver.lt(version, MIN_VERSION)) { throw new Error( - `Database version is not compatible. Please upgrade ${databaseType} version to ${minVersion} or greater`, + `Database version is not compatible. Please upgrade to ${MIN_VERSION} or greater.`, ); } success('Database version check successful.'); } -async function checkV1Tables() { - try { - // check for v1 migrations before v2 release date - const record = - await prisma.$queryRaw`select * from _prisma_migrations where started_at < '2023-04-17'`; - - if (record.length > 0) { - error( - 'Umami v1 tables detected. For how to upgrade from v1 to v2 go to https://umami.is/docs/migrate-v1-v2.', - ); - process.exit(1); - } - } catch { - // Ignore - } -} - async function applyMigration() { if (!process.env.SKIP_DB_MIGRATION) { console.log(execSync('prisma migrate deploy').toString()); @@ -100,13 +72,7 @@ async function applyMigration() { (async () => { let err = false; - for (const fn of [ - checkEnv, - checkConnection, - checkDatabaseVersion, - checkV1Tables, - applyMigration, - ]) { + for (const fn of [checkEnv, checkConnection, checkDatabaseVersion, applyMigration]) { try { await fn(); } catch (e) { diff --git a/scripts/copy-db-files.js b/scripts/copy-db-files.js deleted file mode 100644 index c787d179..00000000 --- a/scripts/copy-db-files.js +++ /dev/null @@ -1,32 +0,0 @@ -/* eslint-disable no-console */ -import 'dotenv/config'; -import fse from 'fs-extra'; -import path from 'node:path'; -import del from 'del'; - -function getDatabaseType(url = process.env.DATABASE_URL) { - const type = process.env.DATABASE_TYPE || (url && url.split(':')[0]); - - if (type === 'postgres') { - return 'postgresql'; - } - - return type; -} - -const databaseType = getDatabaseType(); - -if (!databaseType || !['mysql', 'postgresql'].includes(databaseType)) { - throw new Error('Missing or invalid database'); -} - -console.log(`Database type detected: ${databaseType}`); - -const src = path.resolve(process.cwd(), `db/${databaseType}`); -const dest = path.resolve(process.cwd(), 'prisma'); - -del.sync(dest); - -fse.copySync(src, dest); - -console.log(`Copied ${src} to ${dest}`); diff --git a/src/app/(main)/SideNav.tsx b/src/app/(main)/SideNav.tsx index 57dd0a8e..895c48cc 100644 --- a/src/app/(main)/SideNav.tsx +++ b/src/app/(main)/SideNav.tsx @@ -83,6 +83,9 @@ export function SideNav(props: SidebarProps) { {!isCollapsed && !hasNav && } + + + {links.map(({ id, path, label, icon }) => { return ( @@ -101,9 +104,6 @@ export function SideNav(props: SidebarProps) { ); })} - - - ); diff --git a/src/app/(main)/boards/BoardsPage.tsx b/src/app/(main)/boards/BoardsPage.tsx index 754d898b..991ef965 100644 --- a/src/app/(main)/boards/BoardsPage.tsx +++ b/src/app/(main)/boards/BoardsPage.tsx @@ -1,6 +1,5 @@ 'use client'; import { Column } from '@umami/react-zen'; -import Link from 'next/link'; import { PageHeader } from '@/components/common/PageHeader'; import { PageBody } from '@/components/common/PageBody'; import { BoardAddButton } from './BoardAddButton'; @@ -12,9 +11,6 @@ export function BoardsPage() { - - Board 1 - ); diff --git a/src/app/(main)/links/LinkAddButton.tsx b/src/app/(main)/links/LinkAddButton.tsx new file mode 100644 index 00000000..dc819ef3 --- /dev/null +++ b/src/app/(main)/links/LinkAddButton.tsx @@ -0,0 +1,31 @@ +import { useMessages, useModified } from '@/components/hooks'; +import { Button, Icon, Modal, Dialog, DialogTrigger, Text, useToast } from '@umami/react-zen'; +import { Plus } from '@/components/icons'; +import { LinkEditForm } from './LinkEditForm'; + +export function LinkAddButton({ teamId }: { teamId?: string }) { + const { formatMessage, labels, messages } = useMessages(); + const { toast } = useToast(); + const { touch } = useModified(); + + const handleSave = async () => { + toast(formatMessage(messages.saved)); + touch('links'); + }; + + return ( + + + + + {({ close }) => } + + + + ); +} diff --git a/src/app/(main)/links/LinkDeleteButton.tsx b/src/app/(main)/links/LinkDeleteButton.tsx new file mode 100644 index 00000000..015af001 --- /dev/null +++ b/src/app/(main)/links/LinkDeleteButton.tsx @@ -0,0 +1,55 @@ +import { Dialog } from '@umami/react-zen'; +import { ActionButton } from '@/components/input/ActionButton'; +import { Trash } from '@/components/icons'; +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { messages } from '@/components/messages'; +import { useApi, useMessages, useModified } from '@/components/hooks'; + +export function LinkDeleteButton({ + linkId, + websiteId, + name, + onSave, +}: { + linkId: string; + websiteId: string; + name: string; + onSave?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { del, useMutation } = useApi(); + const { mutate, isPending, error } = useMutation({ + mutationFn: () => del(`/websites/${websiteId}/links/${linkId}`), + }); + const { touch } = useModified(); + + const handleConfirm = (close: () => void) => { + mutate(null, { + onSuccess: () => { + touch('links'); + onSave?.(); + close(); + }, + }); + }; + + return ( + }> + + {({ close }) => ( + + )} + + + ); +} diff --git a/src/app/(main)/links/LinkEditButton.tsx b/src/app/(main)/links/LinkEditButton.tsx new file mode 100644 index 00000000..e7c0bfbe --- /dev/null +++ b/src/app/(main)/links/LinkEditButton.tsx @@ -0,0 +1,19 @@ +import { ActionButton } from '@/components/input/ActionButton'; +import { Edit } from '@/components/icons'; +import { Dialog } from '@umami/react-zen'; +import { LinkEditForm } from './LinkEditForm'; +import { useMessages } from '@/components/hooks'; + +export function LinkEditButton({ linkId }: { linkId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + }> + + {({ close }) => { + return ; + }} + + + ); +} diff --git a/src/app/(main)/links/LinkEditForm.tsx b/src/app/(main)/links/LinkEditForm.tsx new file mode 100644 index 00000000..f46081ee --- /dev/null +++ b/src/app/(main)/links/LinkEditForm.tsx @@ -0,0 +1,113 @@ +import { + Form, + FormField, + FormSubmitButton, + Row, + TextField, + Button, + Text, + Label, + Column, + Icon, + Loading, +} from '@umami/react-zen'; +import { useConfig, useLinkQuery } from '@/components/hooks'; +import { useMessages } from '@/components/hooks'; +import { Refresh } from '@/components/icons'; +import { getRandomChars } from '@/lib/crypto'; +import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery'; + +const generateId = () => getRandomChars(9); + +export function LinkEditForm({ + linkId, + teamId, + onSave, + onClose, +}: { + linkId?: string; + teamId?: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { mutate, error, isPending } = useUpdateQuery('/links', { id: linkId, teamId }); + const { linkDomain } = useConfig(); + const { data, isLoading } = useLinkQuery(linkId); + + const handleSubmit = async (data: any) => { + mutate(data, { + onSuccess: async () => { + onSave?.(); + onClose?.(); + }, + }); + }; + + if (linkId && !isLoading) { + return ; + } + + return ( +
+ {({ setValue }) => { + return ( + <> + + + + + + + + + + + + {linkDomain || window.location.origin}/ + + + + + + + + + {onClose && ( + + )} + {formatMessage(labels.save)} + + + ); + }} +
+ ); +} diff --git a/src/app/(main)/links/LinksDataTable.tsx b/src/app/(main)/links/LinksDataTable.tsx new file mode 100644 index 00000000..043cd768 --- /dev/null +++ b/src/app/(main)/links/LinksDataTable.tsx @@ -0,0 +1,14 @@ +import { useLinksQuery, useNavigation } from '@/components/hooks'; +import { LinksTable } from './LinksTable'; +import { DataGrid } from '@/components/common/DataGrid'; + +export function LinksDataTable() { + const { teamId } = useNavigation(); + const query = useLinksQuery({ teamId }); + + return ( + + {({ data }) => } + + ); +} diff --git a/src/app/(main)/links/LinksPage.tsx b/src/app/(main)/links/LinksPage.tsx index 1b680dd8..766974f5 100644 --- a/src/app/(main)/links/LinksPage.tsx +++ b/src/app/(main)/links/LinksPage.tsx @@ -2,22 +2,24 @@ import { PageBody } from '@/components/common/PageBody'; import { Column } from '@umami/react-zen'; import { PageHeader } from '@/components/common/PageHeader'; -import { BoardAddButton } from '@/app/(main)/boards/BoardAddButton'; -import Link from 'next/link'; -import { useMessages } from '@/components/hooks'; +import { LinkAddButton } from './LinkAddButton'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { LinksDataTable } from '@/app/(main)/links/LinksDataTable'; +import { Panel } from '@/components/common/Panel'; export function LinksPage() { const { formatMessage, labels } = useMessages(); + const { teamId } = useNavigation(); return ( - + - + - - Board 1 - + + + ); diff --git a/src/app/(main)/links/LinksTable.tsx b/src/app/(main)/links/LinksTable.tsx new file mode 100644 index 00000000..e06e2537 --- /dev/null +++ b/src/app/(main)/links/LinksTable.tsx @@ -0,0 +1,37 @@ +import { DataTable, DataColumn, Row } from '@umami/react-zen'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { Empty } from '@/components/common/Empty'; +import { DateDistance } from '@/components/common/DateDistance'; +import { LinkEditButton } from './LinkEditButton'; +import { LinkDeleteButton } from './LinkDeleteButton'; + +export function LinksTable({ data = [] }) { + const { formatMessage, labels } = useMessages(); + const { websiteId } = useNavigation(); + + if (data.length === 0) { + return ; + } + + return ( + + + + + {(row: any) => } + + + {(row: any) => { + const { id, name } = row; + + return ( + + + + + ); + }} + + + ); +} diff --git a/src/app/(main)/pixels/PixelAddButton.tsx b/src/app/(main)/pixels/PixelAddButton.tsx new file mode 100644 index 00000000..a74a455e --- /dev/null +++ b/src/app/(main)/pixels/PixelAddButton.tsx @@ -0,0 +1,32 @@ +import { useMessages, useModified, useNavigation } from '@/components/hooks'; +import { Button, Icon, Modal, Dialog, DialogTrigger, Text, useToast } from '@umami/react-zen'; +import { Plus } from '@/components/icons'; +import { PixelAddForm } from './PixelAddForm'; + +export function PixelAddButton() { + const { formatMessage, labels, messages } = useMessages(); + const { toast } = useToast(); + const { touch } = useModified(); + const { teamId } = useNavigation(); + + const handleSave = async () => { + toast(formatMessage(messages.saved)); + touch('boards'); + }; + + return ( + + + + + {({ close }) => } + + + + ); +} diff --git a/src/app/(main)/pixels/PixelAddForm.tsx b/src/app/(main)/pixels/PixelAddForm.tsx new file mode 100644 index 00000000..56ce44b1 --- /dev/null +++ b/src/app/(main)/pixels/PixelAddForm.tsx @@ -0,0 +1,62 @@ +import { Form, FormField, FormSubmitButton, Row, TextField, Button } from '@umami/react-zen'; +import { useApi } from '@/components/hooks'; +import { DOMAIN_REGEX } from '@/lib/constants'; +import { useMessages } from '@/components/hooks'; + +export function PixelAddForm({ + teamId, + onSave, + onClose, +}: { + teamId?: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels, messages } = useMessages(); + const { post, useMutation } = useApi(); + const { mutate, error, isPending } = useMutation({ + mutationFn: (data: any) => post('/pixels', { ...data, teamId }), + }); + + const handleSubmit = async (data: any) => { + mutate(data, { + onSuccess: async () => { + onSave?.(); + onClose?.(); + }, + }); + }; + + return ( +
+ + + + + + + + + {onClose && ( + + )} + + {formatMessage(labels.save)} + + +
+ ); +} diff --git a/src/app/(main)/pixels/PixelsPage.tsx b/src/app/(main)/pixels/PixelsPage.tsx index fa686620..a7a9e13a 100644 --- a/src/app/(main)/pixels/PixelsPage.tsx +++ b/src/app/(main)/pixels/PixelsPage.tsx @@ -2,8 +2,7 @@ import { PageBody } from '@/components/common/PageBody'; import { Column } from '@umami/react-zen'; import { PageHeader } from '@/components/common/PageHeader'; -import { BoardAddButton } from '@/app/(main)/boards/BoardAddButton'; -import Link from 'next/link'; +import { PixelAddButton } from './PixelAddButton'; import { useMessages } from '@/components/hooks'; export function PixelsPage() { @@ -13,11 +12,8 @@ export function PixelsPage() { - + - - Board 1 - ); diff --git a/src/app/(main)/settings/teams/TeamsTable.tsx b/src/app/(main)/settings/teams/TeamsTable.tsx index a2599251..7d6e890a 100644 --- a/src/app/(main)/settings/teams/TeamsTable.tsx +++ b/src/app/(main)/settings/teams/TeamsTable.tsx @@ -21,13 +21,13 @@ export function TeamsTable({ {(row: any) => {row.name}} - {(row: any) => row.teamUser.find(({ role }) => role === ROLES.teamOwner)?.user?.username} + {(row: any) => row.users.find(({ role }) => role === ROLES.teamOwner)?.user?.username} - - {(row: any) => row._count.website} + + {(row: any) => row._count.websites} - - {(row: any) => row._count.teamUser} + + {(row: any) => row._count.users} {showActions ? ( diff --git a/src/app/(main)/settings/teams/[teamId]/TeamMembersPage.tsx b/src/app/(main)/settings/teams/[teamId]/TeamMembersPage.tsx index 75396bcf..bf245c49 100644 --- a/src/app/(main)/settings/teams/[teamId]/TeamMembersPage.tsx +++ b/src/app/(main)/settings/teams/[teamId]/TeamMembersPage.tsx @@ -1,19 +1,17 @@ 'use client'; -import { TeamContext } from '@/app/(main)/teams/[teamId]/TeamProvider'; import { TeamMembersDataTable } from './TeamMembersDataTable'; import { SectionHeader } from '@/components/common/SectionHeader'; -import { useLoginQuery, useMessages } from '@/components/hooks'; +import { useLoginQuery, useMessages, useTeam } from '@/components/hooks'; import { ROLES } from '@/lib/constants'; -import { useContext } from 'react'; import { Column } from '@umami/react-zen'; export function TeamMembersPage({ teamId }: { teamId: string }) { - const team = useContext(TeamContext); + const team = useTeam(); const { user } = useLoginQuery(); const { formatMessage, labels } = useMessages(); const canEdit = - team?.teamUser?.find( + team?.members?.find( ({ userId, role }) => (role === ROLES.teamOwner || role === ROLES.teamManager) && userId === user.id, ) && user.role !== ROLES.viewOnly; diff --git a/src/app/(main)/settings/websites/WebsitesTable.tsx b/src/app/(main)/settings/websites/WebsitesTable.tsx index 2d285c75..c2077eb1 100644 --- a/src/app/(main)/settings/websites/WebsitesTable.tsx +++ b/src/app/(main)/settings/websites/WebsitesTable.tsx @@ -57,7 +57,7 @@ export function WebsitesTable({ )} {allowEdit && ( - + diff --git a/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx b/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx index 84bd0f4e..468f250d 100644 --- a/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx @@ -1,25 +1,6 @@ 'use client'; -import { Column, Icon, Row, Text } from '@umami/react-zen'; import { WebsiteSettingsPage } from '@/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage'; -import { LinkButton } from '@/components/common/LinkButton'; -import { Arrow } from '@/components/icons'; -import { useNavigation } from '@/components/hooks'; export function SettingsPage({ websiteId }: { websiteId: string }) { - const { pathname } = useNavigation(); - return ( - - - - - - - - Back - - - - - - ); + return ; } diff --git a/src/app/actions/getConfig.ts b/src/app/actions/getConfig.ts index cc99dd9f..44823379 100644 --- a/src/app/actions/getConfig.ts +++ b/src/app/actions/getConfig.ts @@ -1,11 +1,13 @@ 'use server'; export type Config = { - faviconUrl: string | undefined; + faviconUrl?: string; privateMode: boolean; telemetryDisabled: boolean; - trackerScriptName: string | undefined; + trackerScriptName?: string; updatesDisabled: boolean; + linkDomain?: string; + pixelDomain?: string; }; export async function getConfig(): Promise { @@ -15,6 +17,7 @@ export async function getConfig(): Promise { telemetryDisabled: !!process.env.DISABLE_TELEMETRY, trackerScriptName: process.env.TRACKER_SCRIPT_NAME, updatesDisabled: !!process.env.DISABLE_UPDATES, - loginDisabled: !!process.env.DISABLE_LOGIN, + linkDomain: process.env.LINK_DOMAIN, + pixelDomain: process.env.PIXEL_DOMAIN, }; } diff --git a/src/app/api/links/[linkId]/route.ts b/src/app/api/links/[linkId]/route.ts new file mode 100644 index 00000000..4f8763b5 --- /dev/null +++ b/src/app/api/links/[linkId]/route.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; +import { canUpdateWebsite, canDeleteWebsite, canViewWebsite } from '@/lib/auth'; +import { SHARE_ID_REGEX } from '@/lib/constants'; +import { parseRequest } from '@/lib/request'; +import { ok, json, unauthorized, serverError } from '@/lib/response'; +import { deleteWebsite, getWebsite, updateWebsite } from '@/queries'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const website = await getWebsite(websiteId); + + return json(website); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + name: z.string().optional(), + domain: z.string().optional(), + shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { name, domain, shareId } = body; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + try { + const website = await updateWebsite(websiteId, { name, domain, shareId }); + + return Response.json(website); + } catch (e: any) { + if (e.message.includes('Unique constraint') && e.message.includes('share_id')) { + return serverError(new Error('That share ID is already taken.')); + } + + return serverError(e); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canDeleteWebsite(auth, websiteId))) { + return unauthorized(); + } + + await deleteWebsite(websiteId); + + return ok(); +} diff --git a/src/app/api/links/route.ts b/src/app/api/links/route.ts new file mode 100644 index 00000000..8dfd7655 --- /dev/null +++ b/src/app/api/links/route.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; +import { canCreateTeamWebsite, canCreateWebsite } from '@/lib/auth'; +import { json, unauthorized } from '@/lib/response'; +import { uuid } from '@/lib/crypto'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { createLink, getUserLinks } from '@/queries'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const filters = await getQueryFilters(query); + + const result = await getUserLinks(auth.user.id, filters); + + return json(result); +} + +export async function POST(request: Request) { + const schema = z.object({ + name: z.string().max(100), + url: z.string().max(500), + slug: z.string().max(100), + teamId: z.string().nullable().optional(), + id: z.string().uuid().nullable().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { id, name, url, slug, teamId } = body; + + if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) { + return unauthorized(); + } + + const data: any = { + id: id ?? uuid(), + name, + url, + slug, + teamId, + }; + + if (!teamId) { + data.userId = auth.user.id; + } + + const result = await createLink(data); + + return json(result); +} diff --git a/src/app/api/me/teams/route.ts b/src/app/api/me/teams/route.ts index 2ea6575e..8278a302 100644 --- a/src/app/api/me/teams/route.ts +++ b/src/app/api/me/teams/route.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { pagingParams } from '@/lib/schema'; import { getUserTeams } from '@/queries'; import { json } from '@/lib/response'; -import { parseRequest } from '@/lib/request'; +import { getQueryFilters, parseRequest } from '@/lib/request'; export async function GET(request: Request) { const schema = z.object({ @@ -15,7 +15,9 @@ export async function GET(request: Request) { return error(); } - const teams = await getUserTeams(auth.user.id, query); + const filters = await getQueryFilters(query); + + const teams = await getUserTeams(auth.user.id, filters); return json(teams); } diff --git a/src/app/api/me/websites/route.ts b/src/app/api/me/websites/route.ts index a8df856a..41a8756d 100644 --- a/src/app/api/me/websites/route.ts +++ b/src/app/api/me/websites/route.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { pagingParams } from '@/lib/schema'; import { getUserWebsites } from '@/queries'; import { json } from '@/lib/response'; -import { parseRequest } from '@/lib/request'; +import { parseRequest, getQueryFilters } from '@/lib/request'; export async function GET(request: Request) { const schema = z.object({ @@ -15,7 +15,9 @@ export async function GET(request: Request) { return error(); } - const websites = await getUserWebsites(auth.user.id, query); + const filters = await getQueryFilters(query); + + const websites = await getUserWebsites(auth.user.id, filters); return json(websites); } diff --git a/src/app/api/pixels/[pixelId]/route.ts b/src/app/api/pixels/[pixelId]/route.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/app/api/pixels/route.ts b/src/app/api/pixels/route.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/app/api/teams/[teamId]/links/route.ts b/src/app/api/teams/[teamId]/links/route.ts new file mode 100644 index 00000000..a0b0dd2c --- /dev/null +++ b/src/app/api/teams/[teamId]/links/route.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { unauthorized, json } from '@/lib/response'; +import { canViewTeam } from '@/lib/auth'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { getTeamLinks } from '@/queries'; + +export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + const { teamId } = await params; + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewTeam(auth, teamId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query); + + const websites = await getTeamLinks(teamId, filters); + + return json(websites); +} diff --git a/src/app/api/teams/[teamId]/pixels/pixels.ts b/src/app/api/teams/[teamId]/pixels/pixels.ts new file mode 100644 index 00000000..872b4a79 --- /dev/null +++ b/src/app/api/teams/[teamId]/pixels/pixels.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { unauthorized, json } from '@/lib/response'; +import { canViewTeam } from '@/lib/auth'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { getTeamPixels } from '@/queries'; + +export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + const { teamId } = await params; + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewTeam(auth, teamId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query); + + const websites = await getTeamPixels(teamId, filters); + + return json(websites); +} diff --git a/src/app/api/teams/[teamId]/websites/route.ts b/src/app/api/teams/[teamId]/websites/route.ts index f60aa270..f3acde38 100644 --- a/src/app/api/teams/[teamId]/websites/route.ts +++ b/src/app/api/teams/[teamId]/websites/route.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { unauthorized, json } from '@/lib/response'; import { canViewTeam } from '@/lib/auth'; -import { parseRequest } from '@/lib/request'; +import { getQueryFilters, parseRequest } from '@/lib/request'; import { pagingParams, searchParams } from '@/lib/schema'; import { getTeamWebsites } from '@/queries'; @@ -21,7 +21,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ team return unauthorized(); } - const websites = await getTeamWebsites(teamId, query); + const filters = await getQueryFilters(query); + + const websites = await getTeamWebsites(teamId, filters); return json(websites); } diff --git a/src/app/api/users/[userId]/websites/route.ts b/src/app/api/users/[userId]/websites/route.ts index 8634fd4b..0c10682d 100644 --- a/src/app/api/users/[userId]/websites/route.ts +++ b/src/app/api/users/[userId]/websites/route.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { unauthorized, json } from '@/lib/response'; import { getUserWebsites } from '@/queries/prisma/website'; import { pagingParams, searchParams } from '@/lib/schema'; -import { parseRequest } from '@/lib/request'; +import { getQueryFilters, parseRequest } from '@/lib/request'; export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { const schema = z.object({ @@ -22,7 +22,9 @@ export async function GET(request: Request, { params }: { params: Promise<{ user return unauthorized(); } - const websites = await getUserWebsites(userId, query); + const filters = await getQueryFilters(query); + + const websites = await getUserWebsites(userId, filters); return json(websites); } diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index ddd66d57..bead3b42 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -4,7 +4,11 @@ export * from './queries/useEventDataQuery'; export * from './queries/useEventDataEventsQuery'; export * from './queries/useEventDataPropertiesQuery'; export * from './queries/useEventDataValuesQuery'; +export * from './queries/useLinkQuery'; +export * from './queries/useLinksQuery'; export * from './queries/useLoginQuery'; +export * from './queries/usePixelQuery'; +export * from './queries/usePixelsQuery'; export * from './queries/useRealtimeQuery'; export * from './queries/useResultQuery'; export * from './queries/useReportQuery'; @@ -49,8 +53,10 @@ export * from './useLanguageNames'; export * from './useLocale'; export * from './useMessages'; export * from './useModified'; +export * from './useNavigation'; export * from './usePagedQuery'; export * from './useRegionNames'; export * from './useSticky'; -export * from './useNavigation'; +export * from './useTeam'; export * from './useTimezone'; +export * from './useWebsite'; diff --git a/src/components/hooks/queries/useLinkQuery.ts b/src/components/hooks/queries/useLinkQuery.ts new file mode 100644 index 00000000..b1219e6f --- /dev/null +++ b/src/components/hooks/queries/useLinkQuery.ts @@ -0,0 +1,15 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useLinkQuery(linkId: string) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`link:${linkId}`); + + return useQuery({ + queryKey: ['link', { linkId, modified }], + queryFn: () => { + return get(`/link/${linkId}`); + }, + enabled: !!linkId, + }); +} diff --git a/src/components/hooks/queries/useLinksQuery.ts b/src/components/hooks/queries/useLinksQuery.ts new file mode 100644 index 00000000..447afb7f --- /dev/null +++ b/src/components/hooks/queries/useLinksQuery.ts @@ -0,0 +1,15 @@ +import { useApi } from '../useApi'; +import { usePagedQuery } from '../usePagedQuery'; +import { useModified } from '../useModified'; +import { ReactQueryOptions } from '@/lib/types'; + +export function useLinksQuery({ teamId }: { teamId?: string }, options?: ReactQueryOptions) { + const { modified } = useModified('links'); + const { get } = useApi(); + + return usePagedQuery({ + queryKey: ['links', { teamId, modified }], + queryFn: async () => get(teamId ? `/teams/${teamId}/links` : '/links'), + ...options, + }); +} diff --git a/src/components/hooks/queries/usePixelQuery.ts b/src/components/hooks/queries/usePixelQuery.ts new file mode 100644 index 00000000..0f207333 --- /dev/null +++ b/src/components/hooks/queries/usePixelQuery.ts @@ -0,0 +1,15 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function usePixelQuery(pixelId: string) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`pixel:${pixelId}`); + + return useQuery({ + queryKey: ['pixel', { pixelId, modified }], + queryFn: () => { + return get(`/pixel/${pixelId}`); + }, + enabled: !!pixelId, + }); +} diff --git a/src/components/hooks/queries/usePixelsQuery.ts b/src/components/hooks/queries/usePixelsQuery.ts new file mode 100644 index 00000000..f1451873 --- /dev/null +++ b/src/components/hooks/queries/usePixelsQuery.ts @@ -0,0 +1,19 @@ +import { useApi } from '../useApi'; +import { usePagedQuery } from '../usePagedQuery'; +import { useModified } from '../useModified'; +import { ReactQueryOptions } from '@/lib/types'; + +export function usePixelsQuery( + { websiteId, type }: { websiteId: string; type?: string }, + options?: ReactQueryOptions, +) { + const { modified } = useModified(`pixels:${type}`); + const { get } = useApi(); + + return usePagedQuery({ + queryKey: ['pixels', { websiteId, type, modified }], + queryFn: async () => get('/pixels', { websiteId, type }), + enabled: !!websiteId && !!type, + ...options, + }); +} diff --git a/src/components/hooks/queries/useUpdateQuery.ts b/src/components/hooks/queries/useUpdateQuery.ts new file mode 100644 index 00000000..f5cc7cec --- /dev/null +++ b/src/components/hooks/queries/useUpdateQuery.ts @@ -0,0 +1,11 @@ +import { useApi, useModified } from '@/components/hooks'; + +export function useUpdateQuery(path: string, params?: Record) { + const { post, useMutation } = useApi(); + const { mutate, isPending, error } = useMutation({ + mutationFn: (data: Record) => post(path, { ...data, ...params }), + }); + const { touch } = useModified(); + + return { mutate, isPending, error, touch }; +} diff --git a/src/components/hooks/useTeam.ts b/src/components/hooks/useTeam.ts new file mode 100644 index 00000000..979b57f8 --- /dev/null +++ b/src/components/hooks/useTeam.ts @@ -0,0 +1,6 @@ +import { TeamContext } from '@/app/(main)/settings/teams/[teamId]/TeamProvider'; +import { useContext } from 'react'; + +export function useTeam() { + return useContext(TeamContext); +} diff --git a/src/components/input/PanelButton.tsx b/src/components/input/PanelButton.tsx index 58a69195..2f768741 100644 --- a/src/components/input/PanelButton.tsx +++ b/src/components/input/PanelButton.tsx @@ -11,7 +11,7 @@ export function PanelButton(props: ButtonProps) { {...props} style={{ padding: 0 }} > - + diff --git a/src/components/input/TeamsButton.tsx b/src/components/input/TeamsButton.tsx index c9fd9dd0..2087f690 100644 --- a/src/components/input/TeamsButton.tsx +++ b/src/components/input/TeamsButton.tsx @@ -15,7 +15,7 @@ import { Pressable, } from '@umami/react-zen'; import { useLoginQuery, useMessages, useUserTeamsQuery, useNavigation } from '@/components/hooks'; -import { Chevron, User, Users, LogOut } from '@/components/icons'; +import { Chevron, User, Users } from '@/components/icons'; export function TeamsButton({ showText = true }: { showText?: boolean }) { const { user } = useLoginQuery(); @@ -79,17 +79,6 @@ export function TeamsButton({ showText = true }: { showText?: boolean }) { ))} - - - - - - - - {formatMessage(labels.logout)} - - - diff --git a/src/components/messages.ts b/src/components/messages.ts index 92409f25..d034a3d5 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -325,9 +325,13 @@ export const labels = defineMessages({ other: { id: 'label.other', defaultMessage: 'Other' }, boards: { id: 'label.boards', defaultMessage: 'Boards' }, apply: { id: 'label.apply', defaultMessage: 'Apply' }, + link: { id: 'label.link', defaultMessage: 'Link' }, links: { id: 'label.links', defaultMessage: 'Links' }, + pixel: { id: 'label.pixel', defaultMessage: 'Pixel' }, pixels: { id: 'label.pixels', defaultMessage: 'Pixels' }, addBoard: { id: 'label.add-board', defaultMessage: 'Add board' }, + addLink: { id: 'label.add-link', defaultMessage: 'Add link' }, + addPixel: { id: 'label.add-pixel', defaultMessage: 'Add pixel' }, maximize: { id: 'label.maximize', defaultMessage: 'Maximize' }, remaining: { id: 'label.remaining', defaultMessage: 'Remaining' }, conversion: { id: 'label.conversion', defaultMessage: 'Conversion' }, @@ -347,6 +351,7 @@ export const labels = defineMessages({ saveSegment: { id: 'label.save-segment', defaultMessage: 'Save as segment' }, saveCohort: { id: 'label.save-cohort', defaultMessage: 'Save as cohort' }, analysis: { id: 'label.analysis', defaultMessage: 'Analysis' }, + destinationUrl: { id: 'label.destination-url', defaultMessage: 'Destination URL' }, }); export const messages = defineMessages({ diff --git a/src/queries/index.ts b/src/queries/index.ts index 707dc874..1bb7703a 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -1,3 +1,5 @@ +export * from '@/queries/prisma/link'; +export * from '@/queries/prisma/pixel'; export * from '@/queries/prisma/report'; export * from '@/queries/prisma/segment'; export * from '@/queries/prisma/team'; diff --git a/src/queries/prisma/link.ts b/src/queries/prisma/link.ts new file mode 100644 index 00000000..0d887900 --- /dev/null +++ b/src/queries/prisma/link.ts @@ -0,0 +1,71 @@ +import { Prisma, Link } from '@prisma/client'; +import prisma from '@/lib/prisma'; +import { PageResult, QueryFilters } from '@/lib/types'; + +async function findLink(criteria: Prisma.LinkFindUniqueArgs): Promise { + return prisma.client.link.findUnique(criteria); +} + +export async function getLink(linkId: string): Promise { + return findLink({ + where: { + id: linkId, + }, + }); +} + +export async function getLinks( + criteria: Prisma.LinkFindManyArgs, + filters: QueryFilters = {}, +): Promise> { + const { search } = filters; + + const where: Prisma.LinkWhereInput = { + ...criteria.where, + ...prisma.getSearchParameters(search, [{ name: 'contains' }]), + }; + + return prisma.pagedQuery('link', { ...criteria, where }, filters); +} + +export async function getUserLinks( + userId: string, + filters?: QueryFilters, +): Promise> { + return getLinks( + { + where: { + userId, + deletedAt: null, + }, + }, + filters, + ); +} + +export async function getTeamLinks( + teamId: string, + filters?: QueryFilters, +): Promise> { + return getLinks( + { + where: { + teamId, + deletedAt: null, + }, + }, + filters, + ); +} + +export async function createLink(data: Prisma.LinkUncheckedCreateInput): Promise { + return prisma.client.link.create({ data }); +} + +export async function updateLink(linkId: string, data: any): Promise { + return prisma.client.link.update({ where: { id: linkId }, data }); +} + +export async function deleteLink(linkId: string): Promise { + return prisma.client.link.delete({ where: { id: linkId } }); +} diff --git a/src/queries/prisma/pixel.ts b/src/queries/prisma/pixel.ts new file mode 100644 index 00000000..b71b646a --- /dev/null +++ b/src/queries/prisma/pixel.ts @@ -0,0 +1,69 @@ +import { Prisma, Pixel } from '@prisma/client'; +import prisma from '@/lib/prisma'; +import { PageResult, QueryFilters } from '@/lib/types'; + +async function findPixel(criteria: Prisma.PixelFindUniqueArgs): Promise { + return prisma.client.pixel.findUnique(criteria); +} + +export async function getPixel(pixelId: string): Promise { + return findPixel({ + where: { + id: pixelId, + }, + }); +} + +export async function getPixels( + criteria: Prisma.PixelFindManyArgs, + filters: QueryFilters = {}, +): Promise> { + const { search } = filters; + + const where: Prisma.PixelWhereInput = { + ...criteria.where, + ...prisma.getSearchParameters(search, [{ name: 'contains' }]), + }; + + return prisma.pagedQuery('pixel', { ...criteria, where }, filters); +} + +export async function getUserPixels( + userId: string, + filters?: QueryFilters, +): Promise> { + return getPixels( + { + where: { + userId, + }, + }, + filters, + ); +} + +export async function getTeamPixels( + teamId: string, + filters?: QueryFilters, +): Promise> { + return getPixels( + { + where: { + teamId, + }, + }, + filters, + ); +} + +export async function createPixel(data: Prisma.PixelUncheckedCreateInput): Promise { + return prisma.client.pixel.create({ data }); +} + +export async function updatePixel(pixelId: string, data: any): Promise { + return prisma.client.pixel.update({ where: { id: pixelId }, data }); +} + +export async function deletePixel(pixelId: string): Promise { + return prisma.client.pixel.delete({ where: { id: pixelId } }); +} diff --git a/src/queries/prisma/team.ts b/src/queries/prisma/team.ts index edc082ba..086d8e27 100644 --- a/src/queries/prisma/team.ts +++ b/src/queries/prisma/team.ts @@ -16,7 +16,7 @@ export async function getTeam(teamId: string, options: { includeMembers?: boolea where: { id: teamId, }, - ...(includeMembers && { include: { teamUser: true } }), + ...(includeMembers && { include: { members: true } }), }); } @@ -47,12 +47,12 @@ export async function getUserTeams(userId: string, filters: QueryFilters) { { where: { deletedAt: null, - teamUser: { + members: { some: { userId }, }, }, include: { - teamUser: { + members: { include: { user: { select: { @@ -64,10 +64,10 @@ export async function getUserTeams(userId: string, filters: QueryFilters) { }, _count: { select: { - website: { + websites: { where: { deletedAt: null }, }, - teamUser: { + members: { where: { user: { deletedAt: null }, }, diff --git a/src/queries/prisma/website.ts b/src/queries/prisma/website.ts index 0a7492ad..118ec771 100644 --- a/src/queries/prisma/website.ts +++ b/src/queries/prisma/website.ts @@ -2,7 +2,6 @@ import { Prisma, Website } from '@prisma/client'; import redis from '@/lib/redis'; import prisma from '@/lib/prisma'; import { PageResult, QueryFilters } from '@/lib/types'; -import WebsiteFindManyArgs = Prisma.WebsiteFindManyArgs; import { ROLES } from '@/lib/constants'; async function findWebsite(criteria: Prisma.WebsiteFindUniqueArgs): Promise { @@ -27,7 +26,7 @@ export async function getSharedWebsite(shareId: string) { } export async function getWebsites( - criteria: WebsiteFindManyArgs, + criteria: Prisma.WebsiteFindManyArgs, filters: QueryFilters, ): Promise> { const { search } = filters;