diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-organization-selector.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-organization-selector.tsx index 03401988..5d8a663f 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-organization-selector.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-organization-selector.tsx @@ -18,25 +18,21 @@ export default function LayoutOrganizationSelector({ const router = useRouter(); const organization = organizations.find( - (item) => item.slug === params.organizationSlug + (item) => item.id === params.organizationSlug ); - if (!organization) { - return null; - } - return ( item.slug) + .filter((item) => item.id) .map((item) => ({ label: item.name, - value: item.slug, + value: item.id, })) ?? [] } onChange={(value) => { diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout.tsx index 8d825e96..4be142c7 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout.tsx @@ -27,7 +27,7 @@ export default async function AppLayout({ getDashboardsByProjectId(projectId), ]); - if (!organizations.find((item) => item.slug === organizationSlug)) { + if (!organizations.find((item) => item.id === organizationSlug)) { return ( The organization you were looking for could not be found. diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/invites/create-invite.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/invites/create-invite.tsx index e9933856..5de9dfa8 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/invites/create-invite.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/invites/create-invite.tsx @@ -53,6 +53,9 @@ export default function CreateInvite({ projects }: Props) { closeSheet(); router.refresh(); }, + onError() { + toast.error('Failed to invite user'); + }, }); return ( diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/invites/invites.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/invites/invites.tsx index 6d7d5826..ef37b7ca 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/invites/invites.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/invites/invites.tsx @@ -1,6 +1,5 @@ 'use client'; -import { Dot } from '@/components/dot'; import { TooltipComplete } from '@/components/tooltip-complete'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -20,9 +19,9 @@ import { } from '@/components/ui/table'; import { Widget, WidgetHead } from '@/components/widget'; import { api } from '@/trpc/client'; -import { cn } from '@/utils/cn'; import { MoreHorizontalIcon } from 'lucide-react'; import { useRouter } from 'next/navigation'; +import { pathOr } from 'ramda'; import { toast } from 'sonner'; import type { IServiceInvite, IServiceProject } from '@openpanel/db'; @@ -44,10 +43,9 @@ const Invites = ({ invites, projects }: Props) => { - Name + Mail Role Created - Status Access More @@ -66,18 +64,9 @@ interface ItemProps extends IServiceInvite { projects: IServiceProject[]; } -function Item({ - id, - email, - role, - createdAt, - projects, - publicMetadata, - status, - organizationId, -}: ItemProps) { +function Item({ id, email, role, createdAt, projects, meta }: ItemProps) { const router = useRouter(); - const access = (publicMetadata?.access ?? []) as string[]; + const access = pathOr([], ['access'], meta); const revoke = api.organization.revokeInvite.useMutation({ onSuccess() { toast.success(`Invite for ${email} revoked`); @@ -96,17 +85,6 @@ function Item({ {new Date(createdAt).toLocaleDateString()} - - - {status} - {access.map((id) => { const project = projects.find((p) => p.id === id); @@ -136,7 +114,7 @@ function Item({ { - revoke.mutate({ organizationId, invitationId: id }); + revoke.mutate({ memberId: id }); }} > Revoke invite diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/members/members.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/members/members.tsx index 16c786c7..fa9c7b12 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/members/members.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/members/members.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { TooltipComplete } from '@/components/tooltip-complete'; import { Button } from '@/components/ui/button'; import { ComboboxAdvanced } from '@/components/ui/combobox-advanced'; import { @@ -62,10 +63,10 @@ interface ItemProps extends IServiceMember { function Item({ id, - name, + user, role, + organizationId, createdAt, - organization, projects, access: prevAccess, }: ItemProps) { @@ -73,22 +74,35 @@ function Item({ const mutation = api.organization.updateMemberAccess.useMutation(); const revoke = api.organization.removeMember.useMutation({ onSuccess() { - toast.success(`${name} has been removed from the organization`); + toast.success( + `${user?.firstName} has been removed from the organization` + ); router.refresh(); }, onError() { - toast.error(`Failed to remove ${name} from the organization`); + toast.error(`Failed to remove ${user?.firstName} from the organization`); }, }); const [access, setAccess] = useState( prevAccess.map((item) => item.projectId) ); + if (!user) { + return null; + } + return ( - {name} + +
{[user?.firstName, user?.lastName].filter(Boolean).join(' ')}
+
{user?.email}
+
{role} - {new Date(createdAt).toLocaleString()} + + + {new Date(createdAt).toLocaleDateString()} + + { setAccess(newAccess); mutation.mutate({ - userId: id!, - organizationSlug: organization.slug, + userId: user.id, + organizationSlug: organizationId, access: newAccess as string[], }); }} @@ -116,7 +130,7 @@ function Item({ { - revoke.mutate({ organizationId: organization.id, userId: id! }); + revoke.mutate({ organizationId: organizationId, userId: id }); }} > Remove member diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx index 2198bc60..e74e3cd2 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/settings/organization/page.tsx @@ -1,10 +1,10 @@ import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout'; import { FullPageEmptyState } from '@/components/full-page-empty-state'; -import { auth, clerkClient } from '@clerk/nextjs/server'; +import { auth } from '@clerk/nextjs/server'; import { ShieldAlertIcon } from 'lucide-react'; import { notFound } from 'next/navigation'; -import { getOrganizationBySlug } from '@openpanel/db'; +import { db } from '@openpanel/db'; import EditOrganization from './edit-organization'; import InvitesServer from './invites'; @@ -19,25 +19,30 @@ interface PageProps { export default async function Page({ params: { organizationSlug }, }: PageProps) { - const organization = await getOrganizationBySlug(organizationSlug); const session = auth(); - const memberships = await clerkClient.users.getOrganizationMembershipList({ - userId: session.userId!, + const organization = await db.organization.findUnique({ + where: { + id: organizationSlug, + members: { + some: { + userId: session.userId, + }, + }, + }, + include: { + members: { + select: { + role: true, + }, + }, + }, }); if (!organization) { return notFound(); } - const member = memberships.data.find( - (membership) => membership.organization.id === organization.id - ); - - if (!member) { - return notFound(); - } - - const hasAccess = member.role === 'org:admin'; + const hasAccess = organization.members[0]?.role === 'org:admin'; return ( <> diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx index 40dc956d..0f9e0c1f 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx @@ -20,9 +20,7 @@ export default async function Page({ getCurrentProjects(organizationSlug), ]); - const organization = organizations.find( - (org) => org.slug === organizationSlug - ); + const organization = organizations.find((org) => org.id === organizationSlug); if (!organization) { return ( diff --git a/apps/dashboard/src/app/(app)/page.tsx b/apps/dashboard/src/app/(app)/page.tsx index 892a8d8e..4525fd9d 100644 --- a/apps/dashboard/src/app/(app)/page.tsx +++ b/apps/dashboard/src/app/(app)/page.tsx @@ -6,7 +6,7 @@ export default async function Page() { const organizations = await getCurrentOrganizations(); if (organizations.length > 0) { - return redirect(`/${organizations[0]?.slug}`); + return redirect(`/${organizations[0]?.id}`); } return redirect('/onboarding'); diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/page.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/page.tsx index 34783f4f..e403ec87 100644 --- a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/page.tsx +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/page.tsx @@ -12,7 +12,7 @@ type Props = { const Connect = async ({ params: { projectId } }: Props) => { const orgs = await getCurrentOrganizations(); - const organizationSlug = orgs[0]?.slug; + const organizationSlug = orgs[0]?.id; if (!organizationSlug) { throw new Error('No organization found'); } diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/page.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/page.tsx index efe52290..3e724323 100644 --- a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/page.tsx +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/page.tsx @@ -17,7 +17,7 @@ type Props = { const Verify = async ({ params: { projectId } }: Props) => { const orgs = await getCurrentOrganizations(); - const organizationSlug = orgs[0]?.slug; + const organizationSlug = orgs[0]?.id; if (!organizationSlug) { throw new Error('No organization found'); } diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx index a34ff59c..a92d15a3 100644 --- a/apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx +++ b/apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx @@ -112,10 +112,10 @@ const Tracking = ({ value={field.value} items={ organizations - .filter((item) => item.slug) + .filter((item) => item.id) .map((item) => ({ label: item.name, - value: item.slug, + value: item.id, })) ?? [] } onChange={field.onChange} diff --git a/apps/dashboard/src/app/api/clerk/webhook/route.ts b/apps/dashboard/src/app/api/clerk/webhook/route.ts index 2d42a9d8..845cca6b 100644 --- a/apps/dashboard/src/app/api/clerk/webhook/route.ts +++ b/apps/dashboard/src/app/api/clerk/webhook/route.ts @@ -1,10 +1,64 @@ import type { WebhookEvent } from '@clerk/nextjs/server'; +import { pathOr } from 'ramda'; import { AccessLevel, db } from '@openpanel/db'; export async function POST(request: Request) { const payload: WebhookEvent = await request.json(); + if (payload.type === 'user.created') { + const email = payload.data.email_addresses[0]?.email_address; + + if (!email) { + return Response.json( + { message: 'No email address found' }, + { status: 400 } + ); + } + + const user = await db.user.create({ + data: { + id: payload.data.id, + email, + firstName: payload.data.first_name, + lastName: payload.data.last_name, + }, + }); + + const memberships = await db.member.findMany({ + where: { + email, + userId: null, + }, + }); + + for (const membership of memberships) { + const access = pathOr([], ['meta', 'access'], membership); + db.$transaction([ + // Update the member to link it to the user + // This will remove the item from invitations + db.member.update({ + where: { + id: membership.id, + }, + data: { + userId: user.id, + }, + }), + db.projectAccess.createMany({ + data: access + .filter((a) => typeof a === 'string') + .map((projectId) => ({ + organizationSlug: membership.organizationId, + projectId: projectId, + userId: user.id, + level: AccessLevel.read, + })), + }), + ]); + } + } + if (payload.type === 'organizationMembership.created') { const access = payload.data.public_metadata.access; if (Array.isArray(access)) { @@ -12,7 +66,7 @@ export async function POST(request: Request) { data: access .filter((a): a is string => typeof a === 'string') .map((projectId) => ({ - organizationSlug: payload.data.organization.slug!, + organizationSlug: payload.data.organization.slug, projectId: projectId, userId: payload.data.public_user_data.user_id, level: AccessLevel.read, @@ -20,10 +74,37 @@ export async function POST(request: Request) { }); } } + + if (payload.type === 'user.deleted') { + db.$transaction([ + db.user.update({ + where: { + id: payload.data.id, + }, + data: { + deletedAt: new Date(), + firstName: null, + lastName: null, + email: `deleted+${payload.data.id}@openpanel.dev`, + }, + }), + db.projectAccess.deleteMany({ + where: { + userId: payload.data.id, + }, + }), + db.member.deleteMany({ + where: { + userId: payload.data.id, + }, + }), + ]); + } + if (payload.type === 'organizationMembership.deleted') { await db.projectAccess.deleteMany({ where: { - organizationSlug: payload.data.organization.slug!, + organizationSlug: payload.data.organization.slug, userId: payload.data.public_user_data.user_id, }, }); diff --git a/packages/db/prisma/migrations/20240615125828_add_orgs_memebers_and_users/migration.sql b/packages/db/prisma/migrations/20240615125828_add_orgs_memebers_and_users/migration.sql new file mode 100644 index 00000000..7b4e14dd --- /dev/null +++ b/packages/db/prisma/migrations/20240615125828_add_orgs_memebers_and_users/migration.sql @@ -0,0 +1,53 @@ +-- AlterTable +ALTER TABLE "projects" ADD COLUMN "organizationId" TEXT; + +-- CreateTable +CREATE TABLE "organizations" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "createdByUserId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "organizations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL DEFAULT gen_random_uuid(), + "email" TEXT NOT NULL, + "first_name" TEXT, + "last_name" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "members" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "role" TEXT NOT NULL, + "email" TEXT NOT NULL, + "userId" TEXT, + "organizationId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "members_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- AddForeignKey +ALTER TABLE "organizations" ADD CONSTRAINT "organizations_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "members" ADD CONSTRAINT "members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "members" ADD CONSTRAINT "members_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "projects" ADD CONSTRAINT "projects_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20240615130029_user_camel_case/migration.sql b/packages/db/prisma/migrations/20240615130029_user_camel_case/migration.sql new file mode 100644 index 00000000..26d96eb6 --- /dev/null +++ b/packages/db/prisma/migrations/20240615130029_user_camel_case/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to drop the column `first_name` on the `users` table. All the data in the column will be lost. + - You are about to drop the column `last_name` on the `users` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "users" DROP COLUMN "first_name", +DROP COLUMN "last_name", +ADD COLUMN "firstName" TEXT, +ADD COLUMN "lastName" TEXT; diff --git a/packages/db/prisma/migrations/20240615195851_add_meta_to_members/migration.sql b/packages/db/prisma/migrations/20240615195851_add_meta_to_members/migration.sql new file mode 100644 index 00000000..99ddd7ed --- /dev/null +++ b/packages/db/prisma/migrations/20240615195851_add_meta_to_members/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "members" ADD COLUMN "invitedById" TEXT, +ADD COLUMN "meta" JSONB; + +-- AddForeignKey +ALTER TABLE "members" ADD CONSTRAINT "members_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20240615205817_add_deleted_flag/migration.sql b/packages/db/prisma/migrations/20240615205817_add_deleted_flag/migration.sql new file mode 100644 index 00000000..1cc9ae39 --- /dev/null +++ b/packages/db/prisma/migrations/20240615205817_add_deleted_flag/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "deletedAt" TIMESTAMP(3); diff --git a/packages/db/prisma/migrations/20240615213456_add_organization_id_to_the_rest_of_the_models/migration.sql b/packages/db/prisma/migrations/20240615213456_add_organization_id_to_the_rest_of_the_models/migration.sql new file mode 100644 index 00000000..c459139a --- /dev/null +++ b/packages/db/prisma/migrations/20240615213456_add_organization_id_to_the_rest_of_the_models/migration.sql @@ -0,0 +1,23 @@ +-- AlterTable +ALTER TABLE "clients" ADD COLUMN "organizationId" TEXT; + +-- AlterTable +ALTER TABLE "dashboards" ADD COLUMN "organizationId" TEXT; + +-- AlterTable +ALTER TABLE "project_access" ADD COLUMN "organizationId" TEXT; + +-- AlterTable +ALTER TABLE "shares" ADD COLUMN "organizationId" TEXT; + +-- AddForeignKey +ALTER TABLE "project_access" ADD CONSTRAINT "project_access_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "clients" ADD CONSTRAINT "clients_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "shares" ADD CONSTRAINT "shares_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20240616172839_add_user_to_project_access/migration.sql b/packages/db/prisma/migrations/20240616172839_add_user_to_project_access/migration.sql new file mode 100644 index 00000000..1f59e052 --- /dev/null +++ b/packages/db/prisma/migrations/20240616172839_add_user_to_project_access/migration.sql @@ -0,0 +1,2 @@ +-- AddForeignKey +ALTER TABLE "project_access" ADD CONSTRAINT "project_access_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 6cd60d43..2685d248 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -17,10 +17,63 @@ enum ProjectType { backend } +model Organization { + id String @id @default(dbgenerated("gen_random_uuid()")) + name String + projects Project[] + members Member[] + createdByUserId String? + createdBy User? @relation(fields: [createdByUserId], references: [id]) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + ProjectAccess ProjectAccess[] + Client Client[] + Dashboard Dashboard[] + ShareOverview ShareOverview[] + + @@map("organizations") +} + +model User { + id String @id @default(dbgenerated("gen_random_uuid()")) + email String @unique + firstName String? + lastName String? + createdOrganizations Organization[] + membership Member[] + sentInvites Member[] @relation("invitedBy") + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + deletedAt DateTime? + ProjectAccess ProjectAccess[] + + @@map("users") +} + +model Member { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + role String + email String + // userId is nullable because we want to allow invites to be sent to emails that are not registered + userId String? + user User? @relation(fields: [userId], references: [id]) + invitedById String? + invitedBy User? @relation("invitedBy", fields: [invitedById], references: [id]) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id]) + meta Json? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@map("members") +} + model Project { id String @id @default(dbgenerated("gen_random_uuid()")) name String organizationSlug String + organization Organization? @relation(fields: [organizationId], references: [id]) + organizationId String? eventsCount Int @default(0) types ProjectType[] @default([]) @@ -47,14 +100,17 @@ enum AccessLevel { } model ProjectAccess { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid projectId String - project Project @relation(fields: [projectId], references: [id]) + project Project @relation(fields: [projectId], references: [id]) organizationSlug String + organization Organization? @relation(fields: [organizationId], references: [id]) + organizationId String? userId String + user User @relation(fields: [userId], references: [id]) level AccessLevel - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt @@map("project_access") } @@ -105,13 +161,15 @@ enum ClientType { } model Client { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid name String secret String? - type ClientType @default(write) + type ClientType @default(write) projectId String? - project Project? @relation(fields: [projectId], references: [id]) + project Project? @relation(fields: [projectId], references: [id]) organizationSlug String + organization Organization? @relation(fields: [organizationId], references: [id]) + organizationId String? cors String? createdAt DateTime @default(now()) @@ -139,11 +197,13 @@ enum ChartType { } model Dashboard { - id String @id @default(dbgenerated("gen_random_uuid()")) + id String @id @default(dbgenerated("gen_random_uuid()")) name String organizationSlug String + organization Organization? @relation(fields: [organizationId], references: [id]) + organizationId String? projectId String - project Project @relation(fields: [projectId], references: [id]) + project Project @relation(fields: [projectId], references: [id]) reports Report[] createdAt DateTime @default(now()) @@ -195,14 +255,16 @@ model Waitlist { } model ShareOverview { - id String @unique - projectId String @unique - project Project @relation(fields: [projectId], references: [id]) + id String @unique + projectId String @unique + project Project @relation(fields: [projectId], references: [id]) organizationSlug String - public Boolean @default(false) + organization Organization? @relation(fields: [organizationId], references: [id]) + organizationId String? + public Boolean @default(false) password String? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt @@map("shares") } diff --git a/packages/db/src/services/organization.service.ts b/packages/db/src/services/organization.service.ts index 4b6d15a8..bff28ca0 100644 --- a/packages/db/src/services/organization.service.ts +++ b/packages/db/src/services/organization.service.ts @@ -1,24 +1,22 @@ -import type { - Organization, - OrganizationInvitation, - OrganizationMembership, -} from '@clerk/nextjs/dist/types/server'; -import { auth, clerkClient } from '@clerk/nextjs/server'; -import { sort } from 'ramda'; +import { auth } from '@clerk/nextjs/server'; -import type { ProjectAccess } from '../prisma-client'; +import type { Organization, Prisma, ProjectAccess } from '../prisma-client'; import { db } from '../prisma-client'; export type IServiceOrganization = ReturnType; -export type IServiceInvite = ReturnType; -export type IServiceMember = ReturnType; +export type IServiceInvite = Prisma.MemberGetPayload<{ + include: { user: true }; +}>; +export type IServiceMember = Prisma.MemberGetPayload<{ + include: { user: true }; +}> & { access: ProjectAccess[] }; export type IServiceProjectAccess = ProjectAccess; export function transformOrganization(org: Organization) { return { id: org.id, + slug: org.id, name: org.name, - slug: org.slug!, createdAt: org.createdAt, }; } @@ -26,20 +24,28 @@ export function transformOrganization(org: Organization) { export async function getCurrentOrganizations() { const session = auth(); if (!session.userId) return []; - const organizations = await clerkClient.users.getOrganizationMembershipList({ - userId: session.userId, + const organizations = await db.organization.findMany({ + where: { + members: { + some: { + userId: session.userId, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, }); - return sort( - (a, b) => a.createdAt - b.createdAt, - organizations.data.map((item) => transformOrganization(item.organization)) - ); + + return organizations.map(transformOrganization); } export function getOrganizationBySlug(slug: string) { - return clerkClient.organizations - .getOrganization({ slug }) - .then(transformOrganization) - .catch(() => null); + return db.organization.findUnique({ + where: { + id: slug, + }, + }); } export async function getOrganizationByProjectId(projectId: string) { @@ -47,63 +53,42 @@ export async function getOrganizationByProjectId(projectId: string) { where: { id: projectId, }, + include: { + organization: true, + }, }); - return clerkClient.organizations.getOrganization({ - slug: project.organizationSlug, - }); -} + if (!project.organization) { + return null; + } -export function transformInvite(invite: OrganizationInvitation) { - return { - id: invite.id, - organizationId: invite.organizationId, - email: invite.emailAddress, - role: invite.role, - status: invite.status, - createdAt: invite.createdAt, - updatedAt: invite.updatedAt, - publicMetadata: invite.publicMetadata, - }; + return transformOrganization(project.organization); } export async function getInvites(organizationSlug: string) { - const org = await getOrganizationBySlug(organizationSlug); - if (!org) return []; - return await clerkClient.organizations - .getOrganizationInvitationList({ - organizationId: org.id, - }) - .then((invites) => invites.data.map(transformInvite)); -} - -export function transformMember( - item: OrganizationMembership & { - access: IServiceProjectAccess[]; - } -) { - return { - memberId: item.id, - id: item.publicUserData?.userId, - name: - [item.publicUserData?.firstName, item.publicUserData?.lastName] - .filter(Boolean) - .join(' ') || 'Unknown', - role: item.role, - createdAt: item.createdAt, - updatedAt: item.updatedAt, - publicMetadata: item.publicMetadata, - organization: transformOrganization(item.organization), - access: item.access, - }; + return db.member.findMany({ + where: { + organizationId: organizationSlug, + userId: null, + }, + include: { + user: true, + }, + }); } export async function getMembers(organizationSlug: string) { - const org = await getOrganizationBySlug(organizationSlug); - if (!org) return []; const [members, access] = await Promise.all([ - clerkClient.organizations.getOrganizationMembershipList({ - organizationId: org.id, + db.member.findMany({ + where: { + organizationId: organizationSlug, + userId: { + not: null, + }, + }, + include: { + user: true, + }, }), db.projectAccess.findMany({ where: { @@ -112,22 +97,17 @@ export async function getMembers(organizationSlug: string) { }), ]); - return members.data - .map((member) => { - const projectAccess = access.filter( - (item) => item.userId === member.publicUserData?.userId - ); - return { - ...member, - access: projectAccess, - }; - }) - .map(transformMember); + return members.map((member) => ({ + ...member, + access: access.filter((a) => a.userId === member.userId), + })); } export async function getMember(organizationSlug: string, userId: string) { - const org = await getOrganizationBySlug(organizationSlug); - if (!org) return null; - const members = await getMembers(org.id); - return members.find((member) => member.id === userId) ?? null; + return db.member.findFirst({ + where: { + organizationId: organizationSlug, + userId, + }, + }); } diff --git a/packages/db/src/services/project.service.ts b/packages/db/src/services/project.service.ts index 7a2a7a79..7f05ac56 100644 --- a/packages/db/src/services/project.service.ts +++ b/packages/db/src/services/project.service.ts @@ -58,15 +58,34 @@ export async function getCurrentProjects(organizationSlug: string) { return []; } - return db.project.findMany({ - where: { - organizationSlug, - }, - include: { - access: true, - }, - orderBy: { - eventsCount: 'desc', - }, - }); + const [projects, members, access] = await Promise.all([ + db.project.findMany({ + where: { + organizationSlug, + }, + }), + db.member.findMany({ + where: { + userId: session.userId, + organizationId: organizationSlug, + }, + }), + db.projectAccess.findMany({ + where: { + userId: session.userId, + }, + }), + ]); + + if (members.length === 0) { + return []; + } + + if (access.length > 0) { + return projects.filter((project) => + access.some((a) => a.projectId === project.id) + ); + } + + return projects; } diff --git a/packages/db/src/services/user.service.ts b/packages/db/src/services/user.service.ts index 2adee8ec..7f1deb64 100644 --- a/packages/db/src/services/user.service.ts +++ b/packages/db/src/services/user.service.ts @@ -1,18 +1,7 @@ -import type { User } from '@clerk/nextjs/dist/types/server'; -import { auth, clerkClient } from '@clerk/nextjs/server'; +import { auth } from '@clerk/nextjs/server'; import { db } from '../prisma-client'; -export function transformUser(user: User) { - return { - name: `${user.firstName} ${user.lastName}`, - email: user.emailAddresses[0]?.emailAddress ?? '', - id: user.id, - lastName: user.lastName ?? '', - firstName: user.firstName ?? '', - }; -} - export async function getCurrentUser() { const session = auth(); if (!session.userId) { @@ -22,20 +11,9 @@ export async function getCurrentUser() { } export async function getUserById(id: string) { - return clerkClient.users.getUser(id).then(transformUser); -} - -export async function isWaitlistUserAccepted() { - const user = await getCurrentUser(); - const waitlist = await db.waitlist.findFirst({ + return db.user.findUnique({ where: { - email: user?.email, + id, }, }); - - if (!waitlist) { - return false; - } - - return waitlist.accepted; } diff --git a/packages/trpc/src/access.ts b/packages/trpc/src/access.ts index a956f128..75b6ee19 100644 --- a/packages/trpc/src/access.ts +++ b/packages/trpc/src/access.ts @@ -1,6 +1,6 @@ import { clerkClient } from '@clerk/fastify'; -import { getProjectById } from '@openpanel/db'; +import { db, getProjectById } from '@openpanel/db'; import { cacheable } from '@openpanel/redis'; export const getProjectAccessCached = cacheable(getProjectAccess, 60 * 60); @@ -13,20 +13,19 @@ export async function getProjectAccess({ }) { try { // Check if user has access to the project - const [project, organizations] = await Promise.all([ - getProjectById(projectId), - clerkClient.users.getOrganizationMembershipList({ - userId, - }), - ]); - - if (!project) { + const project = await getProjectById(projectId); + if (!project?.organizationSlug) { return false; } - return !!organizations.data.find( - (org) => org.organization.slug === project.organizationSlug - ); + const member = await db.member.findFirst({ + where: { + organizationId: project.organizationSlug, + userId, + }, + }); + + return member; } catch (err) { return false; } @@ -43,11 +42,10 @@ export async function getOrganizationAccess({ userId: string; organizationId: string; }) { - const organizations = await clerkClient.users.getOrganizationMembershipList({ - userId, + return db.member.findFirst({ + where: { + userId, + organizationId, + }, }); - - return !!organizations.data.find( - (org) => org.organization.id === organizationId - ); } diff --git a/packages/trpc/src/routers/onboarding.ts b/packages/trpc/src/routers/onboarding.ts index 219703e7..146ce870 100644 --- a/packages/trpc/src/routers/onboarding.ts +++ b/packages/trpc/src/routers/onboarding.ts @@ -1,9 +1,8 @@ import { randomUUID } from 'crypto'; -import { clerkClient } from '@clerk/fastify'; import type { z } from 'zod'; import { hashPassword, slug, stripTrailingSlash } from '@openpanel/common'; -import { db, getId } from '@openpanel/db'; +import { db, getId, getOrganizationBySlug } from '@openpanel/db'; import type { ProjectType } from '@openpanel/db'; import { zOnboardingProject } from '@openpanel/validation'; @@ -14,16 +13,16 @@ async function createOrGetOrganization( userId: string ) { if (input.organizationSlug) { - return await clerkClient.organizations.getOrganization({ - slug: input.organizationSlug, - }); + return await getOrganizationBySlug(input.organizationSlug); } if (input.organization) { - return await clerkClient.organizations.createOrganization({ - name: input.organization, - slug: slug(input.organization), - createdBy: userId, + return db.organization.create({ + data: { + id: slug(input.organization), + name: input.organization, + createdByUserId: userId, + }, }); } @@ -44,7 +43,7 @@ export const onboardingRouter = createTRPCRouter({ ctx.session.userId ); - if (!organization?.slug) { + if (!organization?.id) { throw new Error('Organization slug is missing'); } @@ -52,7 +51,7 @@ export const onboardingRouter = createTRPCRouter({ data: { id: await getId('project', input.project), name: input.project, - organizationSlug: organization.slug, + organizationSlug: organization.id, types, }, }); @@ -61,7 +60,7 @@ export const onboardingRouter = createTRPCRouter({ const client = await db.client.create({ data: { name: `${project.name} Client`, - organizationSlug: organization.slug, + organizationSlug: organization.id, projectId: project.id, type: 'write', cors: input.domain ? stripTrailingSlash(input.domain) : null, diff --git a/packages/trpc/src/routers/organization.ts b/packages/trpc/src/routers/organization.ts index 1c2de159..d2f2f82e 100644 --- a/packages/trpc/src/routers/organization.ts +++ b/packages/trpc/src/routers/organization.ts @@ -1,4 +1,5 @@ import { clerkClient } from '@clerk/fastify'; +import { pathOr } from 'ramda'; import { z } from 'zod'; import { db, getOrganizationBySlug } from '@openpanel/db'; @@ -7,18 +8,6 @@ import { zInviteUser } from '@openpanel/validation'; import { createTRPCRouter, protectedProcedure } from '../trpc'; export const organizationRouter = createTRPCRouter({ - list: protectedProcedure.query(() => { - return clerkClient.organizations.getOrganizationList(); - }), - get: protectedProcedure - .input( - z.object({ - id: z.string(), - }) - ) - .query(({ input }) => { - return getOrganizationBySlug(input.id); - }), update: protectedProcedure .input( z.object({ @@ -27,42 +16,63 @@ export const organizationRouter = createTRPCRouter({ }) ) .mutation(({ input }) => { - return clerkClient.organizations.updateOrganization(input.id, { - name: input.name, + return db.organization.update({ + where: { + id: input.id, + }, + data: { + name: input.name, + }, }); }), inviteUser: protectedProcedure .input(zInviteUser) .mutation(async ({ input, ctx }) => { - const organization = await getOrganizationBySlug(input.organizationSlug); - - if (!organization) { - throw new Error('Organization not found'); - } - - return clerkClient.organizations.createOrganizationInvitation({ - organizationId: organization.id, + const ticket = await clerkClient.invitations.createInvitation({ emailAddress: input.email, - role: input.role, - inviterUserId: ctx.session.userId, - publicMetadata: { - access: input.access, + notify: true, + }); + + return db.member.create({ + data: { + email: input.email, + organizationId: input.organizationSlug, + role: input.role, + invitedById: ctx.session.userId, + meta: { + access: input.access, + invitationId: ticket.id, + }, }, }); }), revokeInvite: protectedProcedure .input( z.object({ - organizationId: z.string(), - invitationId: z.string(), + memberId: z.string(), }) ) - .mutation(async ({ input, ctx }) => { - return clerkClient.organizations.revokeOrganizationInvitation({ - organizationId: input.organizationId, - invitationId: input.invitationId, - requestingUserId: ctx.session.userId, + .mutation(async ({ input }) => { + const member = await db.member.findUniqueOrThrow({ + where: { + id: input.memberId, + }, + }); + const invitationId = pathOr( + undefined, + ['meta', 'invitationId'], + member + ); + + if (invitationId) { + await clerkClient.invitations.revokeInvitation(invitationId); + } + + return db.member.delete({ + where: { + id: input.memberId, + }, }); }), @@ -77,25 +87,21 @@ export const organizationRouter = createTRPCRouter({ if (ctx.session.userId === input.userId) { throw new Error('You cannot remove yourself from the organization'); } - const organization = await clerkClient.organizations.getOrganization({ - organizationId: input.organizationId, - }); - if (!organization?.slug) { - throw new Error('Organization not found'); - } - - await db.projectAccess.deleteMany({ - where: { - userId: input.userId, - organizationSlug: organization.slug, - }, - }); - - return clerkClient.organizations.deleteOrganizationMembership({ - organizationId: input.organizationId, - userId: input.userId, - }); + await db.$transaction([ + db.member.deleteMany({ + where: { + userId: input.userId, + organizationId: input.organizationId, + }, + }), + db.projectAccess.deleteMany({ + where: { + userId: input.userId, + organizationSlug: input.organizationId, + }, + }), + ]); }), updateMemberAccess: protectedProcedure diff --git a/packages/trpc/src/routers/ticket.ts b/packages/trpc/src/routers/ticket.ts index a65ada58..3f6c8fef 100644 --- a/packages/trpc/src/routers/ticket.ts +++ b/packages/trpc/src/routers/ticket.ts @@ -1,7 +1,8 @@ -import { clerkClient } from '@clerk/fastify'; import { SeventySevenClient } from '@seventy-seven/sdk'; import { z } from 'zod'; +import { getUserById } from '@openpanel/db'; + import { createTRPCRouter, protectedProcedure } from '../trpc'; const API_KEY = process.env.SEVENTY_SEVEN_API_KEY!; @@ -21,14 +22,16 @@ export const ticketRouter = createTRPCRouter({ throw new Error('Ticket system not configured'); } - const user = await clerkClient.users.getUser(ctx.session.userId); + const user = await getUserById(ctx.session.userId); return client.createTicket({ subject: input.subject, body: input.body, meta: input.meta, - senderEmail: user.primaryEmailAddress?.emailAddress || 'none', - senderFullName: user.fullName || 'none', + senderEmail: user?.email || 'none', + senderFullName: user?.firstName + ? [user?.firstName, user?.lastName].filter(Boolean).join(' ') + : 'none', }); }), }); diff --git a/packages/trpc/src/routers/user.ts b/packages/trpc/src/routers/user.ts index c9b8739f..08379283 100644 --- a/packages/trpc/src/routers/user.ts +++ b/packages/trpc/src/routers/user.ts @@ -1,7 +1,7 @@ import { clerkClient } from '@clerk/fastify'; import { z } from 'zod'; -import { transformUser } from '@openpanel/db'; +import { db } from '@openpanel/db'; import { createTRPCRouter, protectedProcedure } from '../trpc'; @@ -13,12 +13,23 @@ export const userRouter = createTRPCRouter({ lastName: z.string(), }) ) - .mutation(({ input, ctx }) => { - return clerkClient.users - .updateUser(ctx.session.userId, { + .mutation(async ({ input, ctx }) => { + const [updatedUser] = await Promise.all([ + db.user.update({ + where: { + id: ctx.session.userId, + }, + data: { + firstName: input.firstName, + lastName: input.lastName, + }, + }), + clerkClient.users.updateUser(ctx.session.userId, { firstName: input.firstName, lastName: input.lastName, - }) - .then(transformUser); + }), + ]); + + return updatedUser; }), });